fix(rollback): restore the backup path contract the engine and the commands disagreed on

Pipeline step 4 dogfood. `/config-audit rollback` could not see a single one of
the four real backups on this machine, and reported "Backup not found" for one
that was sitting right there.

Four defects, one root: nothing agreed on where a backup lives or what its
manifest looks like.

- M-BUG-22 `lib/backup.mjs` resolved `~/.config-audit/backups` (pre-v2.2.0)
  while every command, agent and doc uses `~/.claude/config-audit/backups`.
  The auto-backup hook and fix-cli wrote to the first, implement to the second,
  rollback read only the first. Canonical root now, with the legacy root kept
  readable so older backups stay listable and restorable (`legacy: true`).
- M-BUG-25 `parseManifest` understood only the engine's quoted `original_path:`
  spelling, but implement hand-builds its manifest with `- backup:`/`original:`/
  `sha256:`. Every implement-made backup parsed to zero files and restoreBackup
  returned `{restored: [], failed: []}` — a success-shaped no-op. Both formats
  parse now, and a manifest with unparseable entries throws instead of
  pretending to succeed.
- M-BUG-23 both session hooks watched `~/.config-audit/sessions`, which does not
  exist; sessions live under `~/.claude/`. "Check for active sessions" had never
  fired once. It fires now.
- M-BUG-24 the suite called createBackup() against the developer's real home —
  it had left nine stray backups there, and cleanupOldBackups() deletes past ten.
  Root is overridable via CONFIG_AUDIT_BACKUP_ROOT; both test files use it.

Rollback still cannot delete files implement CREATED — no backup can hold a file
that never existed. It no longer does so silently: manifests carry a `created:`
list, restoreBackup returns `createdNotRemoved`, and rollback.md requires the
report. Automatic deletion is a destructive action and needs its own design.

Verified against backup 20260717_032636 on a throwaway copy: all three files
restore byte-exact (sha256 match), zero writes outside the copy, backup dir
unmodified. Suite 1382 -> 1398/0; frozen v5.0.0 snapshots untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SejM9RQAa1Hfuq7Ek2WfFr
This commit is contained in:
Kjell Tore Guttormsen 2026-07-31 17:23:30 +02:00
commit 8f149891c9
9 changed files with 455 additions and 61 deletions

View file

@ -59,6 +59,21 @@ mkdir -p ~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files/ 2>/dev/nul
Copy each file to be modified. Generate `manifest.yaml` with checksums.
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..."**
### Step 4: Execute actions

View file

@ -64,6 +64,18 @@ Use the Read tool on each backup's `manifest.yaml` (the list of changes captured
- hooks/hooks.json (checksum verified)
- .claude/rules/typescript.md (checksum verified)
```
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
- guidelines/posting-rhythm.md
Remove them manually if you want the pre-implement state exactly.
```
Never finish a restore without this section when the list is non-empty; a
silently half-restored target reads as a clean rollback.
### Delete mode
@ -74,9 +86,14 @@ If user says "delete" after listing, confirm and remove the backup directory.
Use the backup and rollback libraries directly:
```javascript
import { listBackups, restoreBackup, deleteBackup } from '../scanners/rollback-engine.mjs';
import { parseManifest } from '../scanners/lib/backup.mjs';
import { parseManifest, getBackupDir } from '../scanners/lib/backup.mjs';
```
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

View file

