Compare commits

..

No commits in common. "main" and "v6.0.0" have entirely different histories.

16 changed files with 89 additions and 1372 deletions

View file

@ -83,37 +83,6 @@ Finding ID format: `CA-{SCANNER}-{NNN}` — e.g. `CA-CML-001`, `CA-SET-003`, `CA
**`{NNN}` names the CHECK, never the emission position (invariant).** `scanners/lib/finding-codes.mjs` is the single authority: every `finding()` call passes a `code`, and an undeclared or missing one **throws** — there is no counter fallback, because a fallback lets a half-converted scanner ship IDs that look valid. Adding a check takes the next free number for that scanner, never the next source-order position; removing one moves its key to `RETIRED_CODES` and its number is never reissued. IDs are therefore **not unique per finding** — one check failing in three files emits three findings sharing an ID, and `(id, file, line)` is the instance key that `fix-engine` verification uses. Frozen `v5.0.0` baselines mask IDs (`tests/helpers/mask-finding-ids.mjs`) instead of re-deriving them; the check→number pairs are pinned exhaustively in `tests/lib/finding-codes.test.mjs`.
**A file's contract cannot exceed its tools (invariant).** `agents/verifier-agent.md` said both
"Append to: implementation-log.md" (§Output Format) and "never modifies any files" (§Read-Only
Guarantee) while granting only `Read, Glob, Grep`. The failure mode is not a blocked write — it is
the agent improvising a full-file `Write` on the log it *shares* with the parallel implementer
agents, which is the exact defect `implement-log-append.test.mjs` exists to prevent, entering
through the one file that test does not read. `tests/agents/agent-write-contract.test.mjs` asserts
the blanket invariant over the whole catalogue rather than a fact about one file: an agent whose
`tools:` grant no write capability (`Write`/`Edit`/`NotebookEdit`/`Bash`) must instruct no file
write **and** state positively that it returns its findings inline. Both sides are read from the
file, so stripping `Write` from any agent whose body still instructs a write turns it red, and the
sweep asserts a write-tool-less agent exists so the invariant cannot pass vacuously. Measured
2026-08-20: **1 of 7** agents carried the defect; the other six all hold `Write`. The orchestrator
half was already correct — `implement.md` Step 5 appends with Bash `>>` and tells the agent not to
write — so this was a one-way fix in the agent file, not a two-sided one.
**Frontmatter contracts are derived, never listed (invariant).** `.claude/rules/agent-development.md`
and `.claude/rules/command-development.md` state which keys an agent/command MUST carry and that
agent colors are unique; nothing enforced any of it, and the only test that read agent frontmatter
checked `name:` against a hand-written 3-of-7 list. `tests/agents/frontmatter-contract.test.mjs`
takes nothing by hand: required keys are parsed from each rule's own fenced `yaml` block, the files swept
are resolved from each rule's own `paths:`, and the plugin name is read from
`.claude-plugin/plugin.json` — add a key to a rule and it is enforced on the next run with no test
edit, because a hand-kept list of what to sweep is a premise, not a measurement. The repo was
already compliant (7/7 agents, 21/21 commands, 0 duplicate colors), so a green first run proves
nothing: every arm was seen red against a temporarily introduced defect **one at a time**,
including emptying a rule's yaml block to show the derivation is not vacuous. The color **enum** is
deliberately *not* guarded — the official subagent docs list `red/blue/green/yellow/purple/orange/
pink/cyan` (no `magenta`) while issue #19292 lists `magenta` but neither `purple` nor `orange`, and
this plugin ships both `magenta` and `orange`; pinning an unsettled set in a guard would encode an
unverified premise rather than measure one.
## Conventions
Enforced conventions live in `.claude/rules/` (auto-loaded as project instructions):
@ -228,35 +197,6 @@ per-candidate `modelScope` appear **only** when the flag is passed, so a plain
`--subtract` run is byte-identical to the pre-flag payload (asserted on the
serialized bytes, since a key set to `undefined` passes a shallow key check).
**Backup/restore is one code path (invariant).** `scanners/rollback-cli.mjs` is the only entry to
the backup engine, and BOTH pipelines use it: `fix` backs up through `createBackup` directly,
`implement` Step 3 through `--create`. Before R1/R2 the two halves each hid the other's failure.
The engine verified every checksum before and after each write, but had no `process.argv` (16 CLIs
under `scanners/` had one, it did not), so `commands/rollback.md` restored as model prose — an ESM
`import` block a template cannot execute, `cp` offered underneath as the runnable path, and
"(checksum verified)" pre-rendered in the success output. `cp` establishes no checksum, so the
verification was a property of the template. Meanwhile `implement` hand-built its manifest in the
template while `parseManifest` knew one frozen sample of that format, pinned by a HAND-WRITTEN
fixture rather than by the template's own text — the #63 shape on the data side, where a renamed
key yields zero parsed files and a rollback that reports success having restored nothing. Four
properties are load-bearing. (1) **Neither half fixes alone**: a CLI over a prose format still
parses prose; a clean format with no runnable entry still cannot restore. (2) **A gated restore is
exit 1, not 3** — "this write leaves your project" is a verdict about a write that WAS examined and
rides in the payload, where a command running under `2>/dev/null` can act on it (F3's class); 3
stays reserved for argv errors and a backup id that resolves in neither root. (3) **`--created`
records, it does not copy** — no backup can hold a file that does not exist yet, so those paths go
into the manifest for `rollback` to report as left in place; `serializeManifest` emits the bare
`created:` key, which is why the pre-R2 `created: <timestamp>` (a VALUE, meaning the backup id)
never collides with it. (4) **`parseManifest`'s implement-format branch stays** even though nothing
writes that shape now — backups already on disk in it must remain restorable, the same reason
`getLegacyBackupDir()` is still read; its fixture changed meaning from "a stand-in for the
template's text" to "a golden sample of historical bytes". The output contract is guarded by
running the CLI: `tests/commands/backup-restore-contract.test.mjs` checks every field
`rollback.md` renders against a real payload, so a renamed key fails there instead of becoming a
confident sentence in front of a user who is already in trouble. Distinct from the scope gate,
which classifies *where* a restore lands — `rollback.md` still calls `write-scope-cli` before its
approval surface, and classifying is still not approving.
**Subtraction write path (invariant).** `--apply` routes through `scanners/lib/subtraction-write.mjs`, never through `fix-engine` or the `plan`/`implement` pipeline, and both exclusions are **measured**: the subtraction axis is absent from the orchestrated envelope, so `verifyFixes`' re-scan would mark every removal `verified` whether or not it happened (a success-shaped no-op), and the findings pipeline needs a finding code — which names a deterministic check, not a prose judgement. Three properties are load-bearing and each has a guard seen red against its own defect: removals are validated against the ORIGINAL content and applied in **descending** line order (an ascending pass shifts later spans out from under themselves); the **range** check is not redundant with the text check (`line: 0` makes `slice(-1, 0)` empty, so an empty `text` matches and `splice(-1, 1)` deletes the file's LAST line); and `createBackup` skips a nonexistent path while still returning an id, so coverage of every file about to be written is **asserted from the manifest** before a byte changes. The floor is *repeated* here, not moved: `floor-exclusion` still vetoes before anything is proposed, and the engine refuses a load-bearing block again so a hand-built approval cannot route around it. The archive rule (`mv` to `_archive/`) is file-level and does not apply to a block excision — the timestamped backup is the recovery artifact, and inventing a second copy with no restorer behind it would be worse than none.
## Testing

View file

@ -304,10 +304,7 @@ every project on every turn.
That gate now runs inside the engines, not only in the command prose that wraps them. It covers
`/config-audit fix`, `/config-audit rollback`, `campaign export`, `--save-baseline`, and
`optimize --subtract --apply`. For rollback that is now literal: the restore runs through
`scanners/rollback-cli.mjs`, which verifies each file's checksum before and after writing it, so
"restored and verified" is something the run reports rather than something the output template
says. A restore that would land outside your project is refused with nothing written. When a run is withheld, nothing has been written: you get the
`optimize --subtract --apply`. When a run is withheld, nothing has been written: you get the
reason and the affected paths, and you re-run with your approval (`--approve-scope` on the CLIs).
Approval is always a separate act — classifying a target is not approving it. The plugin's own
bookkeeping (backups, session state, ledgers, and the report file you named with `--output-file`)
@ -450,7 +447,6 @@ All tools work standalone — no Claude Code session needed:
| **Tokens** | `node scanners/token-hotspots-cli.mjs <path> [--json] [--global] [--no-exclude-cache] [--output-file path] [--accurate-tokens] [--with-telemetry-recipe]` |
| **Manifest** | `node scanners/manifest.mjs <path> [--json]` — ranked component-level source table with per-source load pattern + always-loaded subtotal |
| **What's active** | `node scanners/whats-active.mjs <path> [--json] [--verbose] [--suggest-disables]` |
| **Backup / restore** | `node scanners/rollback-cli.mjs [--list] [--restore <id>] [--delete <id>] [--create --target <path> …] [--created <path>] [--dry-run] [--approve-scope] [--repo <root>] [--json] [--output-file path]` |
| **Self-audit** | `node scanners/self-audit.mjs [--json] [--fix] [--check-readme]` |
| **Full scan** | `node scanners/scan-orchestrator.mjs <path> [--global] [--full-machine] [--no-suppress]` |
@ -610,7 +606,6 @@ Shared modules used by all scanners — useful if you're reading the source or e
| `whats-active.mjs` | CLI: read-only active-config inventory (v3.1.0+) |
| `token-hotspots-cli.mjs` | CLI: token hotspots ranking with optional `--accurate-tokens` |
| `write-scope-cli.mjs` | CLI: classify write targets before an approval surface (`--target`, repeatable) |
| `rollback-cli.mjs` | CLI: the runnable entry to backup and restore (`--list` / `--create` / `--restore` / `--delete`). Both pipelines back up through this one code path, so the manifest format is never written or read by hand |
---

View file

@ -140,15 +140,7 @@ Checking for secrets...
## Output Format
Return the report below as your final message. Do NOT write it to a file: this
agent is read-only by design (`tools: Read, Glob, Grep`) and has no write tool,
so a write instruction here would be a contract it cannot keep.
The orchestrator appends what you return to
`~/.claude/config-audit/sessions/{session-id}/implementation-log.md` itself,
with Bash `>>` (`commands/implement.md` Step 5) — never the Write tool. That log
is shared with the implementer agents running in parallel, and a full-file Write
on it silently clobbers their entries.
Append to: `~/.claude/config-audit/sessions/{session-id}/implementation-log.md`
```markdown
## Verification Report
@ -251,8 +243,8 @@ Optional: Generate before/after comparison:
This agent:
- Only uses Read, Glob, Grep tools
- Never modifies any files, including the shared implementation log
- Reports findings without taking action — every result is returned inline
- Never modifies any files
- Reports findings without taking action
- Safe to run multiple times
## Model policy

View file

@ -87,32 +87,37 @@ AskUserQuestion:
### Step 3: Create backup
Create the backup through the code that owns the format. Pass one `--target` per
pre-existing file the plan will MODIFY, and one `--created` per file the plan
will CREATE — a backup cannot hold a file that does not exist yet, so those are
recorded rather than copied, and `/config-audit rollback` reads them back to tell
the user which files it is leaving behind.
Create backup silently, and **print the backup ID** — Step 6 has to tell the user
how to roll back, and a timestamp that only ever existed inside a command
substitution cannot be quoted later. Shell state does not survive to the next
block, so capture the printed value and substitute it literally from here on:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --create --target "<file-1>" --target "<file-2>" --created "<new-file-1>" --repo "$PWD" --output-file /tmp/config-audit-implement-backup.json 2>/dev/null; echo $?
BACKUP_ID=$(date +%Y%m%d_%H%M%S)
mkdir -p ~/.claude/config-audit/backups/"$BACKUP_ID"/files/ 2>/dev/null
echo "$BACKUP_ID"
```
| Exit | Meaning |
|------|---------|
| 0 | every target was backed up |
| 1 | at least one target was not there — read `skipped[]` before continuing |
| 3 | the CLI could not run (show the stderr message); do not edit anything |
Use the printed ID wherever `{backup-id}` appears below. Never invent or re-derive
it with a second `date` call — a run that straddles a second boundary would hand
the user a rollback ID that does not exist.
Read `/tmp/config-audit-implement-backup.json`. The payload's `backupId` is the
ID to quote from here on — **never re-derive it**. Shell state does not survive
to the next block, and a second `date` call that straddles a second boundary
would hand the user a rollback ID that does not exist. Use it wherever
`{backup-id}` appears below.
Copy each file to be modified. Generate `manifest.yaml` with checksums.
On exit 1, name the `skipped[]` paths before doing anything else: the plan is
about to change files that have no backup behind them. If any skipped path is one
the plan MODIFIES (rather than creates), stop and report — that action cannot be
rolled back.
The manifest is what `/config-audit rollback` reads, so it MUST carry both lists:
```yaml
files: # pre-existing files this run will MODIFY
- backup: files/root/CLAUDE.md
original: /abs/path/CLAUDE.md
sha256: <sha256 of the pre-change content>
created: # files this run will CREATE (no backup can exist)
- /abs/path/.claude/rules/post-quality.md
```
Record every `create`-type action under `created:`. Rollback cannot restore a
file that never existed, but it must be able to tell the user which files it is
leaving behind — a half-restored target is only dangerous when it is silent.
Tell the user: **"Backup created. Implementing actions..."**
@ -217,10 +222,8 @@ A full-file Write that names only two of the four silently deletes the other two
## Rollback
If the user requests rollback at any point:
1. Run `/config-audit rollback {backup-id}` — it drives `scanners/rollback-cli.mjs`,
which verifies each checksum before and after writing. Do not restore by hand:
a copy performs neither check, and the result cannot be reported as verified.
2. Read the restore payload and report the per-file `status` it returns
1. Read `manifest.yaml` from backup
2. Restore each file and verify checksums
3. **Report — do not delete — the files this run created.** Rollback restores from
backup, and no backup can exist for a file that did not exist before. Those
paths stay on disk; `/config-audit rollback` lists them under "Left in place"

View file

@ -10,12 +10,6 @@ model: sonnet
Restore configuration files from a previous backup. Without arguments, lists available backups. With a backup ID, restores files from that backup.
Every step below runs `scanners/rollback-cli.mjs`, which drives the rollback
engine: it verifies each file's checksum before AND after writing it, resolves
backups made under the pre-v2.2.0 root, and reports the files a restore cannot
undo. Never restore by copying files back by hand — a copy performs none of
those checks, and the result cannot honestly be reported as verified.
## Arguments
- `$ARGUMENTS` may contain a backup ID (format: `YYYYMMDD_HHMMSS`)
@ -25,18 +19,14 @@ those checks, and the result cannot honestly be reported as verified.
### List mode (no argument)
Parse flags and list available backups from `~/.claude/config-audit/backups/`:
```bash
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --list --output-file /tmp/config-audit-rollback-list.json 2>/dev/null; echo $?
ls -1 ~/.claude/config-audit/backups/
```
Exit 0 = listed; 3 = the CLI could not do its job (show the stderr message).
Read `/tmp/config-audit-rollback-list.json` and render one row per entry in
`backups[]``{id}`, how many `{files}` it holds, and `{createdAt}`. An entry
whose `{legacy}` is true was made under the pre-v2.2.0 backup root; say so, since
its path differs from the one printed below.
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Available Backups
@ -50,16 +40,16 @@ its path differs from the one printed below.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
If `{count}` is 0, say there are no backups yet and stop — do not offer a restore.
Use the Read tool on each backup's `manifest.yaml` (the list of changes captured at backup time) to extract the file list and timestamps.
### Restore mode (with backup ID)
1. The `backups[]` entry for that ID (from the list payload above) carries the
`files[]` this restore would write. Classify those `{originalPath}` values
before showing them. A restore writes to the absolute path recorded at backup
time, which may be machine-wide even when the backup was taken from a project
— so the file list must be rendered as the absolute originals, never shortened
to a repo-relative-looking form that implies the write stays local:
1. Read the list of changes from `~/.claude/config-audit/backups/{backup-id}/manifest.yaml` using the Read tool
2. Classify the `original:` paths before showing them. A restore writes to the
absolute path recorded at backup time, which may be machine-wide even when the
backup was taken from a project — so the file list must be rendered as the
absolute originals, never shortened to a repo-relative-looking form that
implies the write stays local:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/write-scope-cli.mjs --target "<original-1>" --target "<original-2>" --repo "$PWD" --output-file /tmp/config-audit-rollback-scope.json 2>/dev/null; echo $?
@ -86,45 +76,21 @@ If `{count}` is 0, say there are no backups yet and stop — do not offer a rest
- "Cancel"
- "Yes — restore, including outside this project"
```
2. Run the restore. Classifying is not approving: add `--approve-scope` only
after the user has answered yes to the question above, and only then.
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --restore "<backup-id>" --repo "$PWD" --output-file /tmp/config-audit-rollback-restore.json 2>/dev/null; echo $?
3. For each file in the list of changes:
a. Read the backup file from `~/.claude/config-audit/backups/{backup-id}/files/{safeName}`
b. Write to the original path
c. Verify the checksum matches the recorded value in the list of changes
4. Show result:
```
Append ` --approve-scope` to that command when the user approved a restore
that leaves this project. To preview without writing anything, append
` --dry-run` instead — a dry run reports what would happen and touches no file.
| Exit | Meaning |
|------|---------|
| 0 | every file restored |
| 1 | nothing was written — the restore needs approval it was not given |
| 2 | at least one file failed; read `failed[]` before saying anything else |
| 3 | the CLI could not run (bad ID, unreadable manifest) — show the stderr message |
3. Read `/tmp/config-audit-rollback-restore.json` and report what the run
actually did. Render one line per entry in `restored[]` and `failed[]`, each
showing its own `{status}` from the payload — never a fixed verification
phrase, because the outcome differs per file and only the payload knows it:
Restored 3 files from backup 20260403_163045
- /abs/path/.claude/settings.json (checksum verified)
- /abs/path/hooks/hooks.json (checksum verified)
- .claude/rules/typescript.md (checksum verified)
```
Restored 2 of 3 files from backup 20260403_163045
- /abs/path/.claude/settings.json — {status}
- /abs/path/hooks/hooks.json — {status}
- /abs/path/.claude/rules/typescript.md — {status}
```
When `{requiresApproval}` is true and nothing was restored, the run was
refused: list the `refused[]` paths, say plainly that no file was changed, and
offer to re-run with approval.
4. **Report what rollback cannot undo.** A backup only holds files that already
existed, so files the implement step CREATED survive the restore. When
`createdNotRemoved` is non-empty, list those paths and say plainly that they
remain:
5. **Report what rollback cannot undo.** A backup only holds files that already
existed, so files the implement step CREATED survive the restore. If the
manifest has a `created:` section (or `restoreBackup()` returns a non-empty
`createdNotRemoved`), list those paths and say plainly that they remain:
```
Left in place — created by implement, no backup exists:
- .claude/rules/post-quality.md
@ -136,24 +102,29 @@ If `{count}` is 0, say there are no backups yet and stop — do not offer a rest
### Delete mode
If the user says "delete" after listing, confirm, then:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --delete "<backup-id>" --output-file /tmp/config-audit-rollback-delete.json 2>/dev/null; echo $?
```
Exit 0 = removed (`deleted` is true in the payload); 3 = no backup with that ID
in either root — show the stderr message rather than reporting a deletion.
If user says "delete" after listing, confirm and remove the backup directory.
## Implementation
`scanners/rollback-cli.mjs` is the only entry point. It reads
`~/.claude/config-audit/backups` and falls back to the pre-v2.2.0
`~/.config-audit/backups`, so a backup made before the move still resolves; the
list payload flags those with `legacy: true`, and both roots are echoed in
`meta` so a report can name the one it used.
Use the backup and rollback libraries directly:
```javascript
import { listBackups, restoreBackup, deleteBackup } from '../scanners/rollback-engine.mjs';
import { parseManifest, getBackupDir } from '../scanners/lib/backup.mjs';
```
The same CLI creates backups (`--create --target <path> …`), which is how
`/config-audit implement` records what it is about to change. Backup and restore
therefore share one manifest format, owned by `scanners/lib/backup.mjs` — the
format is never written or read by hand.
Both read `~/.claude/config-audit/backups` and fall back to the pre-v2.2.0
`~/.config-audit/backups`, so a backup made before the move still resolves;
`listBackups()` flags those with `legacy: true`. Prefer this API over ad-hoc
`cp` — it verifies the checksum before and after each write.
Or via Bash:
```bash
# List backups
ls -1 ~/.claude/config-audit/backups/
# Read manifest
cat ~/.claude/config-audit/backups/{id}/manifest.yaml
# Restore (copy back)
cp ~/.claude/config-audit/backups/{id}/files/{safeName} {originalPath}
```

View file

@ -60,7 +60,6 @@ everything because they sit on the *recovery* path — the surface that runs
exactly when the user is already in trouble, and the least-exercised one.
### R1 — the restore flow is model-executed prose; the code engine has no CLI entry
**CLOSED in #74** — `scanners/rollback-cli.mjs`. The measurement below is kept as written.
**Where:** `commands/rollback.md` §Implementation; `scanners/rollback-engine.mjs`.
**Measured:** 16 scanner CLIs carry a `process.argv` entry — `rollback-engine.mjs`
is not one of them. The template's "Implementation" shows an ESM `import` block a
@ -79,7 +78,6 @@ CLI here, the whole Q2 guard class is structurally blind to this command.
then covers it for free. The "(checksum verified)" line becomes payload-driven.
### R2 — implement's backup manifest is hand-built prose; the parser knows one frozen sample of it
**CLOSED in #74** — the real fix, not the minimum: `implement.md` Step 3 calls `rollback-cli.mjs --create`, so the prose format has no author left. The measurement below is kept as written.
**Where:** `commands/implement.md` Step 3 (mkdir/cp + hand-written
`manifest.yaml` with sha256 lines); `scanners/lib/backup.mjs` `parseManifest`;
`tests/scanners/rollback-paths.test.mjs:157-186`.

View file

@ -805,71 +805,3 @@ once the gate made it unreachable, along with the now-redundant `&& args[i + 1]`
Exit code is **3** by the exit-code contract: a malformed argument means the scanner never got
to do its job, which is categorically different from 0/1/2 — verdicts about a configuration
that *was* examined.
---
### rollback-cli — the recovery path gets a runnable entry (R1+R2, #74)
`scanners/rollback-cli.mjs` is the entry to `rollback-engine.mjs` and, via
`--create`, to `lib/backup.mjs`. It closes the two KRITISK rows of the Q3
severity table as one chunk, because neither half holds alone.
**R1 — the restore was model prose over a code engine.** Measured at the head of
the chunk: 16 files under `scanners/` carried a `process.argv` entry;
`rollback-engine.mjs` was not one of them, though it had verified every file's
checksum *before and after* each write since M-BUG-22 and reported
`createdNotRemoved` since M-BUG-25. `commands/rollback.md` §Implementation
showed an ESM `import` block a command template cannot execute and offered
ad-hoc `cp` underneath as the runnable alternative, then pre-rendered
"`(checksum verified)`" three times in the success output. `cp` establishes no
checksum, so the verification was a property of the template, not of the run —
on the one surface that runs when the user is already in trouble.
**R2 — the backup format had two authors, one of them prose.** The fix pipeline
backed up through `createBackup`; the implement pipeline hand-built its own —
`mkdir`, `cp`, a `date +%Y%m%d_%H%M%S` id, and a manifest typed out in the
template. `parseManifest` grew a second branch for that format because the seam
had already failed silently once, and the fixture pinning it was **hand-written**
rather than derived from the template's own text: the #63 shape on the data
side. Rename a key in the template and `parseManifest` returns zero files while
`rollback` reports success.
**Why one chunk.** Fix only R1 and the new CLI still parses a prose format. Fix
only R2 and the format is clean with no runnable entry behind it.
**Exit contract.** 0 done · 1 outstanding (a restore the scope gate will not
perform without `--approve-scope`, nothing written; or a backup that covered
fewer targets than it was given) · 2 at least one file failed · 3 the CLI could
not do its job. A gated restore is **1, not 3**: "this write leaves your project"
is a verdict about a write that *was* examined, and it rides in the payload,
where a command running under `2>/dev/null` can act on it. That is F3's class,
avoided rather than repeated.
**`--created` records, it does not copy.** No backup can hold a file that does
not exist yet. Those paths go into the manifest so `rollback` can list what it is
leaving in place. `serializeManifest` emits the bare `created:` key; the pre-R2
implement format used `created: <timestamp>` with a VALUE, meaning the backup id,
and `parseManifest` tells them apart on exactly that — which is why the two never
collide in one file.
**The implement-format branch stays.** Nothing writes that shape any more, but
every backup implement made before this chunk is on disk in it and must remain
restorable — the same reasoning that keeps `getLegacyBackupDir()` readable. The
hand-written fixture in `tests/scanners/rollback-paths.test.mjs` therefore
changed meaning rather than becoming obsolete: it is now a golden sample of
historical bytes, which is a legitimate thing to write by hand, instead of a
stand-in for a template's own text, which is not.
**What replaced "(checksum verified)".** `tests/commands/backup-restore-contract.test.mjs`
runs the CLI and checks every field `rollback.md` renders against the real
payload. A renamed payload key now fails a test instead of turning into a
confident sentence. Seen red against its own defect by renaming `{status}`.
**A guard hole found by mutation.** The first version of the implement-side
assertion matched `--create` as a substring, and the same invocation carries
`--created` — so replacing the `--create` call with `--list` left the guard
green. It matches `--create(?![a-z])` now. Separately, mutating
`if (!requireValidArgs(...)) return;` into a bare call showed that
`requireValidArgs` sets exit 3 *by itself*: a CLI can report "I could not parse
my arguments" and still run the restore underneath. `rollback-cli.test.mjs`
asserts on the bytes for that case, not on the exit code.

View file

@ -238,10 +238,10 @@ async function main() {
if (regressions.length > 0) {
process.stderr.write(` Regressions: ${regressions.join(', ')}\n`);
}
// The user-facing entry, not the CLI: `/config-audit rollback` renders
// the scope disclosures and asks before a restore that leaves the repo.
// `scanners/rollback-cli.mjs` exists now (R1) and is what the command
// runs, but naming it here would hand the user the ungated half.
// There is no rollback-cli.mjs — the restore path is the command, which
// drives rollback-engine.mjs. Pointing at a nonexistent script in the
// one message a user reaches for after a bad fix is the worst place for
// a dead reference.
process.stderr.write(`\n Rollback: /config-audit rollback ${backupId}\n`);
}
}

View file

@ -70,19 +70,10 @@ export function checksum(content) {
/**
* Create a backup of the specified files.
*
* `opts.created` records paths the caller is about to CREATE. No backup can
* hold a file that does not exist yet, so these are not copied they are
* written into the manifest so `rollback` can tell the user which files it is
* leaving behind. That list used to exist only in `commands/implement.md`,
* typed out by hand next to a manifest the template also typed out by hand;
* moving it here is what lets the template stop owning the format (R2).
*
* @param {string[]} files - Array of absolute file paths to back up
* @param {object} [opts]
* @param {string} [opts.backupId] - Override backup ID (for testing)
* @param {string[]} [opts.created] - Paths this run will create (recorded, not copied)
* @returns {{ backupId: string, backupPath: string, manifest: object, skipped: string[] }}
* @returns {{ backupId: string, backupPath: string, manifest: object }}
*/
export function createBackup(files, opts = {}) {
const backupId = opts.backupId || generateBackupId();
@ -92,13 +83,9 @@ export function createBackup(files, opts = {}) {
mkdirSync(filesDir, { recursive: true });
const manifestFiles = [];
const skipped = [];
for (const file of files) {
// A target that is not there is reported, never silently dropped: the
// caller asked for a backup of N files and must be able to learn it got
// fewer, before it edits anything.
if (!existsSync(file)) { skipped.push(file); continue; }
if (!existsSync(file)) continue;
const safeName = safeFileName(file);
copyFileSync(file, join(filesDir, safeName));
@ -119,7 +106,6 @@ export function createBackup(files, opts = {}) {
created_at: new Date().toISOString(),
backup_id: backupId,
files: manifestFiles,
created: [...(opts.created || [])],
};
// Write manifest as YAML-like format
@ -129,7 +115,7 @@ export function createBackup(files, opts = {}) {
// Cleanup old backups
cleanupOldBackups();
return { backupId, backupPath, manifest, skipped };
return { backupId, backupPath, manifest };
}
/**
@ -147,14 +133,6 @@ function serializeManifest(manifest) {
yaml += ` checksum: "${f.checksum}"\n`;
yaml += ` size_bytes: ${f.sizeBytes}\n`;
}
// Emitted only when non-empty, and read back by `parseManifest`'s `created:`
// branch — the bare-key form, which is why the implement flow's
// `created: <timestamp>` (a VALUE, meaning the backup id) never collides
// with it.
if (manifest.created && manifest.created.length > 0) {
yaml += `created:\n`;
for (const c of manifest.created) yaml += ` - ${c}\n`;
}
return yaml;
}
@ -190,13 +168,10 @@ export function parseManifest(content) {
}
}
// Parse file entries — implement-flow format. Until R2, `commands/implement.md`
// had the agent hand-build the backup dir, so manifests written by that flow
// use unquoted `- backup:` / `original:` / `sha256:`. Reading only the engine
// format made restoreBackup a success-shaped no-op on every backup implement
// produced (M-BUG-25). The template no longer writes this format, but the
// branch stays: backups already on disk in it must remain restorable — the
// same reason `getLegacyBackupDir()` is still read.
// Parse file entries — implement-flow format. `commands/implement.md` has the
// agent hand-build the backup dir, so real manifests on disk use unquoted
// `- backup:` / `original:` / `sha256:`. Reading only the engine format made
// restoreBackup a success-shaped no-op on every backup implement produced.
if (result.files.length === 0) {
const implBlocks = content.split(/\n\s+-\s+backup:/).slice(1);
for (const block of implBlocks) {

View file

@ -1,208 +0,0 @@
#!/usr/bin/env node
/**
* Config-Audit Rollback CLI the runnable entry to the backup/restore engine.
*
* `rollback-engine.mjs` has verified checksums before AND after each write,
* resolved the pre-v2.2.0 backup root and reported `createdNotRemoved` since
* M-BUG-22/M-BUG-25. None of it was reachable: measured at the head of this
* chunk, 16 files under `scanners/` carried a `process.argv` entry and the
* engine was not one of them. `commands/rollback.md` drove the restore as model
* prose an ESM `import` block a template cannot execute, with ad-hoc `cp`
* offered underneath as the runnable alternative and "(checksum verified)"
* pre-rendered in the success output. `cp` establishes no checksum, so the
* verification was a property of the template rather than of the run (R1).
*
* `--create` lives here for the same reason `--restore` does. The implement
* pipeline used to build its backup by hand `mkdir`, `cp`, a `date`-derived
* id and a manifest typed out in the template while `parseManifest` knew one
* frozen sample of that format, pinned by a hand-written fixture rather than by
* the template's own text. One side of that contract was maintained by editing
* prose (R2). Now both sides are `lib/backup.mjs`.
*
* Usage:
* node rollback-cli.mjs [--list]
* node rollback-cli.mjs --restore <backup-id> [--dry-run] [--approve-scope]
* node rollback-cli.mjs --delete <backup-id>
* node rollback-cli.mjs --create --target <path> [--target <path> ...]
* [--created <path> ...] [--backup-id <id>]
* ... plus [--repo <session-root>] [--output-file <path>] [--json]
*
* Exit codes:
* 0 done, nothing owed
* 1 the run completed but something is outstanding a restore the scope
* gate will not perform without `--approve-scope` (nothing was written),
* or a backup that covered fewer targets than it was given
* 2 at least one file failed to restore (checksum mismatch or write error)
* 3 the CLI could not do its job malformed argv, or a backup id that
* resolves in neither root
*
* A gated restore is exit 1, not 3: "this write leaves your project" is a
* verdict about a write that WAS examined, and it rides in the payload where a
* command can act on it. Anything that only reaches stderr is invisible to a
* command running under `2>/dev/null` (F3's class).
*
* Zero external dependencies.
*/
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
import { createBackup, getBackupDir, getLegacyBackupDir } from './lib/backup.mjs';
import { listBackups, restoreBackup, deleteBackup } from './rollback-engine.mjs';
/** Flag surface. Anything else is exit 3. */
const ARG_SPEC = {
boolean: ['--list', '--create', '--dry-run', '--approve-scope', '--json'],
value: ['--restore', '--delete', '--target', '--created', '--backup-id', '--repo', '--output-file'],
};
/** The mode flags, in the order a diagnostic should name them. */
const MODES = ['--list', '--create', '--restore', '--delete'];
function fail(message) {
process.stderr.write(`Error: ${message}\n`);
process.exitCode = 3;
}
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
const targets = [];
const created = [];
let mode = null;
let backupId = null;
let dryRun = false;
let approveScope = false;
let jsonMode = false;
let outputFile = null;
let overrideId = null;
// The session's root, never a path derived from the backup (#63). A restore
// writes to the ABSOLUTE originals recorded at backup time, so reading the
// root off those paths would call a machine-wide write "in-repo" and silence
// the strongest gate exactly where it matters.
let repoRoot = process.cwd();
const setMode = (flag) => {
if (mode !== null && mode !== flag) return false;
mode = flag;
return true;
};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (MODES.includes(a)) {
if (!setMode(a)) {
// Silently letting the last one win is how a `--delete` rides along
// behind a `--list` the caller thought it was running.
fail(`only one mode may be given (${MODES.join(', ')}); saw "${mode}" and "${a}"`);
return;
}
if (a === '--restore' || a === '--delete') backupId = args[++i];
} else if (a === '--target') targets.push(args[++i]);
else if (a === '--created') created.push(args[++i]);
else if (a === '--backup-id') overrideId = args[++i];
else if (a === '--repo') repoRoot = args[++i];
else if (a === '--output-file') outputFile = args[++i];
else if (a === '--dry-run') dryRun = true;
else if (a === '--approve-scope') approveScope = true;
else if (a === '--json') jsonMode = true;
}
if (mode === null) mode = '--list';
const meta = {
mode: mode.slice(2),
backupRoot: getBackupDir(),
legacyBackupRoot: getLegacyBackupDir(),
repo: resolve(repoRoot),
};
let payload;
let lines = [];
if (mode === '--list') {
const { backups } = await listBackups();
payload = { meta, count: backups.length, backups };
lines = backups.map((b) => `${b.id}\t${b.files.length}\t${b.legacy ? 'legacy' : 'current'}`);
} else if (mode === '--create') {
if (targets.length === 0) {
fail('--create needs at least one --target');
return;
}
const result = createBackup(targets, {
...(overrideId ? { backupId: overrideId } : {}),
created,
});
payload = {
meta,
backupId: result.backupId,
backupPath: result.backupPath,
files: result.manifest.files,
created: result.manifest.created,
skipped: result.skipped,
};
lines = [`${result.backupId}\t${result.manifest.files.length}\t${result.skipped.length}`];
// A backup that covers fewer files than it was asked for is the state the
// caller must not mistake for a clean one: it is about to edit a file it
// cannot roll back.
if (result.skipped.length > 0) process.exitCode = 1;
} else if (mode === '--delete') {
const result = await deleteBackup(backupId);
if (!result.deleted) {
fail(result.error);
return;
}
payload = { meta, backupId, deleted: true, error: null };
lines = [`${backupId}\tdeleted`];
} else {
let result;
try {
result = await restoreBackup(backupId, { dryRun, approveScope, repoRoot });
} catch (err) {
// "Backup not found" and "unreadable manifest" are both the CLI failing to
// do its job, never a verdict about a restore that happened.
fail(err.message);
return;
}
payload = {
meta,
backupId,
dryRun,
gate: result.gate,
requiresApproval: result.requiresApproval,
disclosures: result.disclosures,
restored: result.restored,
failed: result.failed,
refused: result.refused,
createdNotRemoved: result.createdNotRemoved ?? [],
legacy: result.legacy ?? false,
};
lines = [
...result.restored.map((r) => `${r.status}\t${r.originalPath}`),
...result.failed.map((f) => `${f.status}\t${f.originalPath}`),
...result.refused.map((r) => `${r.status}\t${r.originalPath}`),
];
if (result.failed.length > 0) process.exitCode = 2;
else if (payload.requiresApproval && !approveScope && !dryRun) process.exitCode = 1;
}
const json = `${JSON.stringify(payload, null, 2)}\n`;
if (outputFile) {
await writeOutputFile(outputFile, json);
// Nothing on stdout when writing to a file — a command rendering this would
// otherwise show the user the raw payload (ux-rules rule 1).
} else if (jsonMode) {
process.stdout.write(json);
} else {
for (const line of lines) process.stdout.write(`${line}\n`);
}
}
try {
await main();
} catch (err) {
process.stderr.write(`Error: ${err.message}\n`);
process.exitCode = 3;
}

View file

@ -1,109 +0,0 @@
/**
* R4 an agent file must not carry a contract its `tools:` cannot keep.
*
* `agents/verifier-agent.md` said both things at once: §Output Format
* ("Append to: implementation-log.md") and §Read-Only Guarantee ("never
* modifies any files"), while its frontmatter granted only Read/Glob/Grep.
* Which instruction wins is nondeterministic, and the failure mode is not
* "the write fails" it is the agent improvising a full-file Write on the
* SHARED implementation log, clobbering parallel implementer entries. That is
* exactly the defect `implement-log-append.test.mjs` exists to prevent,
* entering through the one file that test does not read.
*
* The guard is the blanket invariant over the whole agents/ catalogue, not a
* statement about verifier-agent: any agent whose tools grant no write
* capability must (a) instruct no file write, and (b) say positively that it
* returns its findings inline. Both sides are read from the file the tools
* list AND the body so removing a write tool from any agent whose body still
* instructs a write turns this red.
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFile, readdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
const AGENTS_DIR = resolve(ROOT, 'agents');
/** Tools that can put bytes on disk. Bash counts: `>>` is a write. */
const WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'Bash']);
/**
* Directive lines that tell the agent to put its output in a file.
* Anchored to line start so prose ABOUT writing ("do not write it to a file")
* is not caught the defect is an instruction, not a mention.
*/
const WRITE_DIRECTIVE_RE =
/^(?:\*\*)?(?:Append|Write|Save|Output|Persist)\b(?![^\n]*\bnot\b)[^\n]*?(?:\bto\b|`[^`\n]+\.(?:md|ya?ml|json)`)/mi;
/** The positive half: the file must say the findings come back inline. */
const RETURN_INLINE_RE =
/\breturn\b[^.]{0,120}?\b(?:as\s+your\s+final\s+message|inline)\b/i;
function frontmatterOf(content) {
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
return m ? m[1] : '';
}
function bodyOf(content) {
const m = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
return m ? content.slice(m[0].length) : content;
}
function toolsOf(frontmatter) {
const m = frontmatter.match(/^tools:\s*(.+)$/m);
if (!m) return [];
return (m[1].match(/[A-Za-z_][A-Za-z0-9_]*/g) || []);
}
async function loadAgents() {
const names = (await readdir(AGENTS_DIR)).filter((n) => n.endsWith('.md')).sort();
return Promise.all(
names.map(async (name) => {
const content = await readFile(resolve(AGENTS_DIR, name), 'utf-8');
const frontmatter = frontmatterOf(content);
const tools = toolsOf(frontmatter);
return {
name,
body: bodyOf(content),
tools,
canWrite: tools.some((t) => WRITE_TOOLS.has(t)),
};
}),
);
}
test('R4 sweep is not vacuous: the agents catalogue is read and at least one agent has no write tool', async () => {
const agents = await loadAgents();
assert.ok(agents.length >= 7,
`expected the agents/ catalogue to be swept, got ${agents.length} files`);
assert.ok(agents.every((a) => a.tools.length > 0),
`every agent must declare tools:, missing in ${agents.filter((a) => !a.tools.length).map((a) => a.name).join(', ')}`);
const writeless = agents.filter((a) => !a.canWrite);
assert.ok(writeless.length >= 1,
'no write-tool-less agent found — the invariant below would be vacuously green');
});
test('R4: no agent instructs a file write its tools cannot perform', async () => {
const agents = await loadAgents();
for (const agent of agents.filter((a) => !a.canWrite)) {
const offending = agent.body.match(WRITE_DIRECTIVE_RE);
assert.ok(
offending === null,
`${agent.name} grants no write tool (tools: ${agent.tools.join(', ')}) but instructs a write: ${JSON.stringify(offending && offending[0])}`,
);
}
});
test('R4: a write-tool-less agent states positively that it returns findings inline', async () => {
const agents = await loadAgents();
for (const agent of agents.filter((a) => !a.canWrite)) {
assert.ok(
RETURN_INLINE_RE.test(agent.body),
`${agent.name} has no write tool, so it must say its findings are returned as its final message`,
);
}
});

