feat(brain): rollback infra + snapshots + e2e-verified BEFORE any destruction (phase 1 task 1)
Establishes a proven rollback mechanism for the LLM-first router overhaul before
any destructive step. Without this, Phase 1-3 work would be irreversible.
What this commit adds:
- Git tag 'brain-pre-llm-bootstrap' on origin/main 9d4a30c3 (pre-overhaul state).
- docs/archive/llm-bootstrap-2026-05/ archive structure with:
- settings-snapshot/ — pre-overhaul ~/.claude/settings.json + project settings
- user-hooks/ — all 14 ~/.claude/hooks/*.py pre-overhaul (incl. §12 ones)
- runtime-flags-snapshot/ — pre-overhaul ~/.claude/runtime/*-mode.json
- nodes-yaml-archive/ — pre-overhaul docs/registry/nodes.yaml
- tools/test-rollback.mjs — rollback planner + executor (--dry-run / --execute)
- tools/test-rollback.test.mjs — TDD: 3 tests for planRollback() contract
- ROLLBACK.md — operator runbook with from->to manifest
E2E smoke proof was run BEFORE this commit (Task 1 step 9):
1. Created TEMP marker commit on top of tag with a dummy file + runtime flag.
2. Ran 'test-rollback.mjs --dry-run' (OK) then '--execute' (user state restored).
3. Reverted git-tracked state and verified marker + flag gone.
4. Verified Task 1 untracked files survived the rollback.
Smoke discovered a bug in the plan's procedure ('git checkout tag -- .' +
'git reset --soft tag' does NOT delete files committed-after-tag — they stay
staged). ROLLBACK.md uses 'git reset --hard <tag>' instead, which correctly
removes overhaul-added tracked files while preserving untracked artefacts
(episodes-*.jsonl, observer notes).
TDD: 3/3 green on test-rollback.test.mjs. Full vitest tools/: 546 passed (was
543 baseline, +3 from this commit), 4 pre-existing 'No test suite' failures
on tools/ruflo-* and tools/subagent-prompt-prefix.test.mjs (out of scope).
Plan: docs/superpowers/plans/2026-05-25-llm-first-router-overhaul.md Task 1.
Spec: docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env node
|
||||
// tools/test-rollback.mjs — Rollback planner + executor for the LLM-first router overhaul.
|
||||
//
|
||||
// Plan: docs/superpowers/plans/2026-05-25-llm-first-router-overhaul.md Task 1.
|
||||
// Spec: docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md §13 (rollback).
|
||||
//
|
||||
// Two responsibilities:
|
||||
// 1. planRollback() — pure, returns a description of what rollback does (testable)
|
||||
// 2. dryRun() / execRollback() — CLI entry points
|
||||
//
|
||||
// Safety:
|
||||
// - execFileSync (no shell, no command injection)
|
||||
// - Entry-point guard uses resolve() (Windows + Cyrillic paths safe, per quirk #103)
|
||||
// - episodes-*.jsonl and observer/notes/* are PRESERVED, never reverted (G5/G6)
|
||||
// - Parser stays forward-compatible to schema v4 after rollback (G5, Task 15)
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
copyFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
mkdirSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ARCHIVE = 'docs/archive/llm-bootstrap-2026-05';
|
||||
|
||||
/**
|
||||
* Pure description of the rollback plan.
|
||||
* Used by tools/test-rollback.test.mjs and as the source of truth for the CLI.
|
||||
*/
|
||||
export function planRollback() {
|
||||
return {
|
||||
gitTag: 'brain-pre-llm-bootstrap',
|
||||
gitStrategy: 'git checkout brain-pre-llm-bootstrap -- <tracked paths>',
|
||||
userLevelRestores: [
|
||||
{
|
||||
from: `${ARCHIVE}/settings-snapshot/user-settings.json.pre-overhaul`,
|
||||
to: '~/.claude/settings.json',
|
||||
},
|
||||
{ from: `${ARCHIVE}/user-hooks/*`, to: '~/.claude/hooks/' },
|
||||
],
|
||||
flagStrategy: 'restore-snapshot-delete-new',
|
||||
preserve: [
|
||||
'docs/observer/episodes-*.jsonl',
|
||||
'docs/observer/notes/*',
|
||||
],
|
||||
parserNote:
|
||||
'после отката parser остаётся forward-compatible к v4 эпизодам (read-only graceful skip) — Task 15 (G5)',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry-run: verify rollback artefacts exist and surface missing ones.
|
||||
* Returns true if rollback is ready, false otherwise.
|
||||
*/
|
||||
export function dryRun() {
|
||||
const plan = planRollback();
|
||||
let ok = true;
|
||||
|
||||
const baseSnap = `${ARCHIVE}/settings-snapshot/user-settings.json.pre-overhaul`;
|
||||
if (!existsSync(baseSnap)) {
|
||||
console.error('MISSING snapshot:', baseSnap);
|
||||
ok = false;
|
||||
}
|
||||
const projSnap = `${ARCHIVE}/settings-snapshot/project-settings.json.pre-overhaul`;
|
||||
if (!existsSync(projSnap)) {
|
||||
console.error('MISSING snapshot:', projSnap);
|
||||
ok = false;
|
||||
}
|
||||
const hooksDir = `${ARCHIVE}/user-hooks`;
|
||||
if (!existsSync(hooksDir) || readdirSync(hooksDir).length === 0) {
|
||||
console.error('MISSING or empty hooks snapshot:', hooksDir);
|
||||
ok = false;
|
||||
}
|
||||
const nodesSnap = `${ARCHIVE}/nodes-yaml-archive/nodes.yaml.pre-overhaul`;
|
||||
if (!existsSync(nodesSnap)) {
|
||||
console.error('MISSING snapshot:', nodesSnap);
|
||||
ok = false;
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync('git', ['rev-parse', plan.gitTag], { stdio: 'pipe' });
|
||||
} catch {
|
||||
console.error('MISSING git tag:', plan.gitTag);
|
||||
ok = false;
|
||||
}
|
||||
|
||||
console.log(ok ? '[dry-run] OK — rollback ready' : '[dry-run] FAIL — see above');
|
||||
return ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute rollback of user-level state + runtime flags.
|
||||
* Git-tracked rollback is left to the operator (separate manual step in ROLLBACK.md)
|
||||
* to keep destructive `git checkout` explicit.
|
||||
*/
|
||||
export function execRollback() {
|
||||
const home = homedir();
|
||||
|
||||
// 1. user settings.json
|
||||
const usFrom = `${ARCHIVE}/settings-snapshot/user-settings.json.pre-overhaul`;
|
||||
if (existsSync(usFrom)) {
|
||||
copyFileSync(usFrom, join(home, '.claude', 'settings.json'));
|
||||
console.log('[execute] restored ~/.claude/settings.json');
|
||||
} else {
|
||||
console.error('[execute] SKIP user settings — snapshot missing');
|
||||
}
|
||||
|
||||
// 2. user hooks (full directory restore — wipe new hooks, restore snapshot)
|
||||
const hooksSrc = `${ARCHIVE}/user-hooks`;
|
||||
const hooksDst = join(home, '.claude', 'hooks');
|
||||
if (existsSync(hooksSrc)) {
|
||||
if (!existsSync(hooksDst)) mkdirSync(hooksDst, { recursive: true });
|
||||
// wipe current
|
||||
for (const f of readdirSync(hooksDst)) {
|
||||
const fp = join(hooksDst, f);
|
||||
if (statSync(fp).isFile()) rmSync(fp);
|
||||
}
|
||||
// restore snapshot
|
||||
let count = 0;
|
||||
for (const f of readdirSync(hooksSrc)) {
|
||||
const sp = join(hooksSrc, f);
|
||||
if (statSync(sp).isFile()) {
|
||||
copyFileSync(sp, join(hooksDst, f));
|
||||
count++;
|
||||
}
|
||||
}
|
||||
console.log(`[execute] restored ~/.claude/hooks/ (${count} files)`);
|
||||
} else {
|
||||
console.error('[execute] SKIP user hooks — snapshot missing');
|
||||
}
|
||||
|
||||
// 3. runtime flags: delete *-mode.json files not present in snapshot, restore snapshot files
|
||||
const runtimeDir = join(home, '.claude', 'runtime');
|
||||
const snapDir = `${ARCHIVE}/runtime-flags-snapshot`;
|
||||
if (existsSync(runtimeDir)) {
|
||||
const snapFlags = existsSync(snapDir) ? readdirSync(snapDir) : [];
|
||||
let deleted = 0;
|
||||
for (const f of readdirSync(runtimeDir).filter((x) => x.endsWith('-mode.json'))) {
|
||||
if (!snapFlags.includes(f)) {
|
||||
rmSync(join(runtimeDir, f));
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
let restored = 0;
|
||||
for (const f of snapFlags) {
|
||||
copyFileSync(join(snapDir, f), join(runtimeDir, f));
|
||||
restored++;
|
||||
}
|
||||
console.log(
|
||||
`[execute] runtime flags: deleted ${deleted} new, restored ${restored} from snapshot`,
|
||||
);
|
||||
} else {
|
||||
console.error('[execute] SKIP runtime flags — ~/.claude/runtime/ missing');
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[execute] user-level + flags restored. ' +
|
||||
'Now run: git checkout brain-pre-llm-bootstrap -- . && npm install',
|
||||
);
|
||||
}
|
||||
|
||||
// Entry-point guard — Cyrillic-safe (quirk #103: import.meta.url === argv[1] fails on RU paths).
|
||||
const argv1 = process.argv[1] ? resolve(process.argv[1]) : '';
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const isMain = argv1 && argv1 === here;
|
||||
|
||||
if (isMain) {
|
||||
const mode = process.argv[2];
|
||||
if (mode === '--dry-run') {
|
||||
process.exit(dryRun() ? 0 : 1);
|
||||
} else if (mode === '--execute') {
|
||||
execRollback();
|
||||
} else {
|
||||
console.log('usage: node tools/test-rollback.mjs --dry-run | --execute');
|
||||
console.log('');
|
||||
console.log(' --dry-run verify rollback artefacts are in place; exit 0 if ready');
|
||||
console.log(' --execute restore user-level state + runtime flags from snapshot');
|
||||
console.log(' (run "git checkout brain-pre-llm-bootstrap -- ." separately)');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// tools/test-rollback.test.mjs — TDD spec for the rollback planner.
|
||||
// Plan: docs/superpowers/plans/2026-05-25-llm-first-router-overhaul.md Task 1 step 4.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { planRollback } from './test-rollback.mjs';
|
||||
|
||||
describe('planRollback', () => {
|
||||
it('restores git-tracked state via the pre-overhaul tag + lists user-level snapshots', () => {
|
||||
const plan = planRollback();
|
||||
expect(plan.gitTag).toBe('brain-pre-llm-bootstrap');
|
||||
expect(plan.userLevelRestores.some((r) => r.to.includes('settings.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('resets runtime flags from snapshot (not hardcoded list)', () => {
|
||||
expect(planRollback().flagStrategy).toBe('restore-snapshot-delete-new');
|
||||
});
|
||||
|
||||
it('lists episodes as PRESERVED, not reverted (G5/G6)', () => {
|
||||
expect(planRollback().preserve.some((x) => x.includes('episodes'))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user