feat(campaign): /config-audit campaign — human-approved write-CLI + command (v5.7 Fase 2 Block 3c)
Completes the THIN machine-wide campaign surface (ledger + roll-up + status). The write half of the campaign motor, mirroring how knowledge-refresh gates register writes: - scanners/campaign-write-cli.mjs (-cli → NOT a scanner; 11 tests): init/add/ set-status, each a thin wrapper over the invariant-enforcing lib transforms (createLedger/addRepo/setRepoStatus) + saveLedger — path-normalization/dedup, idempotent add, status-lifecycle guard and updatedDate bump never hand-rolled. init refuses to clobber an existing/corrupt ledger (exit 1, file untouched); add auto-inits + reports added vs skipped; set-status takes --findings/--session. Deterministic: --reference-date is the only clock read, injected as `now`. Exit 0=write, 1=advisory no-op, 3=error. - commands/campaign.md (opus, no Web): thin orchestrator — always reports first (read-only campaign-cli), then proposes init/add/set-status and invokes ONE write-CLI subcommand only on explicit human approval (Verifiseringsplikt; never hand-edits the JSON). add --discover finds git repos under a root to pick from. Not byte-stable (own command, outside snapshot suite) like /config-audit optimize + knowledge-refresh. Both CLIs are -cli → scanner count stays 15, snapshot suite untouched. commands 20→21, suite 1127→1138 (+11), test files 64→65. Docs: CLAUDE.md §campaign-write-cli + command table + Testing badge; README commands badge 20→21 + table row; help.md + router wiring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4be7a16788
commit
ee0c762151
7 changed files with 622 additions and 3 deletions
34
CLAUDE.md
34
CLAUDE.md
|
|
@ -35,6 +35,7 @@ Analyzes and optimizes Claude Code configuration across three pillars:
|
||||||
| `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence |
|
| `/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 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 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 discover` | Run discovery phase only |
|
| `/config-audit discover` | Run discovery phase only |
|
||||||
| `/config-audit analyze` | Run analysis phase only |
|
| `/config-audit analyze` | Run analysis phase only |
|
||||||
| `/config-audit interview` | Gather user preferences (opt-in) |
|
| `/config-audit interview` | Gather user preferences (opt-in) |
|
||||||
|
|
@ -112,7 +113,7 @@ Default: auto-detects scope from git context. Override with `/config-audit full|
|
||||||
node --test 'tests/**/*.test.mjs'
|
node --test 'tests/**/*.test.mjs'
|
||||||
```
|
```
|
||||||
|
|
||||||
1127 tests across 64 test files (21 lib + 33 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`.
|
1138 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`.
|
||||||
|
|
||||||
### active-config-reader — load-pattern model + rule/agent/output-style enumeration (v5.6 Foundation)
|
### active-config-reader — load-pattern model + rule/agent/output-style enumeration (v5.6 Foundation)
|
||||||
|
|
||||||
|
|
@ -447,6 +448,37 @@ writes, Verifiseringsplikt). `--ledger-file` overrides the default path (determi
|
||||||
`--output-file` mirrors the sibling. Exit codes: **0** = initialized & valid, **1** = not initialized
|
`--output-file` mirrors the sibling. Exit codes: **0** = initialized & valid, **1** = not initialized
|
||||||
yet (advisory), **3** = error (parse/corrupt/invalid). suite 1119→1127.
|
yet (advisory), **3** = error (parse/corrupt/invalid). suite 1119→1127.
|
||||||
|
|
||||||
|
### campaign-write-cli + `/config-audit campaign` — the WRITE half (v5.7 Fase 2, Block 3c, commands 20→21)
|
||||||
|
|
||||||
|
The human-approved mutation half of the campaign motor, completing the THIN campaign surface
|
||||||
|
(ledger + roll-up + status). Two pieces:
|
||||||
|
|
||||||
|
- **`scanners/campaign-write-cli.mjs`** (`-cli` → NOT an orchestrated scanner → scanner count stays
|
||||||
|
15, suite byte-stable; 11 tests): the sibling of `campaign-cli` that *mutates*. Subcommands
|
||||||
|
`init` / `add <path>...` / `set-status <path> <status>`, each a thin wrapper over the
|
||||||
|
invariant-enforcing lib transforms (`createLedger`/`addRepo`/`setRepoStatus`) + `saveLedger` — so
|
||||||
|
path-normalization/dedup, idempotent add, the status-lifecycle guard, and the `updatedDate` bump
|
||||||
|
are **never re-implemented by hand**. `init` refuses to clobber an existing (or corrupt) ledger
|
||||||
|
(**exit 1** advisory, file untouched); `add` **auto-inits** when no ledger exists and reports
|
||||||
|
`added` vs `skipped`; `set-status` accepts `--findings '<json>'` + `--session <id>`. Determinism
|
||||||
|
mirrors the lib + `knowledge-refresh-cli`: `--reference-date` is the **only** place the clock is
|
||||||
|
read (defaults to today), passed to the transforms as the injected `now`. Exit: **0** = write
|
||||||
|
performed, **1** = advisory no-op (init-clobber), **3** = error (unknown subcommand, bad args,
|
||||||
|
invalid status, untracked repo, no/corrupt ledger).
|
||||||
|
- **`commands/campaign.md`** (opus, `allowed-tools: Read/Write/Edit/Bash/Glob` — **no Web**,
|
||||||
|
judgment-free): a thin orchestrator. It always **reports** first (read-only `campaign-cli`), then
|
||||||
|
for `init`/`add`/`set-status` it proposes the change and, **only on explicit human approval**,
|
||||||
|
invokes one write-CLI subcommand (Verifiseringsplikt — it never hand-edits the ledger JSON).
|
||||||
|
`add --discover <root>` finds git repos under a root and lets the user pick. When marking a repo
|
||||||
|
`audited` it attaches findings-by-severity from the repo's session (or user-provided counts) —
|
||||||
|
**never invented**.
|
||||||
|
|
||||||
|
**Not a new scanner, not byte-stable.** Both CLIs carry the `-cli` suffix (out of the
|
||||||
|
scan-orchestrator → scanner count stays **15**, snapshot suite untouched); the command's
|
||||||
|
orchestration is judgment-driven and deliberately outside the snapshot suite, exactly like
|
||||||
|
`/config-audit optimize` + `knowledge-refresh`. **No new agent** (web/judgment-free, runs in the
|
||||||
|
command's own context). suite 1127→1138.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Session directories accumulate — use `/config-audit cleanup` to manage
|
- Session directories accumulate — use `/config-audit cleanup` to manage
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
@ -293,6 +293,7 @@ Your team configuration changes over time. Track it:
|
||||||
| `/config-audit plugin-health` | Audit plugin structure, frontmatter, cross-plugin coherence |
|
| `/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 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 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, resumable across sessions; **human-approved writes only** (read-only report + deterministic write-CLI) |
|
||||||
| `/config-audit discover` | Run discovery phase only |
|
| `/config-audit discover` | Run discovery phase only |
|
||||||
| `/config-audit analyze` | Run analysis phase only |
|
| `/config-audit analyze` | Run analysis phase only |
|
||||||
| `/config-audit interview` | Set preferences for action plan _(optional)_ |
|
| `/config-audit interview` | Set preferences for action plan _(optional)_ |
|
||||||
|
|
|
||||||
177
commands/campaign.md
Normal file
177
commands/campaign.md
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
---
|
||||||
|
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>]"
|
||||||
|
allowed-tools: Read, Write, Edit, Bash, Glob
|
||||||
|
model: opus
|
||||||
|
---
|
||||||
|
|
||||||
|
# Config-Audit: Campaign
|
||||||
|
|
||||||
|
A single config-audit session audits **one** scope. A **campaign** sits above sessions: a
|
||||||
|
durable ledger of every repo you mean to bring up to standard, each repo's lifecycle status
|
||||||
|
(**pending → audited → planned → implemented**), and a machine-wide roll-up of findings by
|
||||||
|
severity. It persists to `~/.claude/config-audit/campaign-ledger.json` — **outside** the
|
||||||
|
plugin dir, next to `sessions/` — so it survives plugin uninstall/reinstall/upgrade and
|
||||||
|
resumes across sessions.
|
||||||
|
|
||||||
|
**The Iron rule (Verifiseringsplikt): nothing is ever auto-written.** Reporting is read-only.
|
||||||
|
Every mutation — creating the ledger, adding a repo, changing a status — is proposed first
|
||||||
|
and applied **only on explicit approval**, by invoking one deterministic write-CLI subcommand.
|
||||||
|
The command never hand-edits the ledger JSON.
|
||||||
|
|
||||||
|
This is the **THIN** campaign surface (ledger + roll-up + status). Cross-repo backlog
|
||||||
|
prioritization and execution are a later block — this command does not run audits or apply
|
||||||
|
fixes itself; it tracks where each repo stands.
|
||||||
|
|
||||||
|
## Two CLIs back this command
|
||||||
|
|
||||||
|
- **Read (report):** `scanners/campaign-cli.mjs` — loads + validates the ledger, emits the
|
||||||
|
repo list + roll-up. 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.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Step 1: Parse arguments
|
||||||
|
|
||||||
|
From `$ARGUMENTS`, pick the mode:
|
||||||
|
|
||||||
|
- *(empty)* or `report` → **report** (read-only). Default.
|
||||||
|
- `init` → initialize the ledger.
|
||||||
|
- `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).
|
||||||
|
- `help` → show this surface and stop.
|
||||||
|
|
||||||
|
Set a shared date stamp for any write: `TODAY=$(date +%F)`.
|
||||||
|
|
||||||
|
### Step 2: Always report current state first
|
||||||
|
|
||||||
|
Whatever the mode, start by showing where the campaign stands (read-only):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-cli.mjs \
|
||||||
|
--output-file ~/.claude/config-audit/sessions/campaign-report.json 2>/dev/null; echo $?
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit **0** = a campaign exists, **1** = not initialized yet (advisory — normal first run),
|
||||||
|
**3** = real error → "The campaign ledger couldn't be read — it may be corrupt." (Stop; do not
|
||||||
|
attempt a write over a corrupt ledger.)
|
||||||
|
|
||||||
|
Read `~/.claude/config-audit/sessions/campaign-report.json` with the Read tool (per the UX
|
||||||
|
rules — never show the raw JSON). It has `initialized`, `repos[]` (each: `path, name, status,
|
||||||
|
sessionId, findingsBySeverity, updatedDate`), and `rollUp {totalRepos, byStatus, bySeverity,
|
||||||
|
reposWithFindings}`.
|
||||||
|
|
||||||
|
Present it as two short tables:
|
||||||
|
|
||||||
|
**Campaign roll-up**
|
||||||
|
|
||||||
|
| Status | Repos |
|
||||||
|
|--------|-------|
|
||||||
|
| pending / audited / planned / implemented | … |
|
||||||
|
|
||||||
|
…plus a one-line severity total across audited repos (e.g. "Findings so far: 3 critical,
|
||||||
|
8 high, 5 medium, 12 low across 4 audited repos").
|
||||||
|
|
||||||
|
**Repos**
|
||||||
|
|
||||||
|
| Repo | Status | Findings (C/H/M/L) | Last updated |
|
||||||
|
|------|--------|--------------------|--------------|
|
||||||
|
|
||||||
|
If `initialized` is false, say so plainly: "No campaign yet. Run `/config-audit campaign init`
|
||||||
|
to start one." Then — if the mode was `init` or `add` — continue to that step (those bootstrap
|
||||||
|
a campaign); for `report`/`set-status` on an uninitialized ledger, stop after this message.
|
||||||
|
|
||||||
|
### Step 3 (mode `init`): Initialize
|
||||||
|
|
||||||
|
If already initialized, say so and stop (no clobber). Otherwise tell the user what will happen,
|
||||||
|
then create it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs init \
|
||||||
|
--reference-date "$TODAY" \
|
||||||
|
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit **0** = created, **1** = already initialized (advisory). Confirm: "Campaign ledger created
|
||||||
|
at `~/.claude/config-audit/campaign-ledger.json`." Then suggest `add`.
|
||||||
|
|
||||||
|
### Step 4 (mode `add`): Add repos — propose, approve, write
|
||||||
|
|
||||||
|
**Gather candidates.**
|
||||||
|
- Explicit paths: use the paths given after `add`.
|
||||||
|
- `--discover <root>`: find git repos (depth-limited), e.g.
|
||||||
|
```bash
|
||||||
|
find "<root>" -maxdepth 3 -type d -name .git 2>/dev/null | sed 's:/\.git$::'
|
||||||
|
```
|
||||||
|
Present the discovered repos as a numbered list and ask **which** to add (and confirm any
|
||||||
|
that are already tracked will be skipped). Use Glob as a fallback if `find` is unavailable.
|
||||||
|
|
||||||
|
**Confirm, then write.** Show the final list and ask for explicit approval. On approval, add
|
||||||
|
them in one call (idempotent — already-tracked repos are skipped, not reset):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs add <path1> <path2> ... \
|
||||||
|
--reference-date "$TODAY" \
|
||||||
|
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
|
||||||
|
```
|
||||||
|
|
||||||
|
(For a single repo with a custom display name, add `--name "<name>"`.) Read the result file and
|
||||||
|
report what was `added` vs `skipped`, then re-show the repo table.
|
||||||
|
|
||||||
|
### Step 5 (mode `set-status`): Transition a repo — propose, approve, write
|
||||||
|
|
||||||
|
Confirm the repo is tracked (from Step 2's report) and that `status` is one of
|
||||||
|
pending/audited/planned/implemented. State the transition ("`<name>`: pending → audited") and
|
||||||
|
ask for approval.
|
||||||
|
|
||||||
|
When marking a repo **audited**, optionally attach its findings-by-severity so the machine-wide
|
||||||
|
roll-up stays meaningful. Two honest sources, in order of preference:
|
||||||
|
1. If the repo was audited in a config-audit session, read that session's finding counts and
|
||||||
|
build `{"critical":C,"high":H,"medium":M,"low":L}` — pass `--session <id>` too.
|
||||||
|
2. Otherwise, use counts the user provides. **Never invent counts** (Verifiseringsplikt) — if
|
||||||
|
none are available, transition the status without `--findings`.
|
||||||
|
|
||||||
|
On approval:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ${CLAUDE_PLUGIN_ROOT}/scanners/campaign-write-cli.mjs set-status <path> <status> \
|
||||||
|
--reference-date "$TODAY" \
|
||||||
|
[--findings '{"critical":0,"high":0,"medium":0,"low":0}'] [--session <id>] \
|
||||||
|
--output-file ~/.claude/config-audit/sessions/campaign-write.json 2>/dev/null; echo $?
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Tailor to where the campaign stands:
|
||||||
|
|
||||||
|
- **Just initialized / few repos:** "`/config-audit campaign add --discover ~/repos` to enroll
|
||||||
|
your repos."
|
||||||
|
- **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`."
|
||||||
|
- 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
|
||||||
|
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 only. Cross-repo backlog prioritization and execution
|
||||||
|
are a later block — this command tracks state, it does not run audits or apply fixes.
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
name: config-audit
|
name: config-audit
|
||||||
description: Claude Code Configuration Intelligence - audit, analyze, and optimize your configuration
|
description: Claude Code Configuration Intelligence - audit, analyze, and optimize your configuration
|
||||||
argument-hint: "[posture|tokens|manifest|feature-gap|optimize|fix|rollback|plan|implement|help|discover|analyze|interview|drift|plugin-health|whats-active|status|cleanup]"
|
argument-hint: "[posture|tokens|manifest|feature-gap|optimize|fix|rollback|plan|implement|help|discover|analyze|interview|drift|plugin-health|whats-active|campaign|status|cleanup]"
|
||||||
allowed-tools: Read, Write, Glob, Grep, Bash, Agent, AskUserQuestion
|
allowed-tools: Read, Write, Glob, Grep, Bash, Agent, AskUserQuestion
|
||||||
model: opus
|
model: opus
|
||||||
---
|
---
|
||||||
|
|
@ -29,6 +29,7 @@ If a subcommand is provided, route to it:
|
||||||
- `drift` → `/config-audit:drift`
|
- `drift` → `/config-audit:drift`
|
||||||
- `plugin-health` → `/config-audit:plugin-health`
|
- `plugin-health` → `/config-audit:plugin-health`
|
||||||
- `whats-active` → `/config-audit:whats-active`
|
- `whats-active` → `/config-audit:whats-active`
|
||||||
|
- `campaign` → `/config-audit:campaign`
|
||||||
- `status` → `/config-audit:status`
|
- `status` → `/config-audit:status`
|
||||||
- `cleanup` → `/config-audit:cleanup`
|
- `cleanup` → `/config-audit:cleanup`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||||
| `/config-audit drift` | Compare current config against a saved baseline |
|
| `/config-audit drift` | Compare current config against a saved baseline |
|
||||||
| `/config-audit plugin-health` | Audit plugin structure and the metadata block at the top of each command/agent file |
|
| `/config-audit plugin-health` | Audit plugin structure and the metadata block at the top of each command/agent file |
|
||||||
| `/config-audit whats-active` | Show active plugins/skills/MCP/hooks/CLAUDE.md with token estimates |
|
| `/config-audit whats-active` | Show active plugins/skills/MCP/hooks/CLAUDE.md with token estimates |
|
||||||
|
| `/config-audit campaign` | Track a machine-wide audit campaign across repos — per-repo status + roll-up, resumable across sessions (human-approved writes) |
|
||||||
|
|
||||||
### Utility
|
### Utility
|
||||||
|
|
||||||
|
|
|
||||||
196
scanners/campaign-write-cli.mjs
Normal file
196
scanners/campaign-write-cli.mjs
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* campaign-write-cli — the human-approved WRITE half of the durable campaign ledger
|
||||||
|
* (v5.7 Fase 2, Block 3c).
|
||||||
|
*
|
||||||
|
* Sibling of the read-only `campaign-cli`: where that one only reports, this one mutates.
|
||||||
|
* Every mutation is routed through the pure, invariant-enforcing lib transforms
|
||||||
|
* (`createLedger`/`addRepo`/`setRepoStatus`) + `saveLedger` — so path normalization/dedup,
|
||||||
|
* idempotent add, the status-lifecycle guard, and the `updatedDate` bump are never
|
||||||
|
* re-implemented by hand. The `/config-audit campaign` command is a thin opus orchestrator:
|
||||||
|
* it reports (via campaign-cli), proposes a change, and only on explicit human approval
|
||||||
|
* invokes a single subcommand here (Verifiseringsplikt). It NEVER auto-writes.
|
||||||
|
*
|
||||||
|
* Determinism mirrors the lib + the knowledge-refresh CLI: `--reference-date` is the only
|
||||||
|
* place the clock is read (defaulting to today), and it is passed to the transforms as the
|
||||||
|
* injected `now`, so the persisted stamps are fully testable.
|
||||||
|
*
|
||||||
|
* Naming: `-cli` suffix → NOT an orchestrated scanner (the scan-orchestrator only loads
|
||||||
|
* scanner modules), so the scanner count is unchanged and the snapshot suite stays
|
||||||
|
* byte-stable.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node campaign-write-cli.mjs init [--ledger-file <p>] [--reference-date <YYYY-MM-DD>]
|
||||||
|
* node campaign-write-cli.mjs add <path>... [--name <n>] [--ledger-file <p>] [--reference-date <d>]
|
||||||
|
* node campaign-write-cli.mjs set-status <path> <status>
|
||||||
|
* [--findings '<json>'] [--session <id>]
|
||||||
|
* [--ledger-file <p>] [--reference-date <d>]
|
||||||
|
* (all accept [--output-file <p>] to write the result payload to a file instead of stdout)
|
||||||
|
*
|
||||||
|
* Exit codes: 0 = write performed, 1 = advisory no-op (init when already initialized),
|
||||||
|
* 3 = error (unknown subcommand, bad args, invalid status, untracked repo, no/corrupt ledger).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import {
|
||||||
|
createLedger,
|
||||||
|
addRepo,
|
||||||
|
setRepoStatus,
|
||||||
|
rollUp,
|
||||||
|
loadLedger,
|
||||||
|
saveLedger,
|
||||||
|
defaultLedgerPath,
|
||||||
|
} from './lib/campaign-ledger.mjs';
|
||||||
|
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
process.stderr.write(`Error: ${message}\n`);
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse argv into a subcommand, positional args, and the flag map. */
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const positionals = [];
|
||||||
|
const flags = { ledgerFile: null, referenceDate: null, outputFile: null, name: null, findings: null, session: null };
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === '--ledger-file' && argv[i + 1] !== undefined) flags.ledgerFile = 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 === '--name' && argv[i + 1] !== undefined) flags.name = argv[++i];
|
||||||
|
else if (a === '--findings' && argv[i + 1] !== undefined) flags.findings = argv[++i];
|
||||||
|
else if (a === '--session' && argv[i + 1] !== undefined) flags.session = argv[++i];
|
||||||
|
else if (a.startsWith('--')) fail(`unknown flag "${a}"`);
|
||||||
|
else positionals.push(a);
|
||||||
|
}
|
||||||
|
return { subcommand: positionals[0], rest: positionals.slice(1), flags };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load an existing ledger, treating a parse error as a hard failure (never clobber corrupt data). */
|
||||||
|
async function loadOrFail(path) {
|
||||||
|
try {
|
||||||
|
return await loadLedger(path); // null on ENOENT (no ledger yet)
|
||||||
|
} catch (err) {
|
||||||
|
fail(`could not read ledger at ${path}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { subcommand, rest, flags } = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
if (!subcommand) fail('a subcommand is required: init | add | set-status');
|
||||||
|
|
||||||
|
const ledgerPath = resolve(flags.ledgerFile || defaultLedgerPath());
|
||||||
|
if (flags.referenceDate && !DATE_RE.test(flags.referenceDate)) fail('--reference-date must be YYYY-MM-DD');
|
||||||
|
// The clock is read here ONLY — the transforms take this injected `now` and stay pure.
|
||||||
|
const now = flags.referenceDate || new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
if (subcommand === 'init') {
|
||||||
|
const existing = await loadOrFail(ledgerPath);
|
||||||
|
if (existing !== null) {
|
||||||
|
// Advisory no-op: never wipe an existing campaign.
|
||||||
|
return emit(
|
||||||
|
{ status: 'ok', action: 'init', written: false, alreadyInitialized: true, ledgerPath },
|
||||||
|
flags.outputFile,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ledger = createLedger({ now });
|
||||||
|
await saveLedger(ledgerPath, ledger);
|
||||||
|
return emit(
|
||||||
|
{
|
||||||
|
status: 'ok', action: 'init', written: true, alreadyInitialized: false, ledgerPath,
|
||||||
|
schemaVersion: ledger.schemaVersion, createdDate: ledger.createdDate, updatedDate: ledger.updatedDate,
|
||||||
|
repos: ledger.repos, rollUp: rollUp(ledger),
|
||||||
|
},
|
||||||
|
flags.outputFile,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'add') {
|
||||||
|
const paths = rest;
|
||||||
|
if (paths.length === 0) fail('add requires at least one repo path');
|
||||||
|
const loaded = await loadOrFail(ledgerPath);
|
||||||
|
const autoInitialized = loaded === null;
|
||||||
|
let ledger = loaded === null ? createLedger({ now }) : loaded;
|
||||||
|
|
||||||
|
const added = [];
|
||||||
|
const skipped = [];
|
||||||
|
for (const p of paths) {
|
||||||
|
const resolved = resolve(p);
|
||||||
|
const present = ledger.repos.some((r) => r.path === resolved);
|
||||||
|
// --name applies only to a lone path; multi-add lets the lib derive each basename.
|
||||||
|
const name = paths.length === 1 ? flags.name || undefined : undefined;
|
||||||
|
ledger = addRepo(ledger, { path: p, name }, { now });
|
||||||
|
(present ? skipped : added).push(resolved);
|
||||||
|
}
|
||||||
|
await saveLedger(ledgerPath, ledger);
|
||||||
|
return emit(
|
||||||
|
{
|
||||||
|
status: 'ok', action: 'add', written: true, autoInitialized, ledgerPath,
|
||||||
|
added, skipped, repos: ledger.repos, rollUp: rollUp(ledger),
|
||||||
|
},
|
||||||
|
flags.outputFile,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'set-status') {
|
||||||
|
const [path, status] = rest;
|
||||||
|
if (!path || !status) fail('set-status requires <path> <status>');
|
||||||
|
const ledger = await loadOrFail(ledgerPath);
|
||||||
|
if (ledger === null) fail(`no ledger at ${ledgerPath} — run "init" or "add" first`);
|
||||||
|
|
||||||
|
let findingsBySeverity;
|
||||||
|
if (flags.findings !== null) {
|
||||||
|
try {
|
||||||
|
findingsBySeverity = JSON.parse(flags.findings);
|
||||||
|
} catch (err) {
|
||||||
|
fail(`--findings must be valid JSON: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const opts = { now };
|
||||||
|
if (findingsBySeverity !== undefined) opts.findingsBySeverity = findingsBySeverity;
|
||||||
|
if (flags.session !== null) opts.sessionId = flags.session;
|
||||||
|
|
||||||
|
let next;
|
||||||
|
try {
|
||||||
|
next = setRepoStatus(ledger, path, status, opts);
|
||||||
|
} catch (err) {
|
||||||
|
// RangeError (bad status) or Error (untracked repo) → caller error.
|
||||||
|
fail(err.message);
|
||||||
|
}
|
||||||
|
await saveLedger(ledgerPath, next);
|
||||||
|
const resolved = resolve(path);
|
||||||
|
return emit(
|
||||||
|
{
|
||||||
|
status: 'ok', action: 'set-status', written: true, ledgerPath,
|
||||||
|
repo: next.repos.find((r) => r.path === resolved), repos: next.repos, rollUp: rollUp(next),
|
||||||
|
},
|
||||||
|
flags.outputFile,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(`unknown subcommand "${subcommand}" — expected init | add | set-status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
211
tests/scanners/campaign-write-cli.test.mjs
Normal file
211
tests/scanners/campaign-write-cli.test.mjs
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
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 { readFileSync, mkdtempSync, existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import {
|
||||||
|
loadLedger,
|
||||||
|
validateLedger,
|
||||||
|
rollUp,
|
||||||
|
} from '../../scanners/lib/campaign-ledger.mjs';
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
const CLI = resolve(__dirname, '../../scanners/campaign-write-cli.mjs');
|
||||||
|
|
||||||
|
const NOW = '2026-06-22';
|
||||||
|
|
||||||
|
// Every test passes an explicit --ledger-file in a temp dir + an injected --reference-date,
|
||||||
|
// so the write-CLI never touches the real default path under HOME and never reads the clock.
|
||||||
|
// The suite is hermetic + deterministic by construction.
|
||||||
|
function runWrite(args) {
|
||||||
|
try {
|
||||||
|
const stdout = execFileSync('node', [CLI, ...args], { encoding: 'utf-8', timeout: 15000 });
|
||||||
|
return { status: 0, stdout };
|
||||||
|
} catch (err) {
|
||||||
|
return { status: err.status, stdout: err.stdout || '', stderr: err.stderr || '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function newDir() {
|
||||||
|
return mkdtempSync(join(tmpdir(), 'camp-write-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the persisted ledger back through the real lib so a test proves the on-disk file
|
||||||
|
// is exactly what the command layer would later load.
|
||||||
|
async function readLedger(file) {
|
||||||
|
const ledger = await loadLedger(file);
|
||||||
|
return { ledger, validation: validateLedger(ledger) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('campaign-write-cli — init', () => {
|
||||||
|
it('creates a valid, empty, versioned ledger (exit 0)', async () => {
|
||||||
|
const dir = newDir();
|
||||||
|
const file = join(dir, 'ledger.json');
|
||||||
|
const { status, stdout } = runWrite(['init', '--ledger-file', file, '--reference-date', NOW]);
|
||||||
|
assert.equal(status, 0);
|
||||||
|
const out = JSON.parse(stdout);
|
||||||
|
assert.equal(out.action, 'init');
|
||||||
|
assert.equal(out.written, true);
|
||||||
|
assert.equal(out.alreadyInitialized, false);
|
||||||
|
assert.equal(out.ledgerPath, resolve(file));
|
||||||
|
|
||||||
|
const { ledger, validation } = await readLedger(file);
|
||||||
|
assert.ok(validation.valid, validation.errors.join('; '));
|
||||||
|
assert.equal(ledger.schemaVersion, 1);
|
||||||
|
assert.equal(ledger.createdDate, NOW);
|
||||||
|
assert.equal(ledger.updatedDate, NOW);
|
||||||
|
assert.deepEqual(ledger.repos, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to clobber an existing ledger (exit 1, file untouched)', async () => {
|
||||||
|
const dir = newDir();
|
||||||
|
const file = join(dir, 'ledger.json');
|
||||||
|
// First init, then add a repo so we can prove a second init does NOT wipe it.
|
||||||
|
runWrite(['init', '--ledger-file', file, '--reference-date', NOW]);
|
||||||
|
runWrite(['add', '/r/a', '--ledger-file', file, '--reference-date', NOW]);
|
||||||
|
|
||||||
|
const { status, stdout } = runWrite(['init', '--ledger-file', file, '--reference-date', NOW]);
|
||||||
|
assert.equal(status, 1, 'advisory: already initialized');
|
||||||
|
const out = JSON.parse(stdout);
|
||||||
|
assert.equal(out.written, false);
|
||||||
|
assert.equal(out.alreadyInitialized, true);
|
||||||
|
|
||||||
|
const { ledger } = await readLedger(file);
|
||||||
|
assert.equal(ledger.repos.length, 1, 'existing repo preserved — not clobbered');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('campaign-write-cli — add', () => {
|
||||||
|
it('auto-initializes when no ledger exists, then adds repos (exit 0)', async () => {
|
||||||
|
const dir = newDir();
|
||||||
|
const file = join(dir, 'ledger.json');
|
||||||
|
assert.ok(!existsSync(file));
|
||||||
|
const { status, stdout } = runWrite([
|
||||||
|
'add', '/r/a', '/r/b', '--ledger-file', file, '--reference-date', NOW,
|
||||||
|
]);
|
||||||
|
assert.equal(status, 0);
|
||||||
|
const out = JSON.parse(stdout);
|
||||||
|
assert.equal(out.action, 'add');
|
||||||
|
assert.equal(out.autoInitialized, true);
|
||||||
|
assert.deepEqual(out.added.sort(), [resolve('/r/a'), resolve('/r/b')].sort());
|
||||||
|
|
||||||
|
const { ledger, validation } = await readLedger(file);
|
||||||
|
assert.ok(validation.valid, validation.errors.join('; '));
|
||||||
|
assert.equal(ledger.repos.length, 2);
|
||||||
|
assert.ok(ledger.repos.every((r) => r.status === 'pending'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends to an existing ledger and is idempotent on re-add (added vs skipped)', async () => {
|
||||||
|
const dir = newDir();
|
||||||
|
const file = join(dir, 'ledger.json');
|
||||||
|
runWrite(['add', '/r/a', '/r/b', '--ledger-file', file, '--reference-date', NOW]);
|
||||||
|
|
||||||
|
const { status, stdout } = runWrite([
|
||||||
|
'add', '/r/a', '/r/c', '--ledger-file', file, '--reference-date', NOW,
|
||||||
|
]);
|
||||||
|
assert.equal(status, 0);
|
||||||
|
const out = JSON.parse(stdout);
|
||||||
|
assert.equal(out.autoInitialized, false);
|
||||||
|
assert.deepEqual(out.added, [resolve('/r/c')]);
|
||||||
|
assert.deepEqual(out.skipped, [resolve('/r/a')]);
|
||||||
|
|
||||||
|
const { ledger } = await readLedger(file);
|
||||||
|
assert.equal(ledger.repos.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses --name for a single path; derives basenames for multiple', async () => {
|
||||||
|
const dir = newDir();
|
||||||
|
const solo = join(dir, 'solo.json');
|
||||||
|
runWrite(['add', '/r/x', '--name', 'custom', '--ledger-file', solo, '--reference-date', NOW]);
|
||||||
|
const { ledger: l1 } = await readLedger(solo);
|
||||||
|
assert.equal(l1.repos[0].name, 'custom');
|
||||||
|
|
||||||
|
const multi = join(dir, 'multi.json');
|
||||||
|
runWrite(['add', '/r/alpha', '/r/beta', '--ledger-file', multi, '--reference-date', NOW]);
|
||||||
|
const { ledger: l2 } = await readLedger(multi);
|
||||||
|
assert.deepEqual(l2.repos.map((r) => r.name).sort(), ['alpha', 'beta']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('campaign-write-cli — set-status', () => {
|
||||||
|
async function seeded() {
|
||||||
|
const dir = newDir();
|
||||||
|
const file = join(dir, 'ledger.json');
|
||||||
|
runWrite(['add', '/r/a', '/r/b', '--ledger-file', file, '--reference-date', NOW]);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('transitions a tracked repo and attaches findings + session (exit 0)', async () => {
|
||||||
|
const file = await seeded();
|
||||||
|
const findings = { critical: 1, high: 2, medium: 0, low: 4 };
|
||||||
|
const { status, stdout } = runWrite([
|
||||||
|
'set-status', '/r/a', 'audited',
|
||||||
|
'--findings', JSON.stringify(findings),
|
||||||
|
'--session', '20260622_120000',
|
||||||
|
'--ledger-file', file, '--reference-date', NOW,
|
||||||
|
]);
|
||||||
|
assert.equal(status, 0);
|
||||||
|
const out = JSON.parse(stdout);
|
||||||
|
assert.equal(out.action, 'set-status');
|
||||||
|
assert.equal(out.repo.status, 'audited');
|
||||||
|
assert.deepEqual(out.repo.findingsBySeverity, findings);
|
||||||
|
assert.equal(out.repo.sessionId, '20260622_120000');
|
||||||
|
|
||||||
|
const { ledger } = await readLedger(file);
|
||||||
|
assert.deepEqual(rollUp(ledger).bySeverity, findings);
|
||||||
|
assert.deepEqual(out.rollUp, rollUp(ledger));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits 3 on an invalid status', async () => {
|
||||||
|
const file = await seeded();
|
||||||
|
const { status } = runWrite([
|
||||||
|
'set-status', '/r/a', 'nonsense', '--ledger-file', file, '--reference-date', NOW,
|
||||||
|
]);
|
||||||
|
assert.equal(status, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits 3 when the repo is not tracked', async () => {
|
||||||
|
const file = await seeded();
|
||||||
|
const { status } = runWrite([
|
||||||
|
'set-status', '/r/ghost', 'audited', '--ledger-file', file, '--reference-date', NOW,
|
||||||
|
]);
|
||||||
|
assert.equal(status, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits 3 when no ledger exists yet', () => {
|
||||||
|
const dir = newDir();
|
||||||
|
const { status } = runWrite([
|
||||||
|
'set-status', '/r/a', 'audited',
|
||||||
|
'--ledger-file', join(dir, 'nope.json'), '--reference-date', NOW,
|
||||||
|
]);
|
||||||
|
assert.equal(status, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('campaign-write-cli — determinism + --output-file', () => {
|
||||||
|
it('stamps updatedDate from --reference-date and writes the payload to --output-file', async () => {
|
||||||
|
const dir = newDir();
|
||||||
|
const file = join(dir, 'ledger.json');
|
||||||
|
runWrite(['init', '--ledger-file', file, '--reference-date', '2026-01-01']);
|
||||||
|
const report = join(dir, 'report.json');
|
||||||
|
const { stdout } = runWrite([
|
||||||
|
'add', '/r/a', '--ledger-file', file, '--reference-date', '2026-03-15',
|
||||||
|
'--output-file', report,
|
||||||
|
]);
|
||||||
|
assert.equal(stdout.trim(), '', 'stdout is silent when --output-file is given');
|
||||||
|
const written = JSON.parse(readFileSync(report, 'utf-8'));
|
||||||
|
assert.equal(written.action, 'add');
|
||||||
|
|
||||||
|
const { ledger } = await readLedger(file);
|
||||||
|
assert.equal(ledger.createdDate, '2026-01-01', 'created stamp preserved');
|
||||||
|
assert.equal(ledger.updatedDate, '2026-03-15', 'updated stamp from the later --reference-date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits 3 on an unknown subcommand', () => {
|
||||||
|
const dir = newDir();
|
||||||
|
const { status } = runWrite(['frobnicate', '--ledger-file', join(dir, 'x.json')]);
|
||||||
|
assert.equal(status, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue