Compare commits

...

2 commits

Author SHA1 Message Date
d5714261d1 fix(agents): an agent cannot promise what its tools forbid
R4 — `verifier-agent.md` carried two contracts at once: §Output Format said
"Append to: implementation-log.md", §Read-Only Guarantee said "never modifies
any files", and `tools:` granted only Read/Glob/Grep. Which one wins is
nondeterministic, and the loss is not a blocked write but a full-file Write on
the log the agent SHARES with the parallel implementer agents — the defect
`implement-log-append.test.mjs` exists to prevent, entering through the one file
that test does not read. The orchestrator half was already right
(`implement.md` Step 5 appends with Bash `>>` and tells the agent not to write),
so the fix is one-way: the agent file now returns its report inline and names
who appends it, and why a Write there would clobber.

The guard is the blanket invariant over the catalogue, not a fact about one
file: any agent whose tools grant no write capability must instruct no write AND
say positively that it returns findings inline. Tools and body are both read, so
stripping `Write` from any agent whose body still writes turns it red. Measured
1 of 7 agents carried the defect; the sweep asserts a write-tool-less agent
exists so the invariant cannot pass vacuously.

R6 — both "Required Frontmatter" rules were enforced by nothing, and the only
test reading agent frontmatter checked `name:` against a hand-written 3-of-7
list. The new guard takes nothing by hand: required keys are parsed from each
rule's own yaml block, the swept files from each rule's own `paths:`, the plugin
name from plugin.json — add a key to a rule and it is enforced next run. The
repo was already 7/7 and 21/21 compliant, so a green first run proves nothing:
all seven arms were 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. Pinning
an unsettled set would encode an unverified premise rather than measure one.

Suite 1777 -> 1785, 0 fail. Frozen tests/snapshots/v5.0.0/ untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEMKCAyVzYTMzLaqPcULVr
2026-08-20 22:59:51 +02:00
44b222859e feat(scanners): the recovery path is code you can run, not prose you can read
R1+R2 as one chunk — both KRITISK rows of the Q3 severity table sit on the
restore path, and neither closes alone.

R1: rollback-engine.mjs verified every checksum before AND after each write,
resolved the legacy backup root and reported createdNotRemoved — and none of it
was reachable. Measured: 16 files under scanners/ carry a process.argv entry;
the engine was not one of them. commands/rollback.md drove the restore as model
prose: an ESM import block a template cannot execute, ad-hoc `cp` offered
underneath as the runnable path, and "(checksum verified)" pre-rendered three
times in the success output. `cp` establishes no checksum, so the verification
was a property of the template rather than of the run — on the one surface that
runs when the user is already in trouble.

R2: implement.md Step 3 hand-built its backup (mkdir, cp, a date-derived id, a
manifest typed out in the template) while parseManifest knew one frozen sample
of that format, pinned by a HAND-WRITTEN fixture instead of by the template's
own text. Rename a key and parseManifest returns zero files while rollback
reports success.

Fixing only R1 leaves the new CLI parsing a prose format; fixing only R2 leaves
a clean format with no runnable entry.

- scanners/rollback-cli.mjs — --list / --create / --restore / --delete over the
  existing engine, on the shared requireValidArgs gate. Exit 0 done, 1
  outstanding (gate refusal with nothing written, or a backup that covered fewer
  targets than given), 2 a file failed, 3 could not do the 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 under 2>/dev/null
  can act on it.
- createBackup gains `created` (recorded, never copied — no backup can hold a
  file that does not exist) and `skipped`, so a backup covering fewer files than
  asked is no longer indistinguishable from a clean one.
- implement.md Step 3 and rollback.md now call the CLI. parseManifest's
  implement-format branch stays: nothing writes that shape now, but every backup
  made before this chunk is on disk in it.
- backup-restore-contract.test.mjs checks every field rollback.md renders
  against a payload produced by RUNNING the CLI. That is what replaced
  "(checksum verified)".

20 guards seen red against the original state before any production code, then
each against its own defect. Two holes that surfaced there were mine: the
implement assertion matched `--create` as a substring of `--created` and stayed
green when the call was removed; and mutating the argv gate showed
requireValidArgs sets exit 3 by itself, so a CLI can report that it could not
parse its arguments and still run the restore underneath — that case is now
asserted on the bytes.

Suite 1752 -> 1777, 0 fail. Frozen tests/snapshots/v5.0.0 untouched. Dogfooded
through the templates' own command lines against a sandboxed HOME, including the
machine-wide arm: refused with the file unchanged, then restored under
--approve-scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Logq8GGWKhtyDem63FTEnG
2026-08-18 21:28:15 +02:00
16 changed files with 1372 additions and 89 deletions

View file

