| status | Accepted | ||||
|---|---|---|---|---|---|
| date | 2026-03-30 | ||||
| deciders |
|
||||
| related |
|
The ways tooling has grown organically across multiple languages and entry points:
| Tool | Language | Lines | Function |
|---|---|---|---|
way-match |
C | ~920 | BM25 scoring |
way-embed |
C++ | ~920 | Embedding match (ONNX/GGUF) |
generate-corpus.sh |
Bash | ~200 | Corpus generation |
lint-ways.sh |
Bash | ~530 | Frontmatter validation |
way-tree-analyze.sh |
Bash | ~300 | Tree structure analysis |
embed-lib.sh |
Bash | ~200 | Shared utilities |
embed-suggest.sh |
Bash | ~100 | Embedding suggestions |
provenance-scan.py |
Python | ~150 | Provenance scanning |
governance.sh |
Bash | ~540 | Governance orchestration |
| Various others | Bash | ~500 | Misc utilities |
Every tool re-walks the same directory tree and re-parses the same frontmatter. Adding a new feature (e.g., the graph generator from ADR-110) means writing yet another script that duplicates file discovery, YAML extraction, and JSON emission. The bash scripts also carry macOS bash 3.2 compatibility constraints that a compiled binary eliminates.
The gh CLI, aws CLI, and gcloud CLI demonstrate the pattern: one binary, subcommands for everything, shared infrastructure for common operations.
A single ways binary replaces all current tooling with subcommands:
ways lint [path] # frontmatter validation (lint-ways.sh)
ways corpus [--global] # corpus generation (generate-corpus.sh)
ways match <query> # BM25 scoring (way-match)
ways embed <query> # embedding match (way-embed)
ways siblings <id> # way-vs-way cosine scoring (new, ADR-110 §5)
ways graph [--format jsonl]# graph export (new, ADR-110 §4)
ways tree <path> # tree analysis (way-tree-analyze.sh)
ways provenance # provenance scanning (provenance-scan.py)
The entire CLI is pure Rust — no FFI, no C/C++ compilation. BM25 scoring was reimplemented natively (~176 lines in bm25.rs). Embedding matching delegates to the existing way-embed binary via subprocess (the embedding engine requires GGUF/ONNX runtime which remains a separate C++ binary).
The original ADR planned FFI wrappers via the cc crate, but the BM25 C code was small enough to port directly. This eliminated cross-compilation complexity entirely — cargo build produces the binary with no native toolchain required beyond Rust.
tools/ways-cli/
├── Cargo.toml
├── src/
│ ├── main.rs # clap dispatcher (19 subcommands)
│ ├── cmd/
│ │ ├── scan/ # prompt/command/file/state matching
│ │ ├── show/ # session-aware way display
│ │ ├── governance/ # 9 governance query modes (7 files)
│ │ ├── lint.rs # frontmatter validation
│ │ ├── corpus.rs # corpus generation
│ │ ├── match_bm25.rs # BM25 scoring (pure Rust)
│ │ ├── embed.rs # embedding match (delegates to way-embed)
│ │ ├── list.rs # session way list with forecast
│ │ ├── context.rs # token usage from transcript
│ │ ├── reset.rs # session state recovery
│ │ └── ... # graph, tree, provenance, stats, etc.
│ ├── bm25.rs # BM25 engine (Porter2 stemming, IDF)
│ ├── scanner.rs # shared: file discovery by frontmatter
│ ├── frontmatter.rs # shared: YAML frontmatter parsing
│ ├── session.rs # session state (directory-per-session)
│ ├── table.rs # ANSI-aware table formatting
│ └── util.rs # shared utilities (home_dir, project detection)
├── tests/
│ └── session_sim.rs # 8 integration scenarios
└── download-ways.sh # pre-built binary installer
Subcommands ship independently. The order follows dependency:
| Phase | Subcommands | Replaces | Status |
|---|---|---|---|
| 1 | lint, corpus, graph |
lint-ways.sh, generate-corpus.sh, new | Shipped |
| 2 | match, embed, siblings |
way-match (ported to Rust), way-embed (subprocess) | Shipped |
| 3 | tree, provenance, scan, show, governance, context, list, stats, reset, init, status, suggest |
All remaining scripts | Shipped |
All three phases delivered as pure Rust. BM25 was ported rather than wrapped via FFI. Embedding delegates to the existing way-embed binary. The scan and show subcommands absorbed the hook orchestration that was previously spread across show-core.sh, show-way.sh, match-way.sh, and check-prompt.sh.
The binary installs to ~/.claude/bin/ways with a symlink to ~/.local/bin/ways. Three install paths:
- Download —
download-ways.shpulls pre-built binary from GitHub Releases (no toolchain needed) - Build from source —
cargo build --release(requires Rust toolchain) make install— tries download first, falls back to build
CI builds 4 platforms (linux-x86_64, linux-aarch64, darwin-x86_64, darwin-arm64) via cargo-zigbuild for ARM cross-compilation. Tagged releases (ways-v*) create GitHub Releases with checksums.
The 10 remaining hook scripts are thin dispatchers — they parse hook JSON input and call ways scan or read session state. The orchestration, matching, and display logic lives entirely in the binary.
BM25 was ported to pure Rust (176 lines in bm25.rs). The embedding engine (way-embed) remains a separate C++ binary because it depends on llama.cpp for GGUF model inference. The ways embed subcommand delegates to way-embed via subprocess.
Future option: the ort crate could replace the C++ embedding binary with a pure Rust ONNX path, eliminating the last subprocess dependency. This is not planned — the current approach works and the embedding binary is stable.
- Single binary, single install, single update path
- Shared file scanning — one tree walk serves all subcommands
- Shared frontmatter parsing — one YAML parser, tested once
- New features (graph, siblings) are subcommands, not new scripts
- macOS bash 3.2 compatibility concerns eliminated for ported logic
ways --helpgives discoverability across all tooling- Shell completion for free via
clap
- Rust toolchain required for development (not for end users — pre-built binaries available)
- CI cross-compiles for 4 platforms via
cargo-zigbuild(working, but adds build complexity) - Porting bash to Rust took more lines for the same functionality (mitigated: type system caught bugs the bash scripts silently swallowed)
governance.sh(543 lines),provenance-verify.sh, andcontext-usage.shwere deleted — fully replaced byways governance,ways governance lint, andways context- 10 bash hook scripts remain as thin dispatchers (15-30 lines each) — they parse hook JSON and call
ways scan Makefilehasmake ways,make ways-rebuild,make install,make releasetargets- CI builds changed from "compile C, compile C++, run bash" to "cargo build, run tests"
- Session state moved from flat
/tmp/.claude-*-{uuid}markers to per-user/tmp/.claude-sessions-{uid}/{session_id}/directories
Go's cobra library is excellent for CLIs and cross-compilation is normally trivial. However, ONNX Runtime is a C library — Go requires CGo to call it. CGo cross-compilation for 4 platforms requires Docker-based toolchains or zig-cc as a C cross-compiler, negating Go's primary advantage. The CGo boundary is also more awkward than Rust's cc crate integration.
Porting the C/C++ inference code to Rust risks subtle behavioral differences in numerics-sensitive paths (BM25 scoring, embedding normalization). The ort crate for ONNX is solid but static linking of ONNX Runtime remains uneven across platforms. The incremental approach (Rust shell + C/C++ FFI) gets to a working binary faster and keeps the option open.
C++ is the right language for the inference engine but the wrong language for directory walking, YAML parsing, CLI dispatch, and JSON emission — which is 80% of the work. Libraries like yaml-cpp and CLI11 exist but are a step down from serde_yaml and clap. The bash scripts exist precisely because C++ was too high-friction for the scripting layer.
Same FFI benefit as Rust + cc, but CGo cross-compilation is harder than Rust's cargo-zigbuild, and maintaining three languages (Go + C + C++) is worse than two (Rust + C/C++).
| ADR | Interaction |
|---|---|
| ADR-014 | way-match binary becomes ways match subcommand. BM25 algorithm unchanged |
| ADR-107 | Corpus generation becomes ways corpus. Locale support (Phase 3) becomes a flag |
| ADR-108 | way-embed binary becomes ways embed subcommand. ONNX/GGUF loading unchanged |
| ADR-110 | Graph export (ways graph) and sibling scoring (ways siblings) ship as subcommands rather than standalone scripts |
The attend binary (ADR-113) initially shipped a hand-rolled argv dispatcher with per-command help text written as free-form println! calls. As the surface grew to ~14 subcommands, the lack of a uniform help/argument-parsing layer started to bite — --help worked on some commands, errored as "unknown subcommand" on others, and silently ran the command on a third group. Rather than introduce a parallel CLI convention, attend adopts the clap-derive structure established here: a Cli struct, a Commands enum, doc comments as the source of truth for help text, and the same agent_fmt::Banner special-casing for bare invocation. External reference docs (docs/cli/attend.md) are generated from the same Cli definition via clap-markdown, wired into the project Makefile so the runtime help text and the published reference cannot drift. This decision retroactively confirms the ADR-111 pattern as the canonical CLI shape across the workspace.