feat(ways): project-scope per-way enable/disable (ADR-131)#100
Conversation
Adds per-way, project-scope toggles via `{project}/.claude/ways.yaml`:
ways:
itops/incident: false
meta/introspection:
enabled: false
Convenience CLI:
ways disable <name> # writes .claude/ways.yaml
ways disable --list # show disabled ways in this project
ways enable <name> # remove the entry (or drop ways: block)
Project scope only — no global per-way disable. Default state is
enabled, so a project with no overlay behaves exactly as today. The
existing user-scope `disabled_domains` knob is retained unchanged for
the "never anywhere" case.
Enforcement parallels the domain disable gate:
- Rust `ways scan` and `ways show` consult `session::way_disabled()`
- Bash subagent injector parses the `ways:` block with awk and skips
matched paths alongside the existing domain check
Includes round-trip tests for the YAML editor (preserves comments and
unrelated keys) and the config parser (shorthand + long-form,
project-scope-only, future-reserved sub-keys ignored).
Code review — ADR-131Two blocking correctness defects found end-to-end, plus a few smaller issues. The ADR, doc updates, Rust enforcement at the two gates, and the round-trip tests all look solid; the failure mode is concentrated in the boundary between the line-based YAML editor ( Blocker 1 — Editing a hand-edited 4-space overlay corrupts the fileWhere:
Reproduced end-to-end: ``` $ ways disable itops/incident $ cat .claude/ways.yaml $ ways config show The user's thresholds, language, and both disabled ways silently revert to defaults. The CLI never surfaces the parse error — only Suggested fixes (any one closes the blocker; doing 1+2 is safer):
The probe that produced these results is on the side; happy to share. Tests that would catch this regression: ```rust #[test] Blocker 2 — Awk parser silently misses YAML shapes the Rust parser acceptsWhere: The awk parser only recognizes the exact shape the Rust writer emits: 2-space block style, key on its own line for long-form,
The impact: the same Suggested fix: The simplest correctness-preserving approach is to stop parsing YAML in bash and delegate. Two options:
If shelling out is unacceptable for cold-start reasons, at minimum:
But the divergence problem is structural — any future YAML feature the user picks up from Issue 3 —
|
| Input | Outcome |
|---|---|
\" foo\" (leading space) |
writer emits foo: false→ parses as key" foo"`, never matches any real way |
\"foo bar\" (internal space) |
parses as \"foo bar\", never matches |
\"foo#x\" |
parses on Rust side, awk strips it → cross-gate divergence (see Blocker 2) |
\"foo/./bar\" |
parses, never matches a real way |
\"foo\\nbar\" after a literal \\n in argv would already be blocked, but a raw newline in shell-passed args isn't always blocked depending on shell quoting |
Real way names are constrained to ^[a-z0-9_-]+(/[a-z0-9_-]+)*$ (look at the corpus — they're all lowercase ASCII with - and _, separated by /). I'd suggest:
```rust
fn validate_way_name(name: &str) -> Result<()> {
let valid_char = |c: char| c.is_ascii_alphanumeric() || c == '' || c == '-' || c == '/';
if name.is_empty() { anyhow::bail!("way name cannot be empty"); }
if !name.chars().all(valid_char) {
anyhow::bail!("way name must contain only [a-zA-Z0-9-/]");
}
if name.starts_with('/') || name.ends_with('/') || name.contains("//") {
anyhow::bail!("way name must not start/end with '/' or contain '//'");
}
if name.contains("..") || name.contains("./") {
anyhow::bail!("way name must not contain '..' or './'");
}
Ok(())
}
```
This fails closed on every garbage input above and removes any risk of YAML-special characters (:, #, \", ', \\, &, *, !) reaching the writer.
Issue 4 — Clap should express the mutually-exclusive args
Where: tools/ways-cli/src/main.rs:602-614 and Commands::Disable at line 246-252.
Currently:
ways disable(no args) →eprintln + exit 2. OK.ways disable --list itops/incident→ silently ignores the positional, runslist(). Empirically confirmed — exit 0, no warning, user thinks the way was disabled and moves on.
Expressing it in clap surfaces the misuse:
```rust
Disable {
/// Way ID to disable (mutually exclusive with --list)
#[arg(required_unless_present = "list", conflicts_with = "list")]
name: Option,
/// List currently disabled ways in this project
#[arg(long)]
list: bool,
},
```
Then ways disable --list itops/incident produces clap's standard "the argument '--list' cannot be used with '[name]'" error with exit 2 — same exit code, but the user sees why. Worth the change because the silent-ignore failure mode here compounds with Blocker 1 (both are "CLI reports success, nothing happened").
Issue 5 — Project-scope invariant is structurally fragile
Where: tools/ways-cli/src/config.rs:38, 92-115
The invariant "only apply_project_ways_overlay populates disabled_ways" is currently enforced only by the fact that apply_yaml doesn't read the ways: key. The test apply_yaml_does_not_populate_disabled_ways (line 411) is a good guard but ADR-131 explicitly contemplates future per-way threshold overrides reading the same ways: block — at which point someone may innocently extend apply_yaml to read ways: and break the invariant.
disabled_ways: Vec<String> is pub, so any code in the crate can push to it without going through the overlay parser. Not a blocker (you have the guard test), but worth noting that the ADR's structural promise ("project scope only") is enforced by convention, not by type.
Possible hardening (optional, not blocking):
- Make
disabled_waysprivate with a single setterpopulate_disabled_ways_from_project_overlay(&str)that's the only code path that can write it. - Or: track a
disabled_ways_source: Option<PathBuf>next to the field, and panic in debug if it's ever set from a non-project layer. Loud failures during tests would prove the invariant.
What looks solid
- ADR-131 itself is exemplary — explicit alternatives, trust-model reasoning, forward-compat note for long-form schema.
apply_project_ways_overlay(config.rs:198-221) is clean — handles both shorthand and long-form, dedupes correctly, ignores future-reserved subkeys per the schema's intent. Theenabled: trueno-op test (line 370) is the right kind of negative test.- Enforcement at the Rust gates (
scan/candidates.rs:71,show/mod.rs:23and:173) is two-line, symmetric, and matches the existingdomain_disabledpattern — easy to audit. session::way_disabled(line 445) is a one-liner mirroringdomain_disabled. Consistent shape.- Doc updates in
extending.md/itops.md/meta.mdcorrectly position project-scope-per-way against the existing global-domain disable, with the right "never anywhere vs. not in this project" framing. cmd/disable.rsoverall structure — helpers are well-named and single-purpose (validate_way_name,way_exists,read_or_empty,is_disabled,rewrite_block,find_block_end,find_entry,block_is_empty). Three public entry points. 385 lines total with ~95 of tests is fine; not overengineered.is_disabledroutes through the realapply_project_ways_overlay_publicso writer and reader can never disagree on what the writer wrote. Excellent self-consistency check.
Suggested merge order
- Blockers 1 + 2 — both can be addressed with the same fix (detect indent from existing block + delegate bash to
ways). Add the YAML-validity assertion as a regression test. - Issue 3 (validation tightening) — small diff.
- Issue 4 (clap) — small diff, big UX win.
- Issue 5 (invariant hardening) — optional, file a follow-up issue if you'd rather land the ADR-131 surface first.
Findings reproduced from a local probe against the branch — happy to attach the probe source if useful.
Two blockers + three follow-ups from the code-reviewer pass: **B1 — 4-space overlay corruption (cmd/disable.rs)** - find_block_end now detects the existing block's child indent from its first entry; find_entry and the writer reuse that indent so a hand-edited 4-space overlay stays 4-space after rewrite. Previously the writer hardcoded 2 spaces and produced mixed-indent (invalid) YAML, which apply_yaml would then silently drop on next load. - write_overlay pre-flights output through serde_yaml::from_str and refuses to write if the result is invalid. Prevents corruption even if the writer regresses later. - find_entry rejects prefix matches (itops/incident must not match itops/incident-secondary). - New tests: 4-space disable preserves indent, 4-space enable removes entry, 4-space long-form replacement, prefix-collision guard. **B2 — Awk vs Rust parser divergence (inject-subagent.sh)** - Replaced the awk YAML parser with a single delegated call to `ways disable --list --names-only`. The bash gate now sees exactly what the Rust config parser sees — eliminates the cross-path bug class where a way is disabled in one execution path but not the other (4-space shorthand, 4-space long-form, flow style, keys containing '#' were all reproducible divergences). **I3 — validate_way_name too permissive (cmd/disable.rs)** - Restricted to ^[a-z0-9_-]+(/[a-z0-9_-]+)*$ per segment. Blocks whitespace, '#', '"', '\\', uppercase, and dot-segments — anything that wouldn't resolve at runtime or would slip past the writer. **I4 — clap should reject `--list <name>` (main.rs)** - Added required_unless_present + conflicts_with on the name arg, and --names-only requires --list. clap now produces a clean usage error instead of silently running list() and ignoring the name. **I5 — Project-scope invariant made structural (config.rs)** - disabled_ways downgraded from pub to pub(crate); added a disabled_ways() accessor for external readers. A future contributor adding per-way knobs to user-scope apply_yaml would have to also touch this field, which sits next to a load-bearing doc comment — turns a conventional invariant into a structural one.
Follow-up review on f206325All blockers and follow-ups verified fixed. Mergeable. B1 — 4-space overlay corruption: fixed
B2 — Awk parser divergence: fixed
I3 —
|
Summary
ways disable <name>/ways enable <name>— project-scope toggle for a single way, writes{project}/.claude/ways.yaml.disabled_waysconfig field, populated only from project overlay (per ADR-131: project scope, no global per-way disabler).ways scan/ways showviasession::way_disabled(), bashinject-subagent.shvia an awk parser of theways:block.ADR
See
docs/architecture/system/ADR-131-project-scope-way-toggles.mdfor context, schema, trust model, and alternatives considered.Test plan
cargo test— 106 unit + 11 integration tests pass (8 new disable.rs tests, 7 new config.rs tests including a guard thatapply_yamlcannot populatedisabled_ways).ways disable,ways disable --list,ways enable, with multi-way + comment-preservation paths exercised.enabled: true(no-op) + trailing comments.cmd/disable.rs(especially block_is_empty / find_entry indentation handling).Notes
disabled_domainsuser-scope mechanism is untouched — both gates stack (domain-disable OR way-disable → skip).ways:block (line-based, not a full re-serialize).