@ -83,6 +83,37 @@ 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):
@ -197,6 +228,35 @@ 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,7 +304,10 @@ 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`. When a run is withheld, nothing has been written: you get the
`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
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`)
@ -447,6 +450,7 @@ 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]` |
@ -606,6 +610,7 @@ 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,7 +140,15 @@ Checking for secrets...
## Output Format
Append to: `~/.claude/config-audit/sessions/{session-id}/implementation-log.md`
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.
```markdown
## Verification Report
@ -243,8 +251,8 @@ Optional: Generate before/after comparison:
This agent:
- Only uses Read, Glob, Grep tools
- Never modifies any files
- Reports findings without taking action
- Never modifies any files, including the shared implementation log
- Reports findings without taking action — every result is returned inline
- Safe to run multiple times
## Model policy

View file

@ -87,37 +87,32 @@ AskUserQuestion:
### Step 3: Create backup
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:
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.
```bash
BACKUP_ID=$(date +%Y%m%d_%H%M%S)
mkdir -p ~/.claude/config-audit/backups/"$BACKUP_ID"/files/ 2>/dev/null
echo "$BACKUP_ID"
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 $?
```
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.
| 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 |
Copy each file to be modified. Generate `manifest.yaml` with checksums.
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.
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.
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.
Tell the user: **"Backup created. Implementing actions..."**
@ -222,8 +217,10 @@ 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. Read `manifest.yaml` from backup
2. Restore each file and verify checksums
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
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,6 +10,12 @@ 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`)
@ -19,14 +25,18 @@ Restore configuration files from a previous backup. Without arguments, lists ava
### 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
ls -1 ~/.claude/config-audit/backups/
node ${CLAUDE_PLUGIN_ROOT}/scanners/rollback-cli.mjs --list --output-file /tmp/config-audit-rollback-list.json 2>/dev/null; echo $?
```
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
@ -40,16 +50,16 @@ ls -1 ~/.claude/config-audit/backups/
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
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.
If `{count}` is 0, say there are no backups yet and stop — do not offer a restore.
### Restore mode (with backup ID)
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:
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:
```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 $?
@ -76,21 +86,45 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
- "Cancel"
- "Yes — restore, including outside this project"
```
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:
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 $?
```
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)
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:
```
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:
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:
```
Left in place — created by implement, no backup exists:
- .claude/rules/post-quality.md
@ -102,29 +136,24 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
### Delete mode
If user says "delete" after listing, confirm and remove the backup directory.
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.
## Implementation
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';
```
`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.
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}
```
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.

View file

@ -60,6 +60,7 @@ 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
@ -78,6 +79,7 @@ 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,3 +805,71 @@ 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`);
}
// 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.
// 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.
process.stderr.write(`\n Rollback: /config-audit rollback ${backupId}\n`);
}
}

View file

@ -70,10 +70,19 @@ 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)
* @returns {{ backupId: string, backupPath: string, manifest: object }}
* @param {string[]} [opts.created] - Paths this run will create (recorded, not copied)
* @returns {{ backupId: string, backupPath: string, manifest: object, skipped: string[] }}
*/
export function createBackup(files, opts = {}) {
const backupId = opts.backupId || generateBackupId();
@ -83,9 +92,13 @@ export function createBackup(files, opts = {}) {
mkdirSync(filesDir, { recursive: true });
const manifestFiles = [];
const skipped = [];
for (const file of files) {
if (!existsSync(file)) continue;
// 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; }
const safeName = safeFileName(file);
copyFileSync(file, join(filesDir, safeName));
@ -106,6 +119,7 @@ export function createBackup(files, opts = {}) {
created_at: new Date().toISOString(),
backup_id: backupId,
files: manifestFiles,
created: [...(opts.created || [])],
};
// Write manifest as YAML-like format
@ -115,7 +129,7 @@ export function createBackup(files, opts = {}) {
// Cleanup old backups
cleanupOldBackups();
return { backupId, backupPath, manifest };
return { backupId, backupPath, manifest, skipped };
}
/**
@ -133,6 +147,14 @@ 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;
}
@ -168,10 +190,13 @@ export function parseManifest(content) {
}
}
// 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.
// 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.
if (result.files.length === 0) {
const implBlocks = content.split(/\n\s+-\s+backup:/).slice(1);
for (const block of implBlocks) {

208
scanners/rollback-cli.mjs Normal file
View file

@ -0,0 +1,208 @@
#!/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

@ -0,0 +1,109 @@
/**
* 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

@ -0,0 +1,138 @@
/**
* 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

@ -0,0 +1,227 @@
/**
* 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,6 +62,9 @@ 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

@ -0,0 +1,395 @@
/**
* 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,12 +154,21 @@ describe('listBackups / restoreBackup across both roots', () => {
// ========================================
// Manifest compatibility (M-BUG-25)
//
// 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
// 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
// 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"