fix(commands): stop answering questions the caller did not ask

Dogfooding `campaign` + `knowledge-refresh` against a throwaway ledger. Seven
defects, all found by running the commands as written and measuring, not by
reading them.

The headline pair only existed together. `knowledge-refresh` built
`STALE_AFTER="--stale-after 30"` and expanded it unquoted, trusting the shell to
split it in two. bash does; zsh — the macOS default, and what the Bash tool runs
here — does not. The CLI got one argv entry, matched no flag, and because it had
no unknown-flag branch, silently kept the 90-day default and reported "✓ All 14
register entries were re-verified within the last 90 days": a true-sounding
sentence about a threshold the user had just overridden. Fixing either half alone
leaves a silent wrong answer or a loud one; both are fixed, and a guard now
rejects any template that packs a flag and its value into one variable.

`knowledge-refresh` also read one register and wrote another: step 6 named an
unanchored `knowledge/best-practices.json` while the CLI reads
`${CLAUDE_PLUGIN_ROOT}/…`, which for an installed plugin is the cache. The
validation gate then ran the cached test against the cached register — green no
matter what was written. The two copies were byte-identical that day, which is
exactly why it was invisible.

`campaign` vouched for repos it could not read. `add /finnes/ikke` returned
`added` + exit 0; `refresh-tokens` then put the phantom in `swept[]` with a
0-token delta and left `skipped[]` empty, so the machine-wide bill claimed
coverage of three repos on a machine with two. Paths stay tracked — an unmounted
volume is a legitimate absence — but are reported as `addedUnverified`, and the
command names them.

Two class sweeps, both measured rather than assumed. `posture` was the single
scanner (1 of 14) whose fatal catch exited 1, which ux-rules defines as a normal
WARNING grade — a crash indistinguishable from a result. And all 13 payload
writers failed on a `--output-file` whose parent did not exist, which on a fresh
machine turned `campaign`'s first run into "the ledger may be corrupt"; they now
share `scanners/lib/write-output.mjs`.

Predicted breadth was too wide for the first time in five sessions: 6 of 8 CLIs
predicted to lack unknown-flag rejection, 4 measured. `drift` and `fix` already
reject them, via a construct the grep did not recognise — a grep matches an
implementation, the invariant is a behaviour. The sweep was rewritten to run each
CLI with a bogus flag and read the exit code.

Suite 1453 → 1469/0. Frozen snapshots untouched. `optimize-lens-cli` and
`token-hotspots-cli` share the unknown-flag defect and are deferred to the v5.14
argument-handling chunk with their positional-swallow arm; the count is recorded
in the guard rather than rounded down to zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NHWjN8EnoxSqRvMTLK2NE
This commit is contained in:
Kjell Tore Guttormsen 2026-08-01 21:26:39 +02:00
commit caea8aca23
23 changed files with 742 additions and 48 deletions

View file

@ -8,6 +8,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **`M-BUG-45``/config-audit knowledge-refresh --stale-after N` was silently dead under zsh.** The
command built `STALE_AFTER="--stale-after 30"` and expanded it unquoted, relying on the shell to
split it into two argv entries. bash does; **zsh — the macOS default since Catalina — does not**.
The CLI received one argv entry reading `--stale-after 30`, matched no flag, and fell back to the
90-day default while reporting success: "✓ All 14 register entries were re-verified within the last
90 days" — a true-sounding sentence about a threshold the user had just overridden. Measured:
`set -- $STALE_AFTER; echo $#` prints 1 under zsh, 2 under bash. The threshold is now passed as its
own quoted argument, and a guard rejects any command template that packs a flag and its value into
one variable.
- **`M-BUG-46` — four CLIs accepted unknown flags in silence.** No `else` branch at all in the parse
loop, so an unrecognised flag vanished without a trace: a typo'd `--ledger-file` made
`campaign-cli` report confidently on the *default* ledger instead of the one the caller named, and
a mistyped `--stale-after` reverted to 90 days. This is what made `M-BUG-45` silent rather than
loud. `campaign-cli` and `knowledge-refresh-cli` now fail with exit 3 and name the offending flag;
`optimize-lens-cli` and `token-hotspots-cli` share the defect and are closed together with their
positional-swallow arm in the v5.14 argument-handling work (tracked in the guard's `KNOWN_OPEN`).
- **`M-BUG-47` — the machine-wide token bill counted repos it could not read.** `refresh-tokens`
routed a repo to `skipped[]` only when `readActiveConfig` *threw*, but that function resolves any
path and its sub-readers all tolerate ENOENT, so a repo that does not exist yields an empty config
instead of an error. Measured: a phantom path landed in `swept[]` with a 0-token delta,
`skipped[]` was empty, and the roll-up claimed `reposWithTokens: 3` for a machine with two real
repos — so the command's own honesty clause ("name those repos plainly so the user knows the bill
omits them") could never fire. Readability is now checked before the sweep.
- **`M-BUG-48``campaign add` vouched for paths that do not exist.** `add /finnes/ikke` returned
`added: [...]` and exit 0, and the phantom row then sat in the backlog permanently. Paths are still
tracked (an unmounted volume is a legitimate reason for a repo to be absent today) but are now
reported separately as `addedUnverified`, and `campaign.md` names them instead of glossing over them.
- **`M-BUG-49``posture` reported a crash as a passing grade.** Its top-level catch set
`process.exitCode = 1`, while every command in this plugin is told that "codes 0, 1, 2 are normal
(PASS/WARNING/FAIL). Only 3 is a real error". A fatal error was therefore indistinguishable from a
WARNING, and the command went on to Read a payload file that was never written. Measured as the
single outlier: 1 of 14 scanners. Now exits 3.
- **`M-BUG-50``knowledge-refresh` read one register and wrote another, and the gate saw neither.**
Step 6 said `Edit knowledge/best-practices.json` — an unanchored relative path — while the CLI reads
`${CLAUDE_PLUGIN_ROOT}/knowledge/best-practices.json`, which for a marketplace install is the plugin
cache. So a normal user has no such file in their repo at all; in the plugin's own checkout the
command read the cache and wrote the working tree; and step 6.3's validation gate ran the cached
test against the cached register — **validating the copy that was not edited, and passing no matter
what was written.** Every write step is now anchored, and the command states where the register
actually lives (a marketplace copy is discarded on the next plugin upgrade).
- **No scanner created its `--output-file` parent directory.** `saveLedger` always did; the payload
write never did — an accidental asymmetry across all 13 writers. `commands/campaign.md` writes its
report under `~/.claude/config-audit/sessions/`, so on a fresh machine — precisely the first run
that `campaign-cli` otherwise handles gracefully with `initialized: false` — the write threw ENOENT
and the command's exit-code table reported it as a possibly-corrupt ledger, steering the user away
from the one action that would have helped. All payload writes now go through
`scanners/lib/write-output.mjs`.
- **`M-BUG-40`, fifth arm — `posture` wrote four temp files it could never read back.** #49 closed the
`$$`/cross-block class in four commands, but `posture.md` survived it, and so did the guard written
to prevent exactly this. The guard compared each `$$` path against the block that created it, so a