@ -6,7 +6,11 @@ import { readdirSync, readFileSync, existsSync } from 'fs';
import { join, basename } from 'path';
import { homedir } from 'os';
const sessionsDir = join(homedir(), '.config-audit', 'sessions');
// Canonical location since v2.2.0. The pre-v2.2.0 path is kept as a fallback so
// sessions created before the move are still detected (see commands/cleanup.md).
const canonicalSessionsDir = join(homedir(), '.claude', 'config-audit', 'sessions');
const legacySessionsDir = join(homedir(), '.config-audit', 'sessions');
const sessionsDir = existsSync(canonicalSessionsDir) ? canonicalSessionsDir : legacySessionsDir;
if (!existsSync(sessionsDir)) {
process.exit(0);

View file

@ -6,7 +6,11 @@ import { readdirSync, readFileSync, statSync, existsSync } from 'fs';
import { join, basename, dirname } from 'path';
import { homedir } from 'os';
const sessionsDir = join(homedir(), '.config-audit', 'sessions');
// Canonical location since v2.2.0. The pre-v2.2.0 path is kept as a fallback so
// sessions created before the move are still detected (see commands/cleanup.md).
const canonicalSessionsDir = join(homedir(), '.claude', 'config-audit', 'sessions');
const legacySessionsDir = join(homedir(), '.config-audit', 'sessions');
const sessionsDir = existsSync(canonicalSessionsDir) ? canonicalSessionsDir : legacySessionsDir;
if (!existsSync(sessionsDir)) {
console.log('{}');

View file

@ -10,15 +10,29 @@ import { join, basename } from 'node:path';
import { createHash } from 'node:crypto';
import { homedir } from 'node:os';
const BACKUP_ROOT = join(homedir(), '.config-audit', 'backups');
const MAX_BACKUPS = 10;
/**
* Get the backup root directory path.
*
* Canonical location is `~/.claude/config-audit/backups` the path every
* command, agent and doc uses. `CONFIG_AUDIT_BACKUP_ROOT` overrides it so tests
* never write into the operator's real home.
* @returns {string}
*/
export function getBackupDir() {
return BACKUP_ROOT;
return process.env.CONFIG_AUDIT_BACKUP_ROOT
|| join(homedir(), '.claude', 'config-audit', 'backups');
}
/**
* Get the pre-v2.2.0 backup root. Read-only: nothing writes here any more, but
* backups made before the move must stay listable and restorable.
* @returns {string}
*/
export function getLegacyBackupDir() {
return process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT
|| join(homedir(), '.config-audit', 'backups');
}
/**
@ -63,7 +77,7 @@ export function checksum(content) {
*/
export function createBackup(files, opts = {}) {
const backupId = opts.backupId || generateBackupId();
const backupPath = join(BACKUP_ROOT, backupId);
const backupPath = join(getBackupDir(), backupId);
const filesDir = join(backupPath, 'files');
mkdirSync(filesDir, { recursive: true });
@ -128,7 +142,7 @@ function serializeManifest(manifest) {
* @returns {object}
*/
export function parseManifest(content) {
const result = { created_at: '', backup_id: '', files: [] };
const result = { created_at: '', backup_id: '', files: [], created: [] };
const createdMatch = content.match(/created_at:\s*"([^"]+)"/);
if (createdMatch) result.created_at = createdMatch[1];
@ -136,7 +150,7 @@ export function parseManifest(content) {
const idMatch = content.match(/backup_id:\s*"([^"]+)"/);
if (idMatch) result.backup_id = idMatch[1];
// Parse file entries
// Parse file entries — engine format (quoted `original_path:` …).
const fileBlocks = content.split(/\n\s+-\s+original_path:/).slice(1);
for (const block of fileBlocks) {
const origMatch = block.match(/^\s*"([^"]+)"/);
@ -154,6 +168,45 @@ 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.
if (result.files.length === 0) {
const implBlocks = content.split(/\n\s+-\s+backup:/).slice(1);
for (const block of implBlocks) {
const bpMatch = block.match(/^\s*(\S+)/);
const origMatch = block.match(/original:\s*(\S+)/);
const csMatch = block.match(/sha256:\s*(\S+)/);
if (origMatch && bpMatch && csMatch) {
result.files.push({
originalPath: origMatch[1],
backupPath: bpMatch[1],
checksum: csMatch[1],
sizeBytes: 0,
});
}
}
if (!result.backup_id) {
const implId = content.match(/^created:\s*(\S+)\s*$/m);
if (implId) result.backup_id = implId[1];
}
}
// Files the implement step CREATED. A backup cannot hold a file that did not
// exist, so rollback can never restore these — but it must be able to say so.
const lines = content.split('\n');
const createdAt = lines.findIndex(l => /^created:[ \t]*$/.test(l));
if (createdAt !== -1) {
for (const line of lines.slice(createdAt + 1)) {
const item = line.match(/^[ \t]+-[ \t]+(\S+)[ \t]*$/);
if (!item) break;
result.created.push(item[1]);
}
}
return result;
}
@ -161,9 +214,10 @@ export function parseManifest(content) {
* Remove old backups beyond MAX_BACKUPS.
*/
function cleanupOldBackups() {
if (!existsSync(BACKUP_ROOT)) return;
const backupRoot = getBackupDir();
if (!existsSync(backupRoot)) return;
const dirs = readdirSync(BACKUP_ROOT, { withFileTypes: true })
const dirs = readdirSync(backupRoot, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name)
.sort();
@ -171,7 +225,7 @@ function cleanupOldBackups() {
if (dirs.length > MAX_BACKUPS) {
const toDelete = dirs.slice(0, dirs.length - MAX_BACKUPS);
for (const dir of toDelete) {
rmSync(join(BACKUP_ROOT, dir), { recursive: true, force: true });
rmSync(join(backupRoot, dir), { recursive: true, force: true });
}
}
}

View file

@ -6,47 +6,72 @@
import { readFile, writeFile, readdir, stat, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { getBackupDir, parseManifest, checksum } from './lib/backup.mjs';
import { getBackupDir, getLegacyBackupDir, parseManifest, checksum } from './lib/backup.mjs';
/**
* Resolve a backup id to its directory, canonical root first, then the
* pre-v2.2.0 root. Returns null when the id exists in neither.
* @param {string} backupId
* @returns {Promise<{ path: string, legacy: boolean } | null>}
*/
async function resolveBackupPath(backupId) {
for (const [root, legacy] of [[getBackupDir(), false], [getLegacyBackupDir(), true]]) {
const candidate = join(root, backupId);
try {
await stat(join(candidate, 'manifest.yaml'));
return { path: candidate, legacy };
} catch {
// try the next root
}
}
return null;
}
/**
* List all available backups.
* @returns {Promise<{ backups: object[] }>}
*/
export async function listBackups() {
const backupRoot = getBackupDir();
const backups = [];
const seen = new Set();
let entries;
try {
entries = await readdir(backupRoot, { withFileTypes: true });
} catch {
return { backups: [] };
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const backupPath = join(backupRoot, entry.name);
const manifestPath = join(backupPath, 'manifest.yaml');
// Canonical root first; a legacy backup with the same id must not shadow it.
for (const [backupRoot, legacy] of [[getBackupDir(), false], [getLegacyBackupDir(), true]]) {
let entries;
try {
const manifestContent = await readFile(manifestPath, 'utf-8');
const manifest = parseManifest(manifestContent);
backups.push({
id: entry.name,
createdAt: manifest.created_at,
files: manifest.files.map(f => ({
originalPath: f.originalPath,
backupPath: f.backupPath,
checksum: f.checksum,
sizeBytes: f.sizeBytes,
})),
});
entries = await readdir(backupRoot, { withFileTypes: true });
} catch {
// Skip backups without valid manifest
continue;
}
for (const entry of entries) {
if (!entry.isDirectory() || seen.has(entry.name)) continue;
const backupPath = join(backupRoot, entry.name);
const manifestPath = join(backupPath, 'manifest.yaml');
try {
const manifestContent = await readFile(manifestPath, 'utf-8');
const manifest = parseManifest(manifestContent);
seen.add(entry.name);
backups.push({
id: entry.name,
createdAt: manifest.created_at,
legacy,
files: manifest.files.map(f => ({
originalPath: f.originalPath,
backupPath: f.backupPath,
checksum: f.checksum,
sizeBytes: f.sizeBytes,
})),
created: manifest.created,
});
} catch {
// Skip backups without valid manifest
continue;
}
}
}
// Sort newest first
@ -65,22 +90,22 @@ export async function listBackups() {
*/
export async function restoreBackup(backupId, opts = {}) {
const verify = opts.verify !== false;
const backupRoot = getBackupDir();
const backupPath = join(backupRoot, backupId);
const manifestPath = join(backupPath, 'manifest.yaml');
const resolved = await resolveBackupPath(backupId);
if (!resolved) throw new Error(`Backup not found: ${backupId}`);
// Read manifest
let manifestContent;
try {
manifestContent = await readFile(manifestPath, 'utf-8');
} catch {
throw new Error(`Backup not found: ${backupId}`);
}
const backupPath = resolved.path;
const manifestContent = await readFile(join(backupPath, 'manifest.yaml'), 'utf-8');
const manifest = parseManifest(manifestContent);
const restored = [];
const failed = [];
// A manifest with entries that parsed to nothing would restore nothing while
// reporting success. Fail loudly instead.
if (manifest.files.length === 0 && /^\s+-\s/m.test(manifestContent)) {
throw new Error(`Unreadable manifest for backup ${backupId}: entries present but none parsed`);
}
for (const fileEntry of manifest.files) {
const backupFilePath = join(backupPath, fileEntry.backupPath);
@ -139,7 +164,10 @@ export async function restoreBackup(backupId, opts = {}) {
}
}
return { restored, failed };
// Files implement CREATED are absent from the backup by definition, so they
// survive the restore. Report them — a half-restored target is only dangerous
// when it is also silent.
return { restored, failed, createdNotRemoved: manifest.created, legacy: resolved.legacy };
}
/**
@ -148,17 +176,11 @@ export async function restoreBackup(backupId, opts = {}) {
* @returns {Promise<{ deleted: boolean, error?: string }>}
*/
export async function deleteBackup(backupId) {
const backupRoot = getBackupDir();
const backupPath = join(backupRoot, backupId);
const resolved = await resolveBackupPath(backupId);
if (!resolved) return { deleted: false, error: `Backup not found: ${backupId}` };
try {
await stat(backupPath);
} catch {
return { deleted: false, error: `Backup not found: ${backupId}` };
}
try {
await rm(backupPath, { recursive: true, force: true });
await rm(resolved.path, { recursive: true, force: true });
return { deleted: true };
} catch (err) {
return { deleted: false, error: err.message };

View file

@ -79,8 +79,12 @@ describe('fix-cli --apply', () => {
// Verify backup exists. The CLI runs with a hermetic HOME (see execFileSync env
// below), so its backups land under HERMETIC_HOME, not the developer's real home.
const backupDir = join(HERMETIC_HOME, '.config-audit', 'backups', output.backupId);
const backupDir = join(HERMETIC_HOME, '.claude', 'config-audit', 'backups', output.backupId);
assert.ok(existsSync(backupDir), 'Backup directory should exist');
// …and nothing may land in the pre-v2.2.0 root, which is read-only now.
const legacyDir = join(HERMETIC_HOME, '.config-audit', 'backups', output.backupId);
assert.ok(!existsSync(legacyDir), 'Nothing may be written to the legacy backup root');
});
it('actually modifies files after --apply', async () => {

View file

@ -7,6 +7,13 @@ import { tmpdir, homedir } from 'node:os';
import { createBackup, getBackupDir, checksum } from '../../scanners/lib/backup.mjs';
import { listBackups, restoreBackup, deleteBackup } from '../../scanners/rollback-engine.mjs';
// Keep every backup this file creates inside a temp root. Without this the
// suite writes into the operator's real ~/.claude/config-audit/backups, where
// cleanupOldBackups() would start deleting genuine backups past MAX_BACKUPS.
const TEST_BACKUP_ROOT = join(tmpdir(), `config-audit-rb-root-${process.pid}`);
process.env.CONFIG_AUDIT_BACKUP_ROOT = TEST_BACKUP_ROOT;
process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = join(TEST_BACKUP_ROOT, 'legacy');
/** Create a temp file and back it up, returning paths and content. */
async function setupTestBackup() {
const tmpDir = join(tmpdir(), `config-audit-rb-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);

View file

@ -0,0 +1,267 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { readFileSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { tmpdir, homedir } from 'node:os';
import { resolve } from 'node:path';
import {
createBackup,
getBackupDir,
getLegacyBackupDir,
parseManifest,
} from '../../scanners/lib/backup.mjs';
import { listBackups, restoreBackup } from '../../scanners/rollback-engine.mjs';
// ========================================
// Backup root — canonical vs legacy (M-BUG-22)
//
// Dogfood step 4 found the rollback engine reading ~/.config-audit/backups
// while every command, agent and doc writes to ~/.claude/config-audit/backups.
// listBackups() saw 0 of the 4 real backups on the operator's machine and
// restoreBackup() threw "Backup not found" for a backup that was right there.
// ========================================
const CANONICAL = join(homedir(), '.claude', 'config-audit', 'backups');
const LEGACY = join(homedir(), '.config-audit', 'backups');
/** Unique temp dir per test, cleaned up by the caller. */
function tempRoot(tag) {
const d = join(tmpdir(), `ca-rbpaths-${tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
mkdirSync(d, { recursive: true });
return d;
}
describe('backup root resolution', () => {
const saved = {};
beforeEach(() => {
saved.root = process.env.CONFIG_AUDIT_BACKUP_ROOT;
saved.legacy = process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
delete process.env.CONFIG_AUDIT_BACKUP_ROOT;
delete process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
});
afterEach(() => {
if (saved.root === undefined) delete process.env.CONFIG_AUDIT_BACKUP_ROOT;
else process.env.CONFIG_AUDIT_BACKUP_ROOT = saved.root;
if (saved.legacy === undefined) delete process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
else process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = saved.legacy;
});
it('getBackupDir() defaults to the canonical ~/.claude/config-audit/backups', () => {
assert.strictEqual(getBackupDir(), CANONICAL);
});
it('getLegacyBackupDir() exposes the pre-v2.2.0 path', () => {
assert.strictEqual(getLegacyBackupDir(), LEGACY);
});
it('the two roots are distinct (guards against a copy-paste fix)', () => {
assert.notStrictEqual(getBackupDir(), getLegacyBackupDir());
});
it('getBackupDir() honours CONFIG_AUDIT_BACKUP_ROOT so tests stay hermetic', () => {
const root = tempRoot('env');
try {
process.env.CONFIG_AUDIT_BACKUP_ROOT = root;
assert.strictEqual(getBackupDir(), root);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('createBackup() writes under the overridden root, not the real home (M-BUG-24)', () => {
const root = tempRoot('write');
const work = tempRoot('work');
try {
process.env.CONFIG_AUDIT_BACKUP_ROOT = root;
const target = join(work, 'settings.json');
writeFileSync(target, '{"original": true}');
const { backupPath } = createBackup([target]);
assert.ok(backupPath.startsWith(root), `backup landed outside the override: ${backupPath}`);
assert.ok(!backupPath.startsWith(homedir() + '/.config-audit'), 'must not write to the real home');
} finally {
rmSync(root, { recursive: true, force: true });
rmSync(work, { recursive: true, force: true });
}
});
});
describe('listBackups / restoreBackup across both roots', () => {
const saved = {};
let canonical, legacy, work;
beforeEach(() => {
saved.root = process.env.CONFIG_AUDIT_BACKUP_ROOT;
saved.legacy = process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
canonical = tempRoot('canon');
legacy = tempRoot('leg');
work = tempRoot('work');
process.env.CONFIG_AUDIT_BACKUP_ROOT = canonical;
process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = legacy;
});
afterEach(() => {
if (saved.root === undefined) delete process.env.CONFIG_AUDIT_BACKUP_ROOT;
else process.env.CONFIG_AUDIT_BACKUP_ROOT = saved.root;
if (saved.legacy === undefined) delete process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT;
else process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT = saved.legacy;
for (const d of [canonical, legacy, work]) rmSync(d, { recursive: true, force: true });
});
it('lists a backup that lives in the canonical root', async () => {
const target = join(work, 'settings.json');
writeFileSync(target, '{"original": true}');
const { backupId } = createBackup([target]);
const { backups } = await listBackups();
assert.ok(backups.some(b => b.id === backupId), 'canonical backup must be listed');
});
it('still lists backups left behind in the legacy root', async () => {
// Seed a legacy-root backup by pointing the writer at it temporarily.
const target = join(work, 'legacy.json');
writeFileSync(target, '{"legacy": true}');
process.env.CONFIG_AUDIT_BACKUP_ROOT = legacy;
const { backupId } = createBackup([target]);
process.env.CONFIG_AUDIT_BACKUP_ROOT = canonical;
const { backups } = await listBackups();
const found = backups.find(b => b.id === backupId);
assert.ok(found, 'a pre-v2.2.0 backup must not become invisible after the path fix');
assert.strictEqual(found.legacy, true, 'legacy backups should be flagged as such');
});
it('restores a backup that only exists in the legacy root', async () => {
const target = join(work, 'legacy-restore.json');
writeFileSync(target, '{"original": true}');
process.env.CONFIG_AUDIT_BACKUP_ROOT = legacy;
const { backupId } = createBackup([target]);
process.env.CONFIG_AUDIT_BACKUP_ROOT = canonical;
writeFileSync(target, '{"modified": true}');
const result = await restoreBackup(backupId);
assert.strictEqual(result.failed.length, 0, 'no failures expected');
assert.strictEqual(result.restored.length, 1, 'the legacy backup should resolve');
assert.strictEqual(await readFile(target, 'utf-8'), '{"original": true}');
});
});
// ========================================
// 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
// returned files: [] and restoreBackup reported success having restored
// nothing — a success-shaped no-op, the worst failure mode in the file.
// ========================================
const ENGINE_MANIFEST = `created_at: "2026-07-17T03:26:36.000Z"
backup_id: "20260717_032636"
files:
- original_path: "/tmp/x/CLAUDE.md"
backup_path: "./files/_tmp_x_CLAUDE.md"
checksum: "6d75b5a5013f5f66de7c9f9aed0f187971a4df568c9f83f38a9f37cd7b332a12"
size_bytes: 42
`;
const IMPLEMENT_MANIFEST = `session: 20260717_step3impl
created: 20260717_032636
target_root: /tmp/x
files:
- backup: files/project-claude/settings.local.json
original: /tmp/x/.claude/settings.local.json
sha256: f03f45699568218df0dce447d82954eeece34264f47783a1a2ff6b10cdc43ef9
- backup: files/nested-claude/settings.local.json
original: /tmp/x/posts/2026-01-23-ralph-wiggum/.claude/settings.local.json
sha256: 39a3723441892ce1cf987508fb448b6a656b65c8d194d1640c1915cd9098d3c6
- backup: files/root/CLAUDE.md
original: /tmp/x/CLAUDE.md
sha256: 6d75b5a5013f5f66de7c9f9aed0f187971a4df568c9f83f38a9f37cd7b332a12
`;
describe('parseManifest format compatibility', () => {
it('still parses the engine format unchanged', () => {
const m = parseManifest(ENGINE_MANIFEST);
assert.strictEqual(m.backup_id, '20260717_032636');
assert.strictEqual(m.files.length, 1);
assert.strictEqual(m.files[0].originalPath, '/tmp/x/CLAUDE.md');
assert.strictEqual(m.files[0].backupPath, './files/_tmp_x_CLAUDE.md');
assert.strictEqual(m.files[0].sizeBytes, 42);
});
it('parses the implement-flow format written by the agent', () => {
const m = parseManifest(IMPLEMENT_MANIFEST);
assert.strictEqual(m.files.length, 3, 'all three entries must be recognised');
assert.strictEqual(m.files[0].originalPath, '/tmp/x/.claude/settings.local.json');
assert.strictEqual(m.files[0].backupPath, 'files/project-claude/settings.local.json');
assert.strictEqual(
m.files[2].checksum,
'6d75b5a5013f5f66de7c9f9aed0f187971a4df568c9f83f38a9f37cd7b332a12',
);
});
it('picks up the implement-flow backup id from `created:`', () => {
const m = parseManifest(IMPLEMENT_MANIFEST);
assert.strictEqual(m.backup_id, '20260717_032636');
});
it('never returns a silent empty file list for a manifest that has entries', () => {
for (const [label, content] of [['engine', ENGINE_MANIFEST], ['implement', IMPLEMENT_MANIFEST]]) {
assert.ok(parseManifest(content).files.length > 0, `${label} manifest parsed to zero files`);
}
});
});
// ========================================
// Files created by implement are not restorable (B1 / M-BUG-26)
//
// A backup records only files that already existed. Rollback therefore cannot
// remove what implement CREATED. That is acceptable; doing it silently is not.
// ========================================
describe('created-file reporting', () => {
it('surfaces manifest `created:` entries so rollback can tell the user what it cannot undo', () => {
const manifest = IMPLEMENT_MANIFEST + `created:
- /tmp/x/.claude/rules/post-quality.md
- /tmp/x/guidelines/posting-rhythm.md
`;
const m = parseManifest(manifest);
assert.deepStrictEqual(m.created, [
'/tmp/x/.claude/rules/post-quality.md',
'/tmp/x/guidelines/posting-rhythm.md',
]);
});
it('defaults `created` to an empty array when the manifest has no such section', () => {
assert.deepStrictEqual(parseManifest(ENGINE_MANIFEST).created, []);
});
});
// ========================================
// Session-dir hooks (M-BUG-23)
//
// Both hooks watched ~/.config-audit/sessions, which does not exist on the
// operator's machine — sessions live in ~/.claude/config-audit/sessions. The
// hooks exited 0 every time, so "check for active sessions" never fired.
// Shape test: same layer as tests/commands/implement-log-append.test.mjs.
// ========================================
describe('session hooks point at the canonical sessions dir', () => {
for (const hook of ['session-start.mjs', 'stop-session-reminder.mjs']) {
it(`${hook} resolves ~/.claude/config-audit/sessions`, () => {
const src = readFileSync(resolve(import.meta.dirname, '../../hooks/scripts', hook), 'utf-8');
assert.match(
src,
/join\(\s*homedir\(\)\s*,\s*'\.claude'\s*,\s*'config-audit'\s*,\s*'sessions'\s*\)/,
`${hook} must watch the canonical sessions dir`,
);
});
}
});