From 319e5541c9ad8313e398c8fc25df23fa426f37b5 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 23 Jun 2026 10:08:04 +0200 Subject: [PATCH] feat(campaign): plan export + execution-by-reuse (v5.7 Fase 2 Block 4c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) -> /docs/config-audit-plan-.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 ` 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) --- CLAUDE.md | 30 +++- README.md | 6 +- commands/campaign.md | 92 +++++++++-- scanners/campaign-export-cli.mjs | 165 ++++++++++++++++++++ scanners/lib/campaign-export.mjs | 78 +++++++++ tests/lib/campaign-export.test.mjs | 72 +++++++++ tests/scanners/campaign-export-cli.test.mjs | 154 ++++++++++++++++++ 7 files changed, 575 insertions(+), 22 deletions(-) create mode 100644 scanners/campaign-export-cli.mjs create mode 100644 scanners/lib/campaign-export.mjs create mode 100644 tests/lib/campaign-export.test.mjs create mode 100644 tests/scanners/campaign-export-cli.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 9203923..1d9166f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ Analyzes and optimizes Claude Code configuration across three pillars: | `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence | | `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) | | `/config-audit knowledge-refresh` | Keep the best-practices register fresh — deterministic stale check (sources older than ~90d) + web candidate poll (CC changelog + Anthropic blog); **human-approved writes only** (Verifiseringsplikt). The "living" half of the knowledge base. Web/judgment-driven, **not byte-stable** | -| `/config-audit campaign` | Machine-wide audit campaign — durable ledger ABOVE sessions: per-repo lifecycle (pending→audited→planned→implemented) + machine-wide roll-up by severity, resumable across sessions. Read-only report (campaign-cli) + **human-approved** writes via a deterministic write-CLI (init/add/set-status). THIN: tracks state, doesn't run audits/fixes. Judgment-driven, **not byte-stable** | +| `/config-audit campaign` | Machine-wide audit campaign — durable ledger ABOVE sessions: per-repo lifecycle (pending→audited→planned→implemented) + machine-wide roll-up by severity + cross-repo prioritized backlog + plan **export** (drop a planned repo's plan into its own `docs/`), resumable across sessions. Read-only report (campaign-cli) + **human-approved** writes via deterministic write/export CLIs. THIN: tracks+routes state, reuses existing implement/rollback for execution. Judgment-driven, **not byte-stable** | | `/config-audit discover` | Run discovery phase only | | `/config-audit analyze` | Run analysis phase only | | `/config-audit interview` | Gather user preferences (opt-in) | @@ -113,7 +113,7 @@ Default: auto-detects scope from git context. Override with `/config-audit full| node --test 'tests/**/*.test.mjs' ``` -1150 tests across 65 test files (21 lib + 34 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`. +1168 tests across 67 test files (22 lib + 35 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`. ### active-config-reader — load-pattern model + rule/agent/output-style enumeration (v5.6 Foundation) @@ -507,6 +507,32 @@ export to each repo's `docs/` + reuse of backup/rollback for execution. **Deferr breaking schema change:** `migrateLedger` (4a) — backlog needs no schema bump, so building migration now would be speculative (`schemaVersion` is already stamped for when it's needed). +### campaign plan-export + execution-by-reuse (v5.7 Fase 2, Block 4c — the rest of Block 4) + +The second half of Block 4. **Asymmetric:** plan export is the new testable code; execution is +*pure reuse* (no new machinery), per the plan's "reuse existing backup/rollback". + +- **Plan export** (`scanners/lib/campaign-export.mjs`, pure, 8 tests): `planExportPath(repoPath, + sessionId)` → `/docs/config-audit-plan-.md` (keyed on the timestamp-unique + sessionId, not the date, so same-day re-audits don't collide); `buildPlanExportDocument({...,now})` + → provenance header (repo/session/how-to-execute-and-undo) + the verbatim session plan. `now` + injected → deterministic. **CLI** `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` (repo has no `sessionId`), `no-action-plan` + (linked session has no plan yet). Writes the file **only** under opt-in `--write` — the CLI does + the byte-faithful copy so a 200-line plan is never re-typed/mutated by the LLM. `--sessions-dir` + override for hermetic tests; exit 0/1/3 mirror the sibling CLIs. +- **Execution = reuse.** No campaign-side execution code. The exported `docs/` file is the repo's + durable record; `/config-audit implement` still reads the canonical plan from the session + (backup + apply + verify), `/config-audit rollback` undoes, then `set-status implemented` + records it. The command (`commands/campaign.md`, new `export ` mode) previews → asks → on + approval invokes `--write` → routes the user to that existing machinery. +- **Byte-stable.** lib + `-cli` + command-doc only → scanner count stays **15**, agents **7**, + commands **21** (export is a *mode*, not a new command), snapshot/backcompat suite untouched. + suite 1150→1168 (lib +8, export-cli +10). **Block 4a (`migrateLedger`) still deferred** to the + first breaking schema change (export needs no schema bump). + ## Gotchas - Session directories accumulate — use `/config-audit cleanup` to manage diff --git a/README.md b/README.md index 5a9d3b5..6d59e16 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ![Commands](https://img.shields.io/badge/commands-21-green) ![Agents](https://img.shields.io/badge/agents-7-orange) ![Hooks](https://img.shields.io/badge/hooks-4-red) -![Tests](https://img.shields.io/badge/tests-1150+-brightgreen) +![Tests](https://img.shields.io/badge/tests-1168+-brightgreen) ![License](https://img.shields.io/badge/license-MIT-lightgrey) A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 15 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, cross-plugin collision, and output-style detection. Zero external dependencies. @@ -293,7 +293,7 @@ Your team configuration changes over time. Track it: | `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence | | `/config-audit whats-active` | Read-only inventory of plugins, skills, MCP, hooks, CLAUDE.md active for a repo (with token estimates) | | `/config-audit knowledge-refresh` | Keep the best-practices register fresh — flag stale entries (sources older than ~90d) + poll for new/changed Claude Code practices; **human-approved writes only** (Verifiseringsplikt). Deterministic stale core + web candidate poll | -| `/config-audit campaign` | Machine-wide audit campaign — durable ledger above sessions tracking each repo's lifecycle (pending → audited → planned → implemented) + a machine-wide roll-up by severity + a single **cross-repo prioritized backlog** to pick from (severity-weighted), resumable across sessions; **human-approved writes only** (read-only report + deterministic write-CLI) | +| `/config-audit campaign` | Machine-wide audit campaign — durable ledger above sessions tracking each repo's lifecycle (pending → audited → planned → implemented) + a machine-wide roll-up by severity + a single **cross-repo prioritized backlog** to pick from (severity-weighted) + plan **export** (drop a planned repo's plan into its own `docs/`), resumable across sessions; **human-approved writes only** (read-only report + deterministic write/export CLIs). Execution reuses the existing `/config-audit implement` + `rollback` | | `/config-audit discover` | Run discovery phase only | | `/config-audit analyze` | Run analysis phase only | | `/config-audit interview` | Set preferences for action plan _(optional)_ | @@ -602,7 +602,7 @@ date, and a `confidence`. It is the source of truth for the optimization lens (O node --test 'tests/**/*.test.mjs' ``` -1055 tests across 59 test files (18 lib + 31 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Requires Node.js 18+ (`node:test`). +1168 tests across 67 test files (22 lib + 35 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Requires Node.js 18+ (`node:test`). --- diff --git a/commands/campaign.md b/commands/campaign.md index ffeb2b4..dbcecf0 100644 --- a/commands/campaign.md +++ b/commands/campaign.md @@ -1,7 +1,7 @@ --- name: config-audit:campaign description: Machine-wide audit campaign — track which repos are pending/audited/planned/implemented across sessions, with a machine-wide roll-up. Human-approved writes only. -argument-hint: "[init | add ... | add --discover | set-status ]" +argument-hint: "[init | add ... | set-status | export ]" allowed-tools: Read, Write, Edit, Bash, Glob model: opus --- @@ -21,19 +21,28 @@ and applied **only on explicit approval**, by invoking one deterministic write-C The command never hand-edits the ledger JSON. This is the **THIN** campaign surface (ledger + roll-up + status + a cross-repo prioritized -backlog to pick from). Execution is a later block — this command does not run audits or apply -fixes itself; it tracks where each repo stands and shows what to tackle next. +backlog to pick from + plan **export**). It does not run audits or apply fixes itself: it tracks +where each repo stands, shows what to tackle next, exports a planned repo's plan into that repo's +own `docs/`, and points at the **existing** per-repo `/config-audit implement` (backup + apply + +verify) and `/config-audit rollback` for execution — Block 4c reuses that machinery, it does not +reinvent it. -## Two CLIs back this command +## Three CLIs back this command - **Read (report):** `scanners/campaign-cli.mjs` — loads + validates the ledger, emits the - repo list + roll-up. Never writes. + repo list + roll-up + backlog. Never writes. - **Write (mutate):** `scanners/campaign-write-cli.mjs` — `init` / `add` / `set-status`, each a thin wrapper over the invariant-enforcing lib transforms + save. Invoked **only** after the user approves a specific action. +- **Export:** `scanners/campaign-export-cli.mjs` — `--repo ` resolves the repo's linked + session, reads its `action-plan.md`, and assembles a `docs/config-audit-plan-.md`. + Read-only (a preview) by default; it writes the file **only** under `--write`, which is + invoked **only** after the user approves. The CLI writes the file byte-faithfully — the plan is + never re-typed. -Both take `--ledger-file ` (defaults to the durable path) and `--output-file `; -the write-CLI also takes `--reference-date ` (the audit date stamp). +All take `--ledger-file ` (defaults to the durable path) and `--output-file `; the +write-CLI + export-CLI also take `--reference-date ` (the date stamp), and the +export-CLI takes `--sessions-dir ` (defaults to `~/.claude/config-audit/sessions`). ## Implementation @@ -46,6 +55,7 @@ From `$ARGUMENTS`, pick the mode: - `add ...` → add one or more repo paths. - `add --discover ` → find git repos under `` and let the user pick which to add. - `set-status ` → transition a tracked repo (`status` ∈ pending/audited/planned/implemented). +- `export ` → export a planned repo's action plan into that repo's own `docs/`. - `help` → show this surface and stop. Set a shared date stamp for any write: `TODAY=$(date +%F)`. @@ -165,7 +175,48 @@ node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs set-status " \ + --reference-date "$TODAY" \ + --output-file ~/.claude/config-audit/sessions/campaign-export.json 2>/dev/null; echo $? +``` + +Exit **0** = previewable, **1** = tracked but not exportable yet, **3** = error (untracked repo, +no/corrupt ledger). Read `~/.claude/config-audit/sessions/campaign-export.json` with the Read tool. + +- **Exit 1 — read `problems`** and guide, then stop (nothing to export): + - `no-session-linked` → "`` has no linked audit session. Link one with + `/config-audit campaign set-status --session `, or audit + plan it first." + - `no-action-plan` → "``'s session has no plan yet. Run `/config-audit plan` in that repo + first, mark it `planned`, then export." +- **Exit 0 — show, then ask.** Tell the user the destination (`targetPath`) and a **short** preview + — the first ~12 lines of `document` only, never the whole file, never the raw JSON (UX rules). + Ask for explicit approval to write it. + +**On approval, write it** (the CLI does the faithful copy — do NOT hand-write the file): + +```bash +node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-export-cli.mjs --repo "" --write \ + --reference-date "$TODAY" \ + --output-file ~/.claude/config-audit/sessions/campaign-export.json 2>/dev/null; echo $? +``` + +Confirm: "Plan exported to ``." Then hand off to the **existing** execution machinery +(Block 4c reuses it — this command does not run it for you): + +> To **execute**: run `/config-audit implement` in `` — it backs up every changed file, +> applies the plan, and verifies. To **undo**: `/config-audit rollback`. When done, record it: +> `/config-audit campaign set-status implemented`. + +### Step 7: Next steps Tailor to where the campaign stands: @@ -174,20 +225,27 @@ Tailor to where the campaign stands: - **Pending repos exist:** "Run `/config-audit` in a pending repo to audit it, then `/config-audit campaign set-status audited` to record the result here." - **Audited but not planned:** "`/config-audit plan` in that repo, then mark it `planned`." +- **Planned repos exist:** "`/config-audit campaign export ` to drop the plan into that + repo's own `docs/`, then `/config-audit implement` there to execute it (backup + verify)." +- **Backlog has items:** point at the top backlog repo and the natural next verb for its status + (audit → plan → export → implement). - Always: the campaign survives this session — re-run `/config-audit campaign` anytime to see the machine-wide picture. ## Notes -- **Read-only report, human-approved writes.** `campaign-cli` never writes; every mutation goes - through `campaign-write-cli` and only after explicit approval, exactly mirroring how - `/config-audit knowledge-refresh` gates register writes. -- **Deterministic core, not byte-stable command.** The lib transforms + both CLIs are +- **Read-only report, human-approved writes.** `campaign-cli` never writes; every mutation — + ledger changes via `campaign-write-cli`, plan exports via `campaign-export-cli --write` — happens + only after explicit approval, exactly mirroring how `/config-audit knowledge-refresh` gates + register writes. The export-CLI's default (no `--write`) is a read-only preview. +- **Deterministic core, not byte-stable command.** The lib transforms + all three CLIs are unit-tested and deterministic (`--reference-date` injected); this command's orchestration is judgment-driven and deliberately **not** in the snapshot suite (like `/config-audit optimize` and `knowledge-refresh`). -- The `-cli` suffix keeps both CLIs out of the scan-orchestrator, so the scanner count and the - byte-stable snapshot suite are unaffected. -- **THIN scope:** ledger + roll-up + status + a read-only cross-repo prioritized backlog. The - backlog is a derived pick-list (`buildBacklog`, severity-weighted); execution is a later block — - this command tracks state and shows what to tackle next, it does not run audits or apply fixes. +- The `-cli` suffix keeps all three CLIs out of the scan-orchestrator, so the scanner count and + the byte-stable snapshot suite are unaffected. +- **THIN scope:** ledger + roll-up + status + a cross-repo prioritized backlog + plan export. + Execution is **not** reinvented here — `export` drops a planned repo's plan into its own `docs/` + (a durable record), and the user runs the existing `/config-audit implement` (backup + apply + + verify) + `/config-audit rollback` to execute and undo. This command tracks state and routes the + work; it does not run audits or apply fixes itself. diff --git a/scanners/campaign-export-cli.mjs b/scanners/campaign-export-cli.mjs new file mode 100644 index 0000000..781a8df --- /dev/null +++ b/scanners/campaign-export-cli.mjs @@ -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-.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 [--write] + * [--ledger-file

] [--sessions-dir

] [--reference-date ] [--output-file

] + * + * 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 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 `). + 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); + }); +} diff --git a/scanners/lib/campaign-export.mjs b/scanners/lib/campaign-export.mjs new file mode 100644 index 0000000..b42e44a --- /dev/null +++ b/scanners/lib/campaign-export.mjs @@ -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} `/docs/config-audit-plan-.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`; +} diff --git a/tests/lib/campaign-export.test.mjs b/tests/lib/campaign-export.test.mjs new file mode 100644 index 0000000..8290d38 --- /dev/null +++ b/tests/lib/campaign-export.test.mjs @@ -0,0 +1,72 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { planExportPath, buildPlanExportDocument } from '../../scanners/lib/campaign-export.mjs'; + +const NOW = '2026-06-23'; +const SESSION = '20260623_101500'; + +describe('planExportPath', () => { + it('targets /docs/config-audit-plan-.md', () => { + assert.equal( + planExportPath('/Users/ktg/repos/foo', SESSION), + `/Users/ktg/repos/foo/docs/config-audit-plan-${SESSION}.md`, + ); + }); + + it('keys on sessionId (not date) so same-day audits do not collide', () => { + const a = planExportPath('/r/x', '20260623_090000'); + const b = planExportPath('/r/x', '20260623_180000'); + assert.notEqual(a, b); + }); + + it('throws on a missing/blank repoPath or sessionId (programmer error)', () => { + assert.throws(() => planExportPath('', SESSION), TypeError); + assert.throws(() => planExportPath('/r/x', ' '), TypeError); + assert.throws(() => planExportPath('/r/x'), TypeError); + }); +}); + +describe('buildPlanExportDocument', () => { + const base = { + repoName: 'foo', + repoPath: '/Users/ktg/repos/foo', + sessionId: SESSION, + planMarkdown: '## Action 1\nDo the thing.\n', + now: NOW, + }; + + it('prepends a provenance header, then the verbatim plan body', () => { + const doc = buildPlanExportDocument(base); + assert.match(doc, /^# Config-Audit Action Plan — foo\n/); + assert.ok(doc.includes(`on ${NOW}.`)); + assert.ok(doc.includes('`/Users/ktg/repos/foo`')); + assert.ok(doc.includes(`\`${SESSION}\``)); + // body is present verbatim, after the --- separator + const [, body] = doc.split('\n---\n'); + assert.ok(body.includes('## Action 1\nDo the thing.')); + }); + + it('points at the existing execute/undo/record machinery (reuse, not reinvent)', () => { + const doc = buildPlanExportDocument(base); + assert.ok(doc.includes('/config-audit implement')); + assert.ok(doc.includes('/config-audit rollback')); + assert.ok(doc.includes(`/config-audit campaign set-status ${base.repoPath} implemented`)); + }); + + it('is deterministic — same inputs produce identical bytes', () => { + assert.equal(buildPlanExportDocument(base), buildPlanExportDocument({ ...base })); + }); + + it('trims trailing whitespace on the body and ends with exactly one newline', () => { + const doc = buildPlanExportDocument({ ...base, planMarkdown: 'body\n\n\n' }); + assert.ok(doc.endsWith('body\n')); + assert.ok(!doc.endsWith('body\n\n')); + }); + + it('throws when any required field is missing or blank', () => { + for (const k of ['repoName', 'repoPath', 'sessionId', 'planMarkdown', 'now']) { + assert.throws(() => buildPlanExportDocument({ ...base, [k]: '' }), TypeError, `blank ${k}`); + } + assert.throws(() => buildPlanExportDocument(), TypeError); + }); +}); diff --git a/tests/scanners/campaign-export-cli.test.mjs b/tests/scanners/campaign-export-cli.test.mjs new file mode 100644 index 0000000..197648f --- /dev/null +++ b/tests/scanners/campaign-export-cli.test.mjs @@ -0,0 +1,154 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { createLedger, addRepo, setRepoStatus, saveLedger } from '../../scanners/lib/campaign-ledger.mjs'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const CLI = resolve(__dirname, '../../scanners/campaign-export-cli.mjs'); + +const NOW = '2026-06-23'; +const SESSION = '20260623_101500'; +const PLAN_BODY = '## Executive summary\n\n2 actions, 1 auto-fixable.\n\n## Action 1\nFix the thing.\n'; + +// Every test points --ledger-file + --sessions-dir at temp dirs, so the CLI never touches the +// real ~/.claude/ — hermetic by construction. +function runCli(extraArgs) { + try { + const stdout = execFileSync('node', [CLI, ...extraArgs], { encoding: 'utf-8', timeout: 15000 }); + return { status: 0, stdout }; + } catch (err) { + return { status: err.status, stdout: err.stdout || '', stderr: err.stderr || '' }; + } +} + +/** + * Build a hermetic world: a temp dir holding the ledger + a sessions/ store + a repo/ dir. + * `opts.withSession` links a sessionId; `opts.withPlan` writes that session's action-plan.md. + */ +function world({ withSession = true, withPlan = true } = {}) { + const root = mkdtempSync(join(tmpdir(), 'camp-exp-')); + const sessionsDir = join(root, 'sessions'); + const repoDir = join(root, 'repo'); + mkdirSync(sessionsDir, { recursive: true }); + mkdirSync(repoDir, { recursive: true }); + + let l = createLedger({ now: NOW }); + l = addRepo(l, { path: repoDir, name: 'repo' }, { now: NOW }); + l = setRepoStatus(l, repoDir, 'planned', { + now: NOW, + findingsBySeverity: { critical: 0, high: 1, medium: 0, low: 2 }, + sessionId: withSession ? SESSION : undefined, + }); + const ledgerFile = join(root, 'campaign-ledger.json'); + // saveLedger is async via the lib; call synchronously through writeFileSync to keep tests simple. + writeFileSync(ledgerFile, `${JSON.stringify(l, null, 2)}\n`); + + if (withSession && withPlan) { + mkdirSync(join(sessionsDir, SESSION), { recursive: true }); + writeFileSync(join(sessionsDir, SESSION, 'action-plan.md'), PLAN_BODY); + } + return { root, sessionsDir, repoDir, ledgerFile }; +} + +const baseArgs = (w) => ['--ledger-file', w.ledgerFile, '--sessions-dir', w.sessionsDir, '--reference-date', NOW]; + +describe('campaign-export-cli — preview (read-only default)', () => { + it('exits 0 and returns an exportable preview without writing', () => { + const w = world(); + const { status, stdout } = runCli([...baseArgs(w), '--repo', w.repoDir]); + assert.equal(status, 0); + const out = JSON.parse(stdout); + assert.equal(out.exportable, true); + assert.equal(out.written, false); + assert.deepEqual(out.problems, []); + assert.equal(out.targetPath, join(w.repoDir, 'docs', `config-audit-plan-${SESSION}.md`)); + assert.ok(out.document.startsWith('# Config-Audit Action Plan — repo')); + assert.ok(out.document.includes('## Action 1\nFix the thing.')); + // preview must NOT have created the file + assert.equal(existsSync(out.targetPath), false); + }); +}); + +describe('campaign-export-cli — --write', () => { + it('writes a byte-exact copy of the assembled document into the repo docs/', () => { + const w = world(); + const preview = JSON.parse(runCli([...baseArgs(w), '--repo', w.repoDir]).stdout); + const { status, stdout } = runCli([...baseArgs(w), '--repo', w.repoDir, '--write']); + assert.equal(status, 0); + const out = JSON.parse(stdout); + assert.equal(out.written, true); + assert.ok(existsSync(out.targetPath)); + // file on disk equals the document the preview reported (no drift) + assert.equal(readFileSync(out.targetPath, 'utf-8'), preview.document); + }); + + it('creates the docs/ dir if missing', () => { + const w = world(); + const out = JSON.parse(runCli([...baseArgs(w), '--repo', w.repoDir, '--write']).stdout); + assert.ok(existsSync(join(w.repoDir, 'docs'))); + assert.ok(existsSync(out.targetPath)); + }); +}); + +describe('campaign-export-cli — gates (advisory exit 1)', () => { + it('exits 1 with no-session-linked when the repo has no sessionId', () => { + const w = world({ withSession: false }); + const { status, stdout } = runCli([...baseArgs(w), '--repo', w.repoDir]); + assert.equal(status, 1); + const out = JSON.parse(stdout); + assert.equal(out.exportable, false); + assert.deepEqual(out.problems, ['no-session-linked']); + assert.equal(out.written, false); + }); + + it('exits 1 with no-action-plan when the linked session has no action-plan.md', () => { + const w = world({ withPlan: false }); + const { status, stdout } = runCli([...baseArgs(w), '--repo', w.repoDir]); + assert.equal(status, 1); + const out = JSON.parse(stdout); + assert.equal(out.exportable, false); + assert.deepEqual(out.problems, ['no-action-plan']); + }); + + it('--write writes nothing when the repo is not exportable', () => { + const w = world({ withPlan: false }); + const { status, stdout } = runCli([...baseArgs(w), '--repo', w.repoDir, '--write']); + assert.equal(status, 1); + assert.equal(JSON.parse(stdout).written, false); + assert.equal(existsSync(join(w.repoDir, 'docs')), false); + }); +}); + +describe('campaign-export-cli — errors (exit 3)', () => { + it('exits 3 when --repo is missing', () => { + const w = world(); + assert.equal(runCli(baseArgs(w)).status, 3); + }); + + it('exits 3 when the repo is not tracked in the ledger', () => { + const w = world(); + assert.equal(runCli([...baseArgs(w), '--repo', join(w.root, 'other')]).status, 3); + }); + + it('exits 3 when no ledger file exists', () => { + const w = world(); + const status = runCli(['--ledger-file', join(w.root, 'nope.json'), '--sessions-dir', w.sessionsDir, '--repo', w.repoDir]).status; + assert.equal(status, 3); + }); +}); + +describe('campaign-export-cli — --output-file', () => { + it('writes the payload JSON to the file and stays silent on stdout', () => { + const w = world(); + const out = join(w.root, 'report.json'); + const { stdout } = runCli([...baseArgs(w), '--repo', w.repoDir, '--output-file', out]); + assert.equal(stdout.trim(), ''); + const written = JSON.parse(readFileSync(out, 'utf-8')); + assert.equal(written.exportable, true); + assert.equal(written.targetPath, join(w.repoDir, 'docs', `config-audit-plan-${SESSION}.md`)); + }); +});