feat(campaign): plan export + execution-by-reuse (v5.7 Fase 2 Block 4c)
Completes Block 4 (4b backlog + 4c export/execution). Asymmetric: plan
export is new testable code; execution is pure reuse of the existing
per-repo implement/rollback (no new execution machinery), per the plan's
"reuse existing backup/rollback".
Plan export ("planer følger arbeidsstedet"):
- scanners/lib/campaign-export.mjs (pure, now injected, 8 tests):
planExportPath(repo,sessionId) -> <repo>/docs/config-audit-plan-<sessionId>.md
(sessionId-keyed so same-day re-audits never collide);
buildPlanExportDocument({...,now}) -> provenance header + verbatim plan.
- scanners/campaign-export-cli.mjs (-cli, read-only by default, 10 tests):
--repo resolves the repo's linked session, reads its action-plan.md,
assembles the doc, emits {exportable,problems,targetPath,document}. Two
gates -> exit 1 advisory: no-session-linked / no-action-plan. Writes the
file ONLY under opt-in --write (byte-faithful copy; the LLM never re-types
a 200-line plan). --sessions-dir override for hermetic tests; exit 0/1/3.
Command: commands/campaign.md gains an `export <path>` mode (Step 6:
preview -> approve -> --write), then routes the user to the existing
/config-audit implement (backup + verify) + rollback + set-status
implemented. Nothing auto-written (Verifiseringsplikt).
Byte-stable: lib + -cli + command-doc only -> scanner count stays 15,
agents 7, commands 21 (export is a mode, not a new command), SC-5 +
backcompat suite untouched. suite 1150->1168. Block 4a (migrateLedger)
still deferred to the first breaking schema change.
Docs: CLAUDE.md section + badge 1150->1168/65->67 files; README badge +
campaign row + Testing prose (fixed stale 1055/59 -> true 1168/67).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
49833aded8
commit
319e5541c9
7 changed files with 575 additions and 22 deletions
165
scanners/campaign-export-cli.mjs
Normal file
165
scanners/campaign-export-cli.mjs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
#!/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 {
|
||||
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}$/;
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
/** 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 writeFile(outputFile, json, 'utf-8');
|
||||
else process.stdout.write(json + '\n');
|
||||
process.exit(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) => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
78
scanners/lib/campaign-export.mjs
Normal file
78
scanners/lib/campaign-export.mjs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* campaign-export — plan-export transforms (v5.7 Fase 2, Block 4c).
|
||||
*
|
||||
* The second half of Block 4 ("durable backlog + execution"). Block 4b built the cross-repo
|
||||
* prioritized backlog the user picks from; this exports a picked repo's per-repo action plan
|
||||
* into the TARGET repo's OWN `docs/` directory, so the plan gets a durable, human-readable
|
||||
* home where the work is done ("planer følger arbeidsstedet" — the operator's continuity rule
|
||||
* that plans live next to the workplace, in `docs/`).
|
||||
*
|
||||
* Design mirrors campaign-ledger: PURE, deterministic transforms — `now` is injected as a
|
||||
* YYYY-MM-DD string, never read from the clock here, so they are fully unit-testable. The IO
|
||||
* (loading the ledger, reading the session's action-plan.md, writing the exported file) lives
|
||||
* in the thin `campaign-export-cli` shell. The transforms throw on programmer error
|
||||
* (missing/blank required field), consistent with the ledger transforms.
|
||||
*
|
||||
* NOTE on execution: Block 4c deliberately adds NO new execution machinery. Execution reuses
|
||||
* the existing per-repo `/config-audit implement` (which backs up every changed file, applies
|
||||
* the plan from the session, and verifies) + `/config-audit rollback`. The exported `docs/`
|
||||
* copy is the repo's durable record of the plan, NOT the execution input — `implement` still
|
||||
* reads the canonical plan from the session directory. See docs/v5.7-optimization-lens-plan.md
|
||||
* §Fase 2 (Block 4).
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* The exported plan's destination inside the TARGET repo's own `docs/`. Keyed on the source
|
||||
* `sessionId` (timestamp-unique per audit) rather than the calendar date, so two audits of the
|
||||
* same repo on the same day produce distinct files (history is preserved, never silently
|
||||
* overwritten) and the filename ties the export back to the audit that produced it.
|
||||
*
|
||||
* @param {string} repoPath - absolute path to the target repo (the ledger stores it resolved)
|
||||
* @param {string} sessionId - the config-audit session that produced the plan
|
||||
* @returns {string} `<repoPath>/docs/config-audit-plan-<sessionId>.md`
|
||||
*/
|
||||
export function planExportPath(repoPath, sessionId) {
|
||||
if (typeof repoPath !== 'string' || repoPath.trim() === '') {
|
||||
throw new TypeError('repoPath is required');
|
||||
}
|
||||
if (typeof sessionId !== 'string' || sessionId.trim() === '') {
|
||||
throw new TypeError('sessionId is required');
|
||||
}
|
||||
return join(repoPath, 'docs', `config-audit-plan-${sessionId}.md`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the exported document: a provenance header (who/when/where this came from + how to
|
||||
* execute and undo it) followed by the verbatim session plan body. Pure — given the same inputs
|
||||
* it always produces the same bytes, so it is snapshot-testable.
|
||||
*
|
||||
* @param {{repoName:string, repoPath:string, sessionId:string, planMarkdown:string, now:string}} input
|
||||
* @returns {string} the full markdown to write into the repo's docs/
|
||||
*/
|
||||
export function buildPlanExportDocument({ repoName, repoPath, sessionId, planMarkdown, now } = {}) {
|
||||
for (const [k, v] of Object.entries({ repoName, repoPath, sessionId, planMarkdown, now })) {
|
||||
if (typeof v !== 'string' || v.trim() === '') {
|
||||
throw new TypeError(`${k} is required`);
|
||||
}
|
||||
}
|
||||
|
||||
const header = [
|
||||
`# Config-Audit Action Plan — ${repoName}`,
|
||||
'',
|
||||
`> Exported from the config-audit machine-wide campaign on ${now}.`,
|
||||
`> **Repo:** \`${repoPath}\``,
|
||||
`> **Source session:** \`${sessionId}\``,
|
||||
'>',
|
||||
'> Generated by `/config-audit plan`. To **execute**: run `/config-audit implement` in this',
|
||||
'> repo — it backs up every changed file, applies the plan, then verifies the result. To',
|
||||
'> **undo**: `/config-audit rollback`. Record progress back in the campaign with',
|
||||
`> \`/config-audit campaign set-status ${repoPath} implemented\`.`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
return `${header}${planMarkdown.trimEnd()}\n`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue