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
30
CLAUDE.md
30
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)` → `<repo>/docs/config-audit-plan-<sessionId>.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 <path>` 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 <path> implemented`
|
||||
records it. The command (`commands/campaign.md`, new `export <path>` 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
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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`).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <path>... | add --discover <root> | set-status <path> <status>]"
|
||||
argument-hint: "[init | add <path>... | set-status <path> <status> | export <path>]"
|
||||
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 <path>` resolves the repo's linked
|
||||
session, reads its `action-plan.md`, and assembles a `docs/config-audit-plan-<sessionId>.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 <path>` (defaults to the durable path) and `--output-file <path>`;
|
||||
the write-CLI also takes `--reference-date <YYYY-MM-DD>` (the audit date stamp).
|
||||
All take `--ledger-file <path>` (defaults to the durable path) and `--output-file <path>`; the
|
||||
write-CLI + export-CLI also take `--reference-date <YYYY-MM-DD>` (the date stamp), and the
|
||||
export-CLI takes `--sessions-dir <path>` (defaults to `~/.claude/config-audit/sessions`).
|
||||
|
||||
## Implementation
|
||||
|
||||
|
|
@ -46,6 +55,7 @@ From `$ARGUMENTS`, pick the mode:
|
|||
- `add <path>...` → add one or more repo paths.
|
||||
- `add --discover <root>` → find git repos under `<root>` and let the user pick which to add.
|
||||
- `set-status <path> <status>` → transition a tracked repo (`status` ∈ pending/audited/planned/implemented).
|
||||
- `export <path>` → 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 <path> <st
|
|||
Exit **3** = invalid status, untracked repo, or no ledger → report the message plainly and do
|
||||
not retry blindly. On success, read the result and re-show the updated roll-up + repo row.
|
||||
|
||||
### Step 6: Next steps
|
||||
### Step 6 (mode `export`): Export a repo's plan to its own `docs/` — preview, approve, write
|
||||
|
||||
"Planer følger arbeidsstedet": a planned repo's action plan belongs in **that repo's** `docs/`,
|
||||
not buried in a session dir. This step copies it there, byte-faithfully.
|
||||
|
||||
**Preview first (read-only — never writes).** The repo must be tracked and have a linked session
|
||||
that carries an `action-plan.md` (i.e. `/config-audit plan` has run there). Run without `--write`:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-export-cli.mjs --repo "<path>" \
|
||||
--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` → "`<name>` has no linked audit session. Link one with
|
||||
`/config-audit campaign set-status <path> <status> --session <id>`, or audit + plan it first."
|
||||
- `no-action-plan` → "`<name>`'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 "<path>" --write \
|
||||
--reference-date "$TODAY" \
|
||||
--output-file ~/.claude/config-audit/sessions/campaign-export.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Confirm: "Plan exported to `<targetPath>`." 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 `<path>` — 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 <path> 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 <path> 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 <path>` 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.
|
||||
|
|
|
|||
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`;
|
||||
}
|
||||
72
tests/lib/campaign-export.test.mjs
Normal file
72
tests/lib/campaign-export.test.mjs
Normal file
|
|
@ -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 <repo>/docs/config-audit-plan-<sessionId>.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);
|
||||
});
|
||||
});
|
||||
154
tests/scanners/campaign-export-cli.test.mjs
Normal file
154
tests/scanners/campaign-export-cli.test.mjs
Normal file
|
|
@ -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`));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue