#!/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 -- ', 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)'); } }