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

@ -43,7 +43,8 @@
*/
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { stat } from 'node:fs/promises';
import { writeOutputFile } from './lib/write-output.mjs';
import {
createLedger,
addRepo,
@ -89,6 +90,23 @@ function parseArgs(argv) {
return { subcommand: positionals[0], rest: positionals.slice(1), flags };
}
/**
* Can this path actually be read as a repo right now?
*
* `readActiveConfig` resolves any string and its sub-readers all tolerate ENOENT, so a repo
* that does not exist yields an EMPTY config instead of an error. Without this check the
* sweep records a phantom repo as successfully swept with 0 tokens, and the machine-wide
* bill claims coverage it does not have. A missing path is reported, never rejected an
* unmounted volume is a legitimate reason for a tracked repo to be absent today.
*/
async function isReadableRepoDir(path) {
try {
return (await stat(path)).isDirectory();
} catch {
return false;
}
}
/** Load an existing ledger, treating a parse error as a hard failure (never clobber corrupt data). */
async function loadOrFail(path) {
try {
@ -100,7 +118,7 @@ async function loadOrFail(path) {
async function emit(payload, outputFile, exitCode) {
const json = JSON.stringify(payload, null, 2);
if (outputFile) await writeFile(outputFile, json, 'utf-8');
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
else process.stdout.write(json + '\n');
process.exitCode = exitCode;
}
@ -146,6 +164,7 @@ async function main() {
let ledger = loaded === null ? createLedger({ now }) : loaded;
const added = [];
const addedUnverified = [];
const skipped = [];
for (const p of paths) {
const resolved = resolve(p);
@ -153,13 +172,17 @@ async function main() {
// --name applies only to a lone path; multi-add lets the lib derive each basename.
const name = paths.length === 1 ? flags.name || undefined : undefined;
ledger = addRepo(ledger, { path: p, name }, { now });
(present ? skipped : added).push(resolved);
if (present) skipped.push(resolved);
else if (await isReadableRepoDir(resolved)) added.push(resolved);
// Tracked either way, but never silently vouched for: the command reports these
// separately so a typo does not become a permanent phantom row in the backlog.
else addedUnverified.push(resolved);
}
await saveLedger(ledgerPath, ledger);
return emit(
{
status: 'ok', action: 'add', written: true, autoInitialized, ledgerPath,
added, skipped, repos: ledger.repos, rollUp: rollUp(ledger),
added, addedUnverified, skipped, repos: ledger.repos, rollUp: rollUp(ledger),
},
flags.outputFile,
0,
@ -216,6 +239,13 @@ async function main() {
let sharedSummary = null;
for (const repo of ledger0.repos) {
// Check readability FIRST: readActiveConfig returns an empty config for a path that
// does not exist rather than throwing, so the catch below would never see it and the
// repo would be recorded as swept with a 0-token delta — a bill that looks complete.
if (!(await isReadableRepoDir(repo.path))) {
skipped.push({ path: repo.path, reason: 'repo path is not readable (does not exist or is not a directory)' });
continue;
}
let split;
try {
const activeConfig = await readActiveConfig(repo.path, { verbose: false });