fix(commands): the removal gate was classified against the wrong root

Found by review after the SUB-WRITE commit, and both defects were in the
template rather than the engine every prediction in the fasit was about.

`--repo` is what a write target is classified AGAINST. The template passed the
SCAN target, and under `--global` that target IS ~/.claude -- so
~/.claude/CLAUDE.md matched `in-repo` and the gate went `silent`. Measured
against the real config: gate silent, scopeClass in-repo, 29 removals applied
with no approval asked. That is the same silent downgrade #62 measured for a
naive .git-upward walk, arriving through a different door, on the one target
this chunk was sequenced behind M-BUG-41 to protect. Every other gated template
already passed `--repo "$PWD"`; this one was the only outlier.

The dry run also could not validate the machine-wide case -- the case that is
mandatory in v1. The gate returned before any file was read, so a dry run there
reported 29 scope-gate refusals and zero checked spans, and the first run able
to find a stale approval would have been the one that writes. A gate guards a
WRITE, and a dry run is not one: `requiresApproval` and the disclosures are
still reported, so the operator is still asked.

The new caller-arm guard was itself red against the corrected template, matching
prose that merely NAMES the CLI. Narrowed to lines that invoke it.

Guards seen red against the original defects: `--repo "<target-path>"` red,
`--repo` omitted red, gate-blocks-dry-run red. Re-dogfooded as the template now
calls it: require-ok / user-scope / 29 spans validated / 0 files written.

Suite 1659 -> 1662/0. Frozen baselines untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017A6vrtPKsVuM4DJ27p7jzw
This commit is contained in:
Kjell Tore Guttormsen 2026-08-10 06:15:25 +02:00
commit 33bfd5ff5b
5 changed files with 93 additions and 4 deletions

View file

@ -76,6 +76,30 @@ test('every caller dry-runs before it writes', async () => {
}
});
test('no caller classifies the write against the path it scanned', async () => {
// Measured (#63): `--repo "<target-path>"` under `--global` hands the CLI
// `~/.claude` as the session root, so `~/.claude/CLAUDE.md` classifies
// `in-repo` and the gate drops to `silent` — 29 removals applied with no
// approval asked. `--repo` is what a target is classified AGAINST; it is the
// session's own root. Every other gated template already passes `$PWD`.
for (const { name, content } of await commandsDrivingTheRemovalCli()) {
for (const line of content.split('\n')) {
// Invocations only. Prose that merely names the CLI (the Notes section
// explaining why removal is not a `fix` action) carries no argv, and
// matching it made this guard red against its own fixed template.
if (!/^node\s.*subtraction-write-cli\.mjs/.test(line.trim())) continue;
const repoArg = line.match(/--repo\s+("[^"]*"|\S+)/);
assert.ok(repoArg, `${name} must pass --repo explicitly on every removal-CLI invocation.`);
assert.equal(
repoArg[1],
'"$PWD"',
`${name} passes ${repoArg[1]} as --repo. Anything but the session root can classify a\n` +
'machine-wide target as in-repo and silently downgrade the strongest gate on this axis.',
);
}
}
});
test('every caller surfaces the scope gate in the user\'s words', async () => {
for (const { name, content } of await commandsDrivingTheRemovalCli()) {
assert.match(