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
173 lines
7 KiB
JavaScript
173 lines
7 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* campaign-export-cli — export a tracked repo's action plan into that repo's own `docs/`
|
|
* (v5.7 Fase 2, Block 4c).
|
|
*
|
|
* Block 4b built the cross-repo prioritized backlog; this is the "plan export" half of Block 4c.
|
|
* Given a repo tracked in the campaign ledger, it resolves the repo's linked config-audit
|
|
* session, reads that session's `action-plan.md`, and assembles (via the pure
|
|
* `campaign-export` lib) a `docs/config-audit-plan-<sessionId>.md` document carrying a
|
|
* provenance header + the verbatim plan ("planer følger arbeidsstedet").
|
|
*
|
|
* Read-only by DEFAULT (a dry-run preview that returns the assembled `document` + `targetPath`
|
|
* so the command can show the user what will be written). The actual write happens ONLY under
|
|
* the opt-in `--write` flag — which the `/config-audit campaign` command invokes solely after
|
|
* explicit human approval (Verifiseringsplikt — nothing auto-written). Writing the file
|
|
* faithfully (a byte-exact copy of the assembled document) is the CLI's job, not the LLM's, so
|
|
* a 200-line plan is never re-typed and cannot drift.
|
|
*
|
|
* Execution is NOT here: Block 4c reuses the existing `/config-audit implement` (backup +
|
|
* apply + verify) + `/config-audit rollback`. This CLI only exports the durable record.
|
|
*
|
|
* Naming: `-cli` suffix → NOT an orchestrated scanner, so the scanner count is unchanged and
|
|
* the snapshot suite stays byte-stable.
|
|
*
|
|
* Usage:
|
|
* node campaign-export-cli.mjs --repo <path> [--write]
|
|
* [--ledger-file <p>] [--sessions-dir <p>] [--reference-date <YYYY-MM-DD>] [--output-file <p>]
|
|
*
|
|
* Exit codes: 0 = exportable (preview ready, or written under --write),
|
|
* 1 = advisory: repo tracked but not exportable yet (no linked session / no plan),
|
|
* 3 = error (missing --repo, untracked repo, no/corrupt ledger, unreadable plan).
|
|
*/
|
|
|
|
import { resolve, join, dirname } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
import { writeOutputFile } from './lib/write-output.mjs';
|
|
import {
|
|
loadLedger,
|
|
validateLedger,
|
|
defaultLedgerPath,
|
|
} from './lib/campaign-ledger.mjs';
|
|
import { planExportPath, buildPlanExportDocument } from './lib/campaign-export.mjs';
|
|
|
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
/**
|
|
* Usage error. Throws rather than calling process.exit(): exit() discards
|
|
* unflushed stdout when stdout is a pipe. The top-level catch prints the same
|
|
* `Error: ` text and sets the same exit code 3, so callers see no difference.
|
|
*/
|
|
class CliUsageError extends Error {}
|
|
|
|
function fail(message) {
|
|
throw new CliUsageError(message);
|
|
}
|
|
|
|
/** Default session store: next to the ledger, OUTSIDE the plugin dir. */
|
|
function defaultSessionsDir() {
|
|
return join(homedir(), '.claude', 'config-audit', 'sessions');
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const flags = { repo: null, ledgerFile: null, sessionsDir: null, referenceDate: null, outputFile: null, write: false };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === '--repo' && argv[i + 1] !== undefined) flags.repo = argv[++i];
|
|
else if (a === '--ledger-file' && argv[i + 1] !== undefined) flags.ledgerFile = argv[++i];
|
|
else if (a === '--sessions-dir' && argv[i + 1] !== undefined) flags.sessionsDir = argv[++i];
|
|
else if (a === '--reference-date' && argv[i + 1] !== undefined) flags.referenceDate = argv[++i];
|
|
else if (a === '--output-file' && argv[i + 1] !== undefined) flags.outputFile = argv[++i];
|
|
else if (a === '--write') flags.write = true;
|
|
else if (a.startsWith('--')) fail(`unknown flag "${a}"`);
|
|
else fail(`unexpected argument "${a}"`);
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
async function emit(payload, outputFile, exitCode) {
|
|
const json = JSON.stringify(payload, null, 2);
|
|
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
|
|
else process.stdout.write(json + '\n');
|
|
process.exitCode = exitCode;
|
|
}
|
|
|
|
async function main() {
|
|
const flags = parseArgs(process.argv.slice(2));
|
|
if (!flags.repo) fail('--repo <path> is required');
|
|
if (flags.referenceDate && !DATE_RE.test(flags.referenceDate)) fail('--reference-date must be YYYY-MM-DD');
|
|
|
|
const ledgerPath = resolve(flags.ledgerFile || defaultLedgerPath());
|
|
const sessionsDir = resolve(flags.sessionsDir || defaultSessionsDir());
|
|
const repoPath = resolve(flags.repo);
|
|
// The clock is read here ONLY — passed to the pure lib as the injected `now`.
|
|
const now = flags.referenceDate || new Date().toISOString().slice(0, 10);
|
|
|
|
let ledger;
|
|
try {
|
|
ledger = await loadLedger(ledgerPath);
|
|
} catch (err) {
|
|
fail(`could not read ledger at ${ledgerPath}: ${err.message}`);
|
|
}
|
|
if (ledger === null) fail(`no campaign ledger at ${ledgerPath} — run "/config-audit campaign init" first`);
|
|
|
|
const { valid, errors } = validateLedger(ledger);
|
|
if (!valid) fail(`ledger at ${ledgerPath} is invalid:\n - ${errors.join('\n - ')}`);
|
|
|
|
const repo = ledger.repos.find((r) => r.path === repoPath);
|
|
if (!repo) fail(`repo "${repoPath}" is not tracked in the campaign — add it first`);
|
|
|
|
const repoInfo = { path: repo.path, name: repo.name, status: repo.status, sessionId: repo.sessionId ?? null };
|
|
|
|
// Gate 1: the repo must have a linked session (set via `set-status … --session <id>`).
|
|
if (typeof repo.sessionId !== 'string' || repo.sessionId.trim() === '') {
|
|
return emit(
|
|
{ status: 'ok', action: 'export', repo: repoInfo, exportable: false, problems: ['no-session-linked'],
|
|
written: false, targetPath: null, document: null },
|
|
flags.outputFile,
|
|
1,
|
|
);
|
|
}
|
|
|
|
// Gate 2: that session must carry an action-plan.md (i.e. `/config-audit plan` has run).
|
|
const sourcePlanPath = join(sessionsDir, repo.sessionId, 'action-plan.md');
|
|
let planMarkdown;
|
|
try {
|
|
planMarkdown = await readFile(sourcePlanPath, 'utf-8');
|
|
} catch (err) {
|
|
if (err && err.code === 'ENOENT') {
|
|
return emit(
|
|
{ status: 'ok', action: 'export', repo: repoInfo, sessionId: repo.sessionId, sourcePlanPath,
|
|
exportable: false, problems: ['no-action-plan'], written: false, targetPath: null, document: null },
|
|
flags.outputFile,
|
|
1,
|
|
);
|
|
}
|
|
fail(`could not read action plan at ${sourcePlanPath}: ${err.message}`);
|
|
}
|
|
|
|
const targetPath = planExportPath(repo.path, repo.sessionId);
|
|
const document = buildPlanExportDocument({
|
|
repoName: repo.name,
|
|
repoPath: repo.path,
|
|
sessionId: repo.sessionId,
|
|
planMarkdown,
|
|
now,
|
|
});
|
|
|
|
let written = false;
|
|
if (flags.write) {
|
|
await mkdir(dirname(targetPath), { recursive: true });
|
|
await writeFile(targetPath, document, 'utf-8');
|
|
written = true;
|
|
}
|
|
|
|
return emit(
|
|
{ status: 'ok', action: 'export', repo: repoInfo, sessionId: repo.sessionId, sourcePlanPath,
|
|
exportable: true, problems: [], written, targetPath, document },
|
|
flags.outputFile,
|
|
0,
|
|
);
|
|
}
|
|
|
|
const isDirectRun =
|
|
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
|
if (isDirectRun) {
|
|
main().catch((err) => {
|
|
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
|
|
process.stderr.write(`${prefix}: ${err.message}\n`);
|
|
process.exitCode = 3;
|
|
});
|
|
}
|