View file

@ -1,138 +0,0 @@
/**
* R6 the two "Required Frontmatter" contracts are enforced by something.
*
* `.claude/rules/agent-development.md` and `.claude/rules/command-development.md`
* write down which frontmatter keys an agent/command MUST carry, and that agent
* colors must be unique within the plugin. Nothing enforced any of it: the only
* test that looked at agent frontmatter (`agent-prompt-shape`) asserted `name:`
* on a HAND-WRITTEN list of 3 of the 7 agents. Everything was compliant and
* unwatched the state in which a rule becomes fiction one file at a time.
*
* Nothing here is hand-maintained, because a hand-kept list of what to sweep is
* a premise, not a measurement (the #57 shape):
* - the required KEYS are parsed out of each rule's own ```yaml block;
* - the files swept are resolved from each rule's own `paths:` frontmatter;
* - the plugin name is read from `.claude-plugin/plugin.json`.
* Add a key to a rule and it is enforced on the next run, with no test edit.
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFile, readdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
const RULES_DIR = resolve(ROOT, '.claude', 'rules');
function frontmatterOf(content) {
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
return m ? m[1] : '';
}
/** Top-level keys of a frontmatter block, in source order. */
function topLevelKeys(frontmatter) {
return frontmatter
.split('\n')
.map((line) => line.match(/^([A-Za-z][A-Za-z0-9_-]*):/))
.filter(Boolean)
.map((m) => m[1]);
}
function valueOf(frontmatter, key) {
const m = frontmatter.match(new RegExp(`^${key}:[ \\t]*(.*)$`, 'm'));
return m ? m[1].trim() : null;
}
/**
* A rule file is the contract. Read the required keys out of the ```yaml block
* under "## Required Frontmatter", and the swept directory out of `paths:`.
*/
async function loadRule(fileName) {
const content = await readFile(resolve(RULES_DIR, fileName), 'utf-8');
const paths = valueOf(frontmatterOf(content), 'paths');
const fence = content.match(/##\s+Required Frontmatter[\s\S]*?```ya?ml\r?\n([\s\S]*?)```/);
const requiredKeys = fence ? topLevelKeys(fence[1]).filter((k) => k !== '---') : [];
const dir = paths ? paths.split('/')[0] : null;
return { fileName, paths, dir, requiredKeys };
}
async function loadTargets(rule) {
const dirAbs = resolve(ROOT, rule.dir);
const names = (await readdir(dirAbs)).filter((n) => n.endsWith('.md')).sort();
return Promise.all(
names.map(async (name) => {
const frontmatter = frontmatterOf(await readFile(resolve(dirAbs, name), 'utf-8'));
return { name, frontmatter, keys: topLevelKeys(frontmatter) };
}),
);
}
const AGENT_RULE = 'agent-development.md';
const COMMAND_RULE = 'command-development.md';
test('R6 derivation is not vacuous: both rules yield required keys and a non-empty file set', async () => {
for (const fileName of [AGENT_RULE, COMMAND_RULE]) {
const rule = await loadRule(fileName);
assert.ok(rule.requiredKeys.length > 0,
`${fileName}: no required keys parsed from its "Required Frontmatter" yaml block — every assertion below would be vacuously green`);
assert.ok(rule.dir, `${fileName}: no paths: frontmatter to resolve a file set from`);
const targets = await loadTargets(rule);
assert.ok(targets.length > 0,
`${fileName}: paths: ${rule.paths} resolved to 0 files — the sweep would prove nothing`);
}
});
test('R6: every agent and command carries the keys its rule requires, non-empty', async () => {
for (const fileName of [AGENT_RULE, COMMAND_RULE]) {
const rule = await loadRule(fileName);
const targets = await loadTargets(rule);
for (const target of targets) {
for (const key of rule.requiredKeys) {
assert.ok(target.keys.includes(key),
`${rule.dir}/${target.name} is missing required frontmatter key "${key}" (required by .claude/rules/${fileName}; ${targets.length} files swept)`);
const value = valueOf(target.frontmatter, key);
assert.ok(value !== null && value !== '',
`${rule.dir}/${target.name} has an empty "${key}" — a present-but-empty key satisfies no contract`);
}
}
}
});
test('R6: agent colors are unique within the plugin', async () => {
const rule = await loadRule(AGENT_RULE);
const targets = await loadTargets(rule);
const seen = new Map();
for (const target of targets) {
const color = valueOf(target.frontmatter, 'color');
if (seen.has(color)) {
assert.fail(`duplicate agent color "${color}": ${seen.get(color)} and ${target.name} (.claude/rules/${AGENT_RULE}: "Color must be unique within the plugin")`);
}
seen.set(color, target.name);
}
assert.equal(seen.size, targets.length, `expected ${targets.length} distinct colors, got ${seen.size}`);
});
test('R6: agent names are kebab-case with the -agent suffix', async () => {
const rule = await loadRule(AGENT_RULE);
for (const target of await loadTargets(rule)) {
const name = valueOf(target.frontmatter, 'name');
assert.match(name, /^[a-z0-9]+(?:-[a-z0-9]+)*-agent$/,
`agents/${target.name}: name "${name}" must be kebab-case with an -agent suffix`);
}
});
test('R6: command names are plugin:action, or the bare plugin name for the router', async () => {
const plugin = JSON.parse(await readFile(resolve(ROOT, '.claude-plugin', 'plugin.json'), 'utf-8')).name;
const rule = await loadRule(COMMAND_RULE);
const targets = await loadTargets(rule);
const routers = [];
for (const target of targets) {
const name = valueOf(target.frontmatter, 'name');
if (name === plugin) { routers.push(target.name); continue; }
assert.match(name, new RegExp(`^${plugin}:[a-z0-9]+(?:-[a-z0-9]+)*$`),
`commands/${target.name}: name "${name}" must be "${plugin}:action" (or the bare "${plugin}" router)`);
}
assert.equal(routers.length, 1, `expected exactly one bare-"${plugin}" router command, got ${routers.length}: ${routers.join(', ')}`);
});

View file

@ -1,227 +0,0 @@
/**
* R1 + R2 the backup/restore data contract belongs to the code.
*
* Two templates used to own it in prose, and each half made the other's failure
* silent:
*
* R1 `commands/rollback.md` drove the restore itself. Its "Implementation"
* section showed an ESM `import` block a command template cannot execute and
* offered ad-hoc `cp` as the runnable alternative, then pre-rendered
* "`(checksum verified)`" in the success output. `cp` verifies nothing, so the
* claim was a property of the template, not of the run.
*
* R2 `commands/implement.md` Step 3 hand-built the backup: `mkdir`, `cp`, a
* `date +%Y%m%d_%H%M%S` id, and a manifest typed out in the template. The
* parser on the other side (`parseManifest`) knew ONE frozen sample of that
* format, pinned by a HAND-WRITTEN fixture rather than by the template's own
* text the #63 shape on the data side. Rename a key in the template and
* `parseManifest` returns zero files while `rollback` reports success.
*
* The fix is one chunk because half of it does not hold: a CLI over a prose
* format still parses prose, and a clean format with no runnable entry still
* cannot restore. What this file asserts is that neither half came back.
*
* The last test is the one that replaces "(checksum verified)": every field the
* template renders is checked against a payload produced by RUNNING the CLI, so
* the output prose can only claim what the code actually reports.
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawn } from 'node:child_process';
import { readFile, writeFile, mkdtemp, mkdir, rm } from 'node:fs/promises';
import { resolve, dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
const COMMANDS_DIR = join(ROOT, 'commands');
const CLI = join(ROOT, 'scanners', 'rollback-cli.mjs');
const read = (name) => readFile(join(COMMANDS_DIR, name), 'utf-8');
/** Fenced blocks as `[{ lang, body }]`. */
function fences(content) {
return [...content.matchAll(/```(\w*)\n([\s\S]*?)```/g)].map((m) => ({ lang: m[1], body: m[2] }));
}
test('rollback.md drives the engine through the CLI, like every other command', async () => {
const content = await read('rollback.md');
assert.match(
content,
/node \$\{CLAUDE_PLUGIN_ROOT\}\/scanners\/rollback-cli\.mjs/,
'rollback.md must call the rollback CLI at an anchored path. A relative path resolves\n' +
"against the user's working directory, not the plugin ([[plugin-root-is-the-cache]]).",
);
assert.match(
content,
/rollback-cli\.mjs[^\n]*--output-file[^\n]*2>\/dev\/null/,
'The CLI must be invoked as `--output-file <path> 2>/dev/null` (ux-rules rule 2). A payload\n' +
'the command has to act on cannot ride on stdout or stderr.',
);
});
test('rollback.md no longer pre-renders a verification it did not perform', async () => {
const content = await read('rollback.md');
assert.doesNotMatch(
content,
/\(checksum verified\)/,
'The success block hard-codes "(checksum verified)" for every file. The runnable path in\n' +
'this template (`cp`) establishes no checksum at all, so the words came from the template\n' +
'rather than from the run. Render the per-file `status` the CLI returns instead.',
);
});
test('rollback.md offers no hand-rolled restore beside the engine', async () => {
const content = await read('rollback.md');
for (const { lang, body } of fences(content)) {
if (lang !== 'bash') continue;
assert.doesNotMatch(
body,
/^\s*cp\s+/m,
'A `cp` restore skips the checksum verification before and after each write that the\n' +
'engine performs. Two restore paths means the safe one is optional.',
);
}
assert.doesNotMatch(
content,
/import\s*\{[^}]*\}\s*from\s*['"][^'"]*rollback-engine\.mjs['"]/,
'A command template is not an ES module; an `import` block here is instructions the\n' +
'runtime never executes, which is what left `cp` as the only runnable path.',
);
});
test('implement.md Step 3 creates its backup through the code, not by hand', async () => {
const content = await read('implement.md');
// `--create(?![a-z])`, not `--create`: the same line carries `--created`, so a
// prefix match is satisfied by the very flag whose presence proves nothing
// about whether a backup is being MADE. Measured by mutating `--create` away
// and watching this assertion stay green.
assert.match(
content,
/node \$\{CLAUDE_PLUGIN_ROOT\}\/scanners\/rollback-cli\.mjs[^\n]*--create(?![a-z])/,
'Step 3 must call `rollback-cli.mjs --create`. Backing up through the same code path the\n' +
'fix pipeline uses is what deletes the second copy of the backup policy.',
);
assert.match(
content,
/--created\b/,
'Files this run will CREATE have no backup and must be recorded so rollback can say what\n' +
'it is leaving behind. The list moved from hand-written YAML to a flag; it must still exist.',
);
});
test('implement.md hand-builds no manifest and invents no backup id', async () => {
const content = await read('implement.md');
for (const { body } of fences(content)) {
assert.doesNotMatch(
body,
/^\s*sha256:/m,
'Step 3 types out a manifest whose only reader is `parseManifest`. Every key here is a\n' +
'contract with code, maintained by hand on one side — the seam that already failed once\n' +
'(M-BUG-25) and whose fixture was hand-written rather than derived from this template.',
);
assert.doesNotMatch(
body,
/mkdir\s+-p\s+~\/\.claude\/config-audit\/backups/,
'The template builds the backup directory layout itself. The layout is `createBackup`\'s\n' +
'to define; a second definition in prose drifts away from it silently.',
);
assert.doesNotMatch(
body,
/BACKUP_ID=\$\(date/,
'A backup id derived by the template is a second id generator. `createBackup` returns the\n' +
'id it actually used; anything else can name a directory that does not exist.',
);
}
});
test('every field rollback.md renders is a field the CLI really emits', async () => {
// The replacement for "(checksum verified)": the output prose is checked
// against a payload produced by RUNNING the CLI. A renamed payload key turns
// the template's claim into a violation here rather than into a confident
// sentence in front of a user who is already in trouble.
//
// `K` is the only derived reference — a count the model computes from the
// classified targets, not a field any scanner emits.
const DERIVED = new Set(['K']);
const root = await mkdtemp(join(tmpdir(), 'ca-rbrender-'));
try {
const home = join(root, 'home');
const work = join(root, 'work');
const cwd = join(root, 'cwd');
await mkdir(join(home, '.claude'), { recursive: true });
await mkdir(work, { recursive: true });
await mkdir(cwd, { recursive: true });
const backupRoot = join(home, '.claude', 'config-audit', 'backups');
const env = {
...process.env,
HOME: home,
USERPROFILE: home,
CONFIG_AUDIT_BACKUP_ROOT: backupRoot,
CONFIG_AUDIT_LEGACY_BACKUP_ROOT: join(home, '.config-audit', 'backups'),
};
const runCli = (argv) => new Promise((res) => {
const child = spawn(process.execPath, [CLI, ...argv], { cwd, env });
child.stdout.on('data', () => {});
child.stderr.on('data', () => {});
child.on('close', (code) => res(code));
});
const target = join(work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const createOut = join(root, 'create.json');
await runCli([
'--create', '--target', target,
'--created', join(work, 'made-by-implement.md'),
'--repo', work, '--output-file', createOut,
]);
const created = JSON.parse(await readFile(createOut, 'utf-8'));
const listOut = join(root, 'list.json');
await runCli(['--list', '--output-file', listOut]);
const listed = JSON.parse(await readFile(listOut, 'utf-8'));
const restoreOut = join(root, 'restore.json');
await runCli([
'--restore', created.backupId, '--repo', work, '--dry-run', '--output-file', restoreOut,
]);
const restored = JSON.parse(await readFile(restoreOut, 'utf-8'));
const keys = new Set();
const walk = (node) => {
if (Array.isArray(node)) node.slice(0, 5).forEach(walk);
else if (node && typeof node === 'object') {
for (const k of Object.keys(node)) { keys.add(k); walk(node[k]); }
}
};
[created, listed, restored].forEach(walk);
const content = await read('rollback.md');
// Render fences only. A `bash` fence is a command, not a render contract,
// and `${CLAUDE_PLUGIN_ROOT}` would otherwise read as a payload field.
const refs = new Set();
for (const { lang, body } of fences(content)) {
if (lang === 'bash') continue;
for (const m of body.matchAll(/\{([a-zA-Z][a-zA-Z0-9_.]*)\}/g)) refs.add(m[1]);
}
assert.ok(refs.size > 0, 'no render reference found in rollback.md — the sweep certifies nothing');
const missing = [...refs].filter((r) => !DERIVED.has(r) && !keys.has(r.split('.').pop()));
assert.deepEqual(
missing,
[],
'rollback.md renders fields rollback-cli.mjs never emits:\n ' + missing.join('\n '),
);
} finally {
await rm(root, { recursive: true, force: true });
}
});

View file

@ -62,9 +62,6 @@ const GUARDED = [
{ cli: 'self-audit.mjs', argv: [], valueFlag: null }, // no value-taking flag
{ cli: 'write-scope-cli.mjs', argv: ['--target', 'x'], valueFlag: '--output-file' },
{ cli: 'subtraction-write-cli.mjs', argv: ['--approved', 'x'], valueFlag: '--output-file' },
// R1. All three probes below exit at `requireValidArgs`, before any mode runs,
// so none of them reaches the operator's real backup root.
{ cli: 'rollback-cli.mjs', argv: [], valueFlag: '--output-file' },
];
/**

View file

@ -1,395 +0,0 @@
/**
* R1 the restore path gets a runnable entry.
*
* `rollback-engine.mjs` has always verified checksums before AND after each
* write, resolved the legacy backup root, and reported `createdNotRemoved`.
* None of it was reachable: measured at the head of this chunk, 16 files under
* `scanners/` carry a `process.argv` entry and the engine was not one of them.
* `commands/rollback.md` drove the restore as model prose an ESM `import`
* block a command template cannot execute, with ad-hoc `cp` offered as the
* runnable alternative and a pre-rendered "(checksum verified)" line under it.
* `cp` establishes no checksum, so the claim was rendered by the template
* rather than produced by the run.
*
* Two properties of this file are load-bearing:
*
* 1. **The unknown-flag control comes first.** `command-cli-contract.test.mjs`
* only trusts a CLI's silence about a flag once it has SEEN that CLI reject
* a flag which cannot exist. The same control is asserted here, at the
* source, so a future edit that moves argv parsing behind a required-arg
* check fails in the CLI's own test rather than silently making every flag
* pair downstream pass for the wrong reason.
*
* 2. **A refusal is asserted on the BYTES, never on the exit code alone.**
* The gate's whole job is that nothing was written; a test that reads only
* the verdict would pass against an engine that refused loudly and wrote
* anyway.
*/
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawn } from 'node:child_process';
import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, stat } from 'node:fs/promises';
import { join, resolve, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { parseManifest } from '../../scanners/lib/backup.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI = resolve(__dirname, '..', '..', 'scanners', 'rollback-cli.mjs');
/**
* A sandbox that is a HOME as well as a backup root, so `user-scope`
* classification and backup resolution both stay inside the temp dir. The cwd
* is a third, empty directory: probing a writer runs the writer (#67), and a
* CLI that defaults a path to the working directory must be caught doing it.
*/
async function sandbox() {
const root = await mkdtemp(join(tmpdir(), 'ca-rbcli-'));
const home = join(root, 'home');
const work = join(root, 'work');
const cwd = join(root, 'cwd');
await mkdir(join(home, '.claude'), { recursive: true });
await mkdir(work, { recursive: true });
await mkdir(cwd, { recursive: true });
return {
root,
home,
work,
cwd,
backupRoot: join(home, '.claude', 'config-audit', 'backups'),
cleanup: () => rm(root, { recursive: true, force: true }),
};
}
async function run(sb, argv) {
const { code, stdout, stderr } = await new Promise((res) => {
const child = spawn(process.execPath, [CLI, ...argv], {
cwd: sb.cwd,
env: {
...process.env,
HOME: sb.home,
USERPROFILE: sb.home,
CONFIG_AUDIT_BACKUP_ROOT: sb.backupRoot,
CONFIG_AUDIT_LEGACY_BACKUP_ROOT: join(sb.home, '.config-audit', 'backups'),
},
});
let out = '';
let err = '';
child.stdout.on('data', (d) => { out += d; });
child.stderr.on('data', (d) => { err += d; });
child.on('close', (c) => res({ code: c, stdout: out, stderr: err }));
});
return { code, stdout, stderr, cwdEntries: await readdir(sb.cwd) };
}
/** Run and read the `--output-file` payload back. */
async function runJson(sb, argv) {
const out = join(sb.root, `payload-${Math.random().toString(36).slice(2)}.json`);
const r = await run(sb, [...argv, '--output-file', out]);
return { ...r, payload: JSON.parse(await readFile(out, 'utf-8')), outFile: out };
}
/** Make a backup of `files` through the CLI itself and return its id. */
async function seedBackup(sb, files, created = []) {
const argv = ['--create'];
for (const f of files) argv.push('--target', f);
for (const c of created) argv.push('--created', c);
const { code, payload } = await runJson(sb, [...argv, '--repo', sb.work]);
assert.equal(code, 0, 'seeding a backup must succeed');
return payload.backupId;
}
test('rejects a flag that cannot exist (the control every downstream probe rests on)', async () => {
const sb = await sandbox();
try {
const { code, stderr } = await run(sb, ['--zzz-not-a-real-flag']);
assert.equal(code, 3, 'a malformed argv is exit 3, never a verdict');
assert.match(stderr, /unknown flag "--zzz-not-a-real-flag"/i);
} finally {
await sb.cleanup();
}
});
test('a malformed argv stops the run, it does not merely colour the exit code', async () => {
// Found by mutating the gate: `requireValidArgs` sets exit 3 by itself, so a
// caller that drops the `return` still LOOKS rejected while the mode runs to
// completion underneath. A restore that happened is not undone by the exit
// code that says it should not have.
const sb = await sandbox();
try {
const target = join(sb.work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const id = await seedBackup(sb, [target]);
await writeFile(target, '# current\n');
const { code } = await run(sb, ['--restore', id, '--repo', sb.work, '--zzz-not-a-real-flag']);
assert.equal(code, 3);
assert.equal(
await readFile(target, 'utf-8'),
'# current\n',
'the CLI restored a file while reporting that it could not parse its own arguments',
);
} finally {
await sb.cleanup();
}
});
test('two modes in one argv is an argument error, not a silent winner', async () => {
const sb = await sandbox();
try {
const { code, stderr } = await run(sb, ['--list', '--delete', 'x']);
assert.equal(code, 3);
assert.match(stderr, /one mode/i);
} finally {
await sb.cleanup();
}
});
test('list mode on an empty backup root answers zero, and writes nothing', async () => {
const sb = await sandbox();
try {
const { code, payload, cwdEntries } = await runJson(sb, ['--list']);
assert.equal(code, 0);
assert.equal(payload.meta.mode, 'list');
assert.deepEqual(payload.backups, []);
assert.equal(payload.count, 0);
assert.deepEqual(cwdEntries, [], 'the CLI must not default any path to the working directory');
} finally {
await sb.cleanup();
}
});
test('--output-file keeps the payload off stdout (ux-rules rule 1)', async () => {
const sb = await sandbox();
try {
const { stdout } = await runJson(sb, ['--list']);
assert.equal(stdout, '', 'a payload written to a file must not also reach the transcript');
} finally {
await sb.cleanup();
}
});
test('create mode backs up the real bytes and round-trips through parseManifest', async () => {
const sb = await sandbox();
try {
const a = join(sb.work, 'CLAUDE.md');
const b = join(sb.work, '.claude', 'settings.json');
await mkdir(dirname(b), { recursive: true });
await writeFile(a, '# original A\n');
await writeFile(b, '{"a":1}\n');
const willCreate = join(sb.work, '.claude', 'rules', 'new-rule.md');
const { code, payload } = await runJson(
sb,
['--create', '--target', a, '--target', b, '--created', willCreate, '--repo', sb.work],
);
assert.equal(code, 0);
assert.equal(payload.meta.mode, 'create');
assert.match(payload.backupId, /^\d{8}_\d{6}$/);
assert.equal(payload.files.length, 2);
assert.deepEqual(payload.created, [willCreate]);
assert.deepEqual(payload.skipped, []);
// R2: the data contract is owned by the code on BOTH sides. A manifest the
// engine writes must be one the engine's own parser reads back whole —
// including `created:`, which the hand-built template format carried and
// `createBackup` did not.
const manifest = parseManifest(
await readFile(join(payload.backupPath, 'manifest.yaml'), 'utf-8'),
);
assert.equal(manifest.backup_id, payload.backupId);
assert.equal(manifest.files.length, 2);
assert.deepEqual(manifest.created, [willCreate]);
assert.equal(manifest.files[0].originalPath, a);
} finally {
await sb.cleanup();
}
});
test('create mode reports a target it could not back up instead of counting it', async () => {
const sb = await sandbox();
try {
const real = join(sb.work, 'CLAUDE.md');
await writeFile(real, 'x\n');
const ghost = join(sb.work, 'not-there.md');
const { code, payload } = await runJson(
sb,
['--create', '--target', real, '--target', ghost, '--repo', sb.work],
);
assert.equal(code, 1, 'a backup that covers fewer files than asked is a warning, not a pass');
assert.deepEqual(payload.skipped, [ghost]);
assert.equal(payload.files.length, 1);
} finally {
await sb.cleanup();
}
});
test('restore writes the backed-up bytes back and verifies the checksum', async () => {
const sb = await sandbox();
try {
const target = join(sb.work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const id = await seedBackup(sb, [target]);
await writeFile(target, '# clobbered\n');
const { code, payload } = await runJson(sb, ['--restore', id, '--repo', sb.work]);
assert.equal(code, 0);
assert.equal(payload.meta.mode, 'restore');
assert.equal(payload.backupId, id);
assert.deepEqual(payload.failed, []);
assert.equal(payload.restored.length, 1);
assert.equal(payload.restored[0].status, 'restored');
assert.equal(await readFile(target, 'utf-8'), '# original\n');
} finally {
await sb.cleanup();
}
});
test('a corrupted backup fails loudly and leaves the original alone', async () => {
const sb = await sandbox();
try {
const target = join(sb.work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const id = await seedBackup(sb, [target]);
await writeFile(target, '# current\n');
// Corrupt the stored copy: the checksum in the manifest no longer matches.
const files = join(sb.backupRoot, id, 'files');
const [stored] = await readdir(files);
await writeFile(join(files, stored), '# tampered\n');
const { code, payload } = await runJson(sb, ['--restore', id, '--repo', sb.work]);
assert.equal(code, 2, 'a failed restore is a FAIL verdict, not a pass');
assert.deepEqual(payload.restored, []);
assert.equal(payload.failed[0].status, 'checksum-mismatch');
assert.equal(
await readFile(target, 'utf-8'),
'# current\n',
'a checksum mismatch must stop the write, not land tampered bytes on the original',
);
} finally {
await sb.cleanup();
}
});
test('a restore that leaves this project is refused until --approve-scope', async () => {
const sb = await sandbox();
try {
const target = join(sb.home, '.claude', 'CLAUDE.md');
await writeFile(target, '# machine-wide original\n');
const id = await seedBackup(sb, [target]);
await writeFile(target, '# machine-wide current\n');
const refused = await runJson(sb, ['--restore', id, '--repo', sb.work]);
assert.equal(refused.code, 1, 'approval owed is a warning verdict, not a tool error');
assert.equal(refused.payload.requiresApproval, true);
assert.equal(refused.payload.gate, 'require-ok');
assert.ok(refused.payload.disclosures.length > 0, 'the refusal must carry words to render');
assert.deepEqual(refused.payload.restored, []);
assert.equal(refused.payload.refused[0].reason, 'scope-gate');
assert.equal(
await readFile(target, 'utf-8'),
'# machine-wide current\n',
'the gate refused and must therefore have written nothing',
);
const ok = await runJson(sb, ['--restore', id, '--repo', sb.work, '--approve-scope']);
assert.equal(ok.code, 0);
assert.equal(await readFile(target, 'utf-8'), '# machine-wide original\n');
} finally {
await sb.cleanup();
}
});
test('--dry-run reports what would happen and touches nothing', async () => {
const sb = await sandbox();
try {
const target = join(sb.work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const id = await seedBackup(sb, [target]);
await writeFile(target, '# current\n');
const { code, payload } = await runJson(sb, ['--restore', id, '--repo', sb.work, '--dry-run']);
assert.equal(code, 0);
assert.equal(payload.dryRun, true);
assert.equal(payload.restored[0].status, 'dry-run');
assert.equal(await readFile(target, 'utf-8'), '# current\n', 'a dry run is not a write');
} finally {
await sb.cleanup();
}
});
test('restore reports the files it cannot undo', async () => {
const sb = await sandbox();
try {
const target = join(sb.work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const madeByImplement = join(sb.work, '.claude', 'rules', 'post-quality.md');
const id = await seedBackup(sb, [target], [madeByImplement]);
const { payload } = await runJson(sb, ['--restore', id, '--repo', sb.work]);
assert.deepEqual(
payload.createdNotRemoved,
[madeByImplement],
'a half-restored target is only dangerous when it is also silent',
);
} finally {
await sb.cleanup();
}
});
test('an unknown backup id is a tool error, never an empty success', async () => {
const sb = await sandbox();
try {
const { code, stderr } = await run(sb, ['--restore', '19700101_000000', '--repo', sb.work]);
assert.equal(code, 3);
assert.match(stderr, /not found/i);
} finally {
await sb.cleanup();
}
});
test('delete removes the backup directory, and refuses an id it cannot find', async () => {
const sb = await sandbox();
try {
const target = join(sb.work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const id = await seedBackup(sb, [target]);
const { code, payload } = await runJson(sb, ['--delete', id]);
assert.equal(code, 0);
assert.equal(payload.deleted, true);
await assert.rejects(() => stat(join(sb.backupRoot, id)));
const missing = await run(sb, ['--delete', '19700101_000000']);
assert.equal(missing.code, 3);
} finally {
await sb.cleanup();
}
});
test('list mode sees a backup the CLI itself created', async () => {
const sb = await sandbox();
try {
const target = join(sb.work, 'CLAUDE.md');
await writeFile(target, '# original\n');
const id = await seedBackup(sb, [target]);
const { payload } = await runJson(sb, ['--list']);
assert.equal(payload.count, 1);
assert.equal(payload.backups[0].id, id);
assert.equal(payload.backups[0].files[0].originalPath, target);
} finally {
await sb.cleanup();
}
});

View file

@ -154,21 +154,12 @@ describe('listBackups / restoreBackup across both roots', () => {
// ========================================
// Manifest compatibility (M-BUG-25)
//
// The implement flow used to hand-build its manifest (commands/implement.md told
// the agent to mkdir + cp), so backups written by that flow use `- backup:` /
// `original:` / `sha256:` while parseManifest only understood the engine's
// quoted `original_path:` / `backup_path:` / `checksum:`. Result: parseManifest
// The implement flow hand-builds its manifest (commands/implement.md tells the
// agent to mkdir + cp), so real backups on disk use `- backup:` / `original:` /
// `sha256:` while parseManifest only understood the engine's quoted
// `original_path:` / `backup_path:` / `checksum:`. Result: parseManifest
// returned files: [] and restoreBackup reported success having restored
// nothing — a success-shaped no-op, the worst failure mode in the file.
//
// R2 removed the prose format at the SOURCE: Step 3 now calls
// `rollback-cli.mjs --create`, so nothing writes this shape any more. The
// fixture below therefore changed meaning rather than becoming obsolete — it
// pins a format that still exists ON DISK in every backup implement made before
// this chunk, and those must stay restorable. That is also why it is legitimately
// hand-written now: it is a golden sample of historical bytes, not a stand-in for
// a template's own text (the #63 objection that applied while the template was
// still authoring it).
// ========================================
const ENGINE_MANIFEST = `created_at: "2026-07-17T03:26:36.000Z"