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:
Kjell Tore Guttormsen 2026-06-22 17:25:15 +02:00
commit ee0c762151
7 changed files with 622 additions and 3 deletions

177
commands/campaign.md Normal file
View 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.

View file

@ -1,7 +1,7 @@
---
name: config-audit
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
model: opus
---
@ -29,6 +29,7 @@ If a subcommand is provided, route to it:
- `drift``/config-audit:drift`
- `plugin-health``/config-audit:plugin-health`
- `whats-active``/config-audit:whats-active`
- `campaign``/config-audit:campaign`
- `status``/config-audit:status`
- `cleanup``/config-audit:cleanup`

View file

@ -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 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 campaign` | Track a machine-wide audit campaign across repos — per-repo status + roll-up, resumable across sessions (human-approved writes) |
### Utility