`write-scope.mjs` has existed since M-BUG-41, but only one writer ever called it. Measured 2026-08-12: 9 files under `scanners/` write to disk, 1 imported the gate; 21 command templates, 17 mention a write, 5 call `write-scope-cli`. Five templates paraphrasing one policy is the shape that put the lever table in five copies (#61) — one level up. The defect was never "8 ungated writers = 8 bugs". Four of them write the plugin's own bookkeeping and must STAY ungated: a gate that fires on every run gets switched off, and then it guards nothing. The defect is that nothing declared WHICH, so the question was answered by reading, and answered differently each time it was asked. `tests/lib/write-gate-coverage.test.mjs` makes the answer structural: every writer either imports the gate or holds an EXEMPT entry naming where the bytes land. Seen RED against today's tree before the fix (4 ungated writers), and each of its four assertions was separately seen red against its own defect. Two premises in the plan text were falsified by measuring them first: - `scan-orchestrator` was carried as "plugin-managed, legitimately exempt". `--save-baseline` derives its path from the SCAN TARGET, so `--global` lands `~/.claude/.config-audit-baseline.json` — user-scope, require-ok. It is gated. `lib/baseline.mjs` is the genuinely exempt one. - the first sweep scored 9 writers with a regex that could not match `writeFileSync(`, so `lib/backup.mjs` — a real writer — read as clean. The guard covers sync and async forms, strips comments before matching, and asserts non-emptiness so a regex that stops matching cannot make every other assertion vacuously green (#63, #64). Gated: fix-engine, rollback-engine, campaign-export-cli, scan-orchestrator. All five call sites share ONE reduction, `evaluateWriteTargets` — four copies of classify/strongestGate/dedup is the drift this exists to prevent. `campaign export` still DISCLOSES rather than refuses: cross-repo is by design there, and tightening it into a refusal would break the feature. A dry run is still not a write, so it is never gated (#63). A refusal is a verdict about a config that WAS examined, so it rides in the payload and keeps the 0/1/2 exit contract (#62) — and the verdict now reaches the success payload too, since stderr is discarded by `2>/dev/null` (F3's class). commands/fix.md carries `--approve-scope` from the answer the user gives, with the rule stated where it can be read: classifying is not approving. Dogfooded end to end: a target outside the session root refuses with zero bytes written, then applies under `--approve-scope`. Suite 1703 -> 1707/0. Frozen v5.0.0 + default-output snapshots: 0 changed files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pkn22uGCgk6QZA738zNmHL
204 lines
8.7 KiB
JavaScript
204 lines
8.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';
|
|
import { evaluateWriteTargets } from './lib/write-scope.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, 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 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,
|
|
});
|
|
|
|
// 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;
|
|
});
|
|
}
|