A command template is a caller with no compiler behind it. It names a scanner and
an argv; nothing checked that the scanner still accepts them. M-BUG-45 measured
what that costs: `--stale-after` arrived malformed, was ignored, and the command
reported "all 14 entries re-verified within the last 90 days" about a threshold
the user had just overridden.
The new guard builds the argv from each template's OWN text (#63 — a hand-typed
call is a path no user takes), reading all three forms a flag appears in,
including the comment-only `GLOBAL_FLAG="" # --global`; that third form is the
one that dies unobserved, since the default path leaves the variable empty.
Measured: 38 invocations, 54 (CLI, flag) pairs, 15 CLIs, 0 dead scanner paths.
Two premises in the plan text were falsified by measuring:
- "the flag exists in the CLI's BOOL_FLAGS/VALUE_FLAGS" — only 3 of 34 scanner
files declare such a surface. The contract is checked on BEHAVIOUR instead:
run the CLI, ask whether it calls the flag unknown.
- `--full-machine` was predicted dead on `posture`. It is live. The fasit was
wrong, not the code.
What the measurement found instead: `campaign-export-cli` was the only one of the
fifteen without the shared `requireValidArgs` gate. Its hand-rolled chain guards
every value branch with `argv[i + 1] !== undefined`, so a trailing `--repo` fell
past all of them to the `startsWith('--')` catch-all and was reported as an
unknown flag — about the flag the CLI itself requires. Classification of "value
flag, no value" across all fifteen: 14 correct, 1 wrong. It now uses ARG_SPEC +
requireValidArgs like the other twelve; valid argv reaches the existing loop
byte-for-byte unchanged. Special-casing it in the test would have rebuilt, in
test code, the prose exception Q1 deleted.
And what the guard itself got wrong, which is worse than what it was looking for:
probing a flag means RUNNING the CLI, and some flags are writers. Its first run
let `drift-cli --save` default its target to the working directory and overwrite
the operator's real ~/.config-audit/baselines/default.json — an ungated write
outside the repo, produced by the guard whose whole subject is ungated writes
outside the repo. Every probe now runs under hermeticEnv() with its own empty
cwd, and the cwd is asserted empty afterwards. Isolation that is only a
convention is not isolation. Side effect: 65s -> 13s, because a hermetic HOME
stops every probe from enumerating ~/.claude.
All six arms seen RED against their own defect, twice — including the ORIGINAL
class (remove --approve-scope from fix-cli) and the plan's own verification
(delete the write-scope-cli line from a template). The non-emptiness arm is
derived from the tree, not pinned to a count that would only be a drift point.
Suite 1707 -> 1724, frozen v5.0.0 + default-output snapshots 0 changed files.
Not fixed here, found while verifying and pre-existing at 749b710: the suite was
NOT green on HEAD. output-file-robustness fails on drift-cli, root cause
diff-engine.mjs:194 — `m.from.severity` where `m.from` is undefined in the moved
section of the drift report. It crashes after the scan, in formatting, so the
CLI exits 3 with no output file. Its own chunk, not this one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pj6UoTi6iPsAB2B2j6EZ1k
222 lines
9.5 KiB
JavaScript
222 lines
9.5 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';
|
|
import { evaluateWriteTargets } from './lib/write-scope.mjs';
|
|
import { requireValidArgs } from './lib/cli-args.mjs';
|
|
|
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
/**
|
|
* Flag surface. This was the last hand-rolled parser of the fifteen CLIs the
|
|
* command layer calls, and the only one that misclassified its own argv: the
|
|
* chain below guards each value flag with `argv[i + 1] !== undefined`, so a
|
|
* trailing `--repo` fell past every branch to the `startsWith('--')` catch-all
|
|
* and was reported as an **unknown flag** — about the one flag this CLI
|
|
* requires. The shared gate runs first and names the real fault; valid argv
|
|
* reaches the loop below byte-for-byte unchanged.
|
|
*/
|
|
const ARG_SPEC = {
|
|
boolean: ['--write', '--approve-scope'],
|
|
value: ['--repo', '--ledger-file', '--sessions-dir', '--reference-date', '--output-file', '--session-root'],
|
|
};
|
|
|
|
/**
|
|
* 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, approveScope: false, sessionRoot: null };
|
|
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 === '--approve-scope') flags.approveScope = true;
|
|
else if (a === '--session-root' && argv[i + 1] !== undefined) flags.sessionRoot = argv[++i];
|
|
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 args = process.argv.slice(2);
|
|
if (!requireValidArgs(args, ARG_SPEC)) return;
|
|
|
|
const flags = parseArgs(args);
|
|
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,
|
|
});
|
|
|
|
// Q1 — the scope gate, in code rather than in commands/campaign.md's prose.
|
|
//
|
|
// `--repo` here names the repo being EXPORTED TO, which is the write target's
|
|
// repo, not the session's. The session root is where the operator stands, so
|
|
// it comes from `--session-root` (default cwd) — reading it off `--repo`
|
|
// would make every export look "in-repo" and silence the gate by
|
|
// construction (#63).
|
|
//
|
|
// Export into another project is `cross-repo`, whose gate is `disclose`, NOT
|
|
// `require-ok`: campaign export is cross-repo BY DESIGN, and tightening it
|
|
// into a refusal breaks the feature. So the disclosure always rides in the
|
|
// payload, and only a `require-ok` class (machine-wide config, or a path in
|
|
// no project at all) actually withholds the write.
|
|
const scope = evaluateWriteTargets([targetPath], resolve(flags.sessionRoot ?? process.cwd()));
|
|
|
|
if (flags.write && scope.requiresApproval && !flags.approveScope) {
|
|
return emit(
|
|
{ status: 'refused', action: 'export', reason: 'scope-gate', repo: repoInfo,
|
|
sessionId: repo.sessionId, sourcePlanPath, exportable: true, problems: [],
|
|
written: false, targetPath, gate: scope.gate, requiresApproval: true,
|
|
disclosures: scope.disclosures },
|
|
flags.outputFile,
|
|
0,
|
|
);
|
|
}
|
|
|
|
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,
|
|
gate: scope.gate, requiresApproval: scope.requiresApproval,
|
|
disclosures: scope.disclosures },
|
|
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;
|
|
});
|
|
}
|