ruvnet-brain 4.0.4 → 4.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -69,23 +69,44 @@ import { applyNightlyChoice, nightlyStatus } from './nightly-controller.mjs';
69
69
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
70
70
  const REPO = path.dirname(__dirname);
71
71
  const CONSOLE_DIR = path.join(REPO, 'console');
72
- const HOME = os.homedir();
73
- const NPM_PREFIX = path.join(HOME, '.npm-global');
74
- const CONFIG_DIR = path.join(HOME, '.claude/ruvnet-brain');
72
+ const SYSTEM_HOME = os.homedir();
73
+
74
+ /**
75
+ * The console's explicit root boundary. It scopes only console-owned configuration, state, cache,
76
+ * and discovery; global binaries, account credentials, and external tool state retain SYSTEM_HOME.
77
+ */
78
+ export function consoleRootFromEnvironment(env = process.env, systemHome = SYSTEM_HOME) {
79
+ const configured = env.RUVNET_CONSOLE_ROOT;
80
+ if (!configured) return systemHome;
81
+ if (typeof configured !== 'string' || !path.isAbsolute(configured)) {
82
+ throw new Error('RUVNET_CONSOLE_ROOT must be an absolute path');
83
+ }
84
+ return path.resolve(configured);
85
+ }
86
+
87
+ // A test or an embedded caller can isolate the console's owned files and discovery roots without
88
+ // redefining the operating-system home directory. Normal launches retain the OS home exactly.
89
+ const CONSOLE_ROOT = consoleRootFromEnvironment();
90
+ const NPM_PREFIX = path.join(SYSTEM_HOME, '.npm-global');
91
+ const CONFIG_DIR = path.join(CONSOLE_ROOT, '.claude/ruvnet-brain');
75
92
  const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
76
- const UNDO_JOURNAL = path.join(HOME, '.cache/ruvnet-brain/console-undo.jsonl');
93
+ const UNDO_JOURNAL = path.join(CONSOLE_ROOT, '.cache/ruvnet-brain/console-undo.jsonl');
77
94
  const INSTALLED_KB = process.env.RUVNET_BRAIN_KB
78
- || path.join(HOME, '.cache', 'ruvnet-brain', 'kb');
95
+ || path.join(CONSOLE_ROOT, '.cache', 'ruvnet-brain', 'kb');
79
96
  const COMPLETE_BRAIN_SOURCE = process.env.RUVNET_BRAIN_COMPLETE_SOURCE
80
97
  || path.join(REPO, 'dist', 'ruvnet-brain');
81
98
  const TOKEN = crypto.randomBytes(24).toString('hex');
82
- const RUNTIME_RECEIPT_DIR = path.join(HOME, '.cache', 'ruvnet-brain', 'console-instances');
99
+ const RUNTIME_RECEIPT_DIR = path.join(CONSOLE_ROOT, '.cache', 'ruvnet-brain', 'console-instances');
83
100
  const RUNTIME_PRODUCT = 'ruvnet-brain-console';
84
101
  const RUNTIME_SCHEMA = 1;
85
102
  const RUNTIME_API_CONTRACT = 1;
86
103
  const RUNTIME_SCRIPT = fs.realpathSync(fileURLToPath(import.meta.url));
87
104
  const RUNTIME_SOURCE_SHA256 = crypto.createHash('sha256').update(fs.readFileSync(RUNTIME_SCRIPT)).digest('hex');
88
105
 
106
+ function consoleCandidateRoots() {
107
+ return candidateRoots({ home: CONSOLE_ROOT, configPath: CONFIG_PATH });
108
+ }
109
+
89
110
  function canonicalScope(cwd = process.cwd()) {
90
111
  try { return fs.realpathSync(cwd); } catch { return path.resolve(cwd); }
91
112
  }
@@ -356,7 +377,7 @@ function wiringSurvey() {
356
377
  // must never count the same project twice.
357
378
  const seenProjects = new Set();
358
379
  const projects = [];
359
- for (const root of candidateRoots()) {
380
+ for (const root of consoleCandidateRoots()) {
360
381
  for (const proj of findProjects(root)) {
361
382
  const resolved = path.resolve(proj);
362
383
  if (seenProjects.has(resolved)) continue;
@@ -394,7 +415,7 @@ function wiringSurvey() {
394
415
 
395
416
  // ── Memory health (read-only probes for the project the console was launched from) ────────────────
396
417
  function sessionHookExists() {
397
- return fs.existsSync(path.join(HOME, '.claude/hooks/agentdb-ensure.sh')) || fs.existsSync(path.join(HOME, '.claude/hooks'));
418
+ return fs.existsSync(path.join(CONSOLE_ROOT, '.claude/hooks/agentdb-ensure.sh')) || fs.existsSync(path.join(CONSOLE_ROOT, '.claude/hooks'));
398
419
  }
399
420
  function probeMemory(projectDir) {
400
421
  const db = path.join(projectDir, '.swarm/memory.db');
@@ -462,7 +483,7 @@ function scanFleet() {
462
483
  function gatherMemory(cwd, { fleet = true } = {}) {
463
484
  // health = for the project the console was launched from (fall back to this repo)
464
485
  const project = fs.existsSync(path.join(cwd, '.swarm/memory.db')) ? cwd : REPO;
465
- const projName = project.replace(HOME + '/Code/', '').replace(HOME + '/', '~/');
486
+ const projName = project.replace(CONSOLE_ROOT + '/Code/', '').replace(CONSOLE_ROOT + '/', '~/');
466
487
  const health = scoreMemoryHealth({ project: projName, probes: probeMemory(project) });
467
488
  return { fleet: fleet ? scanFleet() : null, health };
468
489
  }
@@ -471,10 +492,10 @@ function gatherMemory(cwd, { fleet = true } = {}) {
471
492
  function gatherSavings() {
472
493
  // Primary source is the real routing-receipts ledger written by scripts/route-cheap.mjs.
473
494
  const files = [
474
- path.join(HOME, '.claude/metaharness/routing-receipts.jsonl'),
475
- path.join(HOME, '.cache/ruvnet-brain/metaharness-receipts.jsonl'),
495
+ path.join(SYSTEM_HOME, '.claude/metaharness/routing-receipts.jsonl'),
496
+ path.join(SYSTEM_HOME, '.cache/ruvnet-brain/metaharness-receipts.jsonl'),
476
497
  // Canonical user-level ledger (issue #36 — the hooks no longer scatter per-CWD copies).
477
- path.join(HOME, '.cache/ruvnet-brain/token-ledger.jsonl'),
498
+ path.join(SYSTEM_HOME, '.cache/ruvnet-brain/token-ledger.jsonl'),
478
499
  // Legacy location, still read so an existing user's history is not orphaned by the move.
479
500
  path.join(REPO, 'plugin/scripts/.ruvnet-brain/token-ledger.jsonl'),
480
501
  ];
@@ -578,7 +599,7 @@ function gatherConfig() {
578
599
  });
579
600
  }
580
601
  return {
581
- path: CONFIG_PATH.replace(HOME, '~'),
602
+ path: CONFIG_PATH.replace(CONSOLE_ROOT, '~'),
582
603
  exists: fs.existsSync(CONFIG_PATH),
583
604
  values: {
584
605
  openrouterKey: credential.configured, // boolean only — never the secret itself
@@ -627,7 +648,7 @@ function gatherAdvocacy() {
627
648
  const chosen = raw && typeof raw === 'object' && raw.settings && typeof raw.settings === 'object'
628
649
  ? raw.settings : {};
629
650
  return {
630
- path: state.path.replace(HOME, '~'),
651
+ path: state.path.replace(CONSOLE_ROOT, '~'),
631
652
  exists: state.exists,
632
653
  values: Object.fromEntries(LIVE_USER_FIELDS.map((field) => [
633
654
  field.key,
@@ -675,7 +696,7 @@ function saveAdvocacy(values) {
675
696
  publishSettingsToCache();
676
697
  return {
677
698
  ok: true,
678
- backup: result.backup ? result.backup.replace(HOME, '~') : null,
699
+ backup: result.backup ? result.backup.replace(CONSOLE_ROOT, '~') : null,
679
700
  values: Object.fromEntries(LIVE_USER_SETTING_KEYS.map((key) => [key, result.values[key]])),
680
701
  log: result.log,
681
702
  };
@@ -715,7 +736,7 @@ function gatherBrainProfile() {
715
736
  const source = measureBrainProfile(COMPLETE_BRAIN_SOURCE);
716
737
  const updaterAvailable = fs.existsSync(path.join(INSTALLED_KB, 'forge-update.mjs'));
717
738
  return {
718
- path: INSTALLED_KB.replace(HOME, '~'),
739
+ path: INSTALLED_KB.replace(CONSOLE_ROOT, '~'),
719
740
  values: { brainProfile: actual },
720
741
  stored: settings.values.brainProfile,
721
742
  disagreement: settings.values.brainProfile !== actual,
@@ -736,7 +757,7 @@ function gatherBrainProfile() {
736
757
  bytes: installed.byStore.ruvector ?? source.byStore.ruvector ?? null,
737
758
  },
738
759
  },
739
- restoreSource: COMPLETE_BRAIN_SOURCE.replace(HOME, '~'),
760
+ restoreSource: COMPLETE_BRAIN_SOURCE.replace(CONSOLE_ROOT, '~'),
740
761
  };
741
762
  }
742
763
 
@@ -747,7 +768,7 @@ function gatherBrainPower() {
747
768
  off: state.off,
748
769
  since: state.since,
749
770
  reason: state.reason,
750
- switchPath: state.path.replace(HOME, '~'),
771
+ switchPath: state.path.replace(CONSOLE_ROOT, '~'),
751
772
  // The RESOLVED answer — what the machine actually does — not the mirror's opinion of it.
752
773
  values: { brainEnabled: !state.off },
753
774
  defaults: { brainEnabled: BRAIN_FIELD.default },
@@ -829,7 +850,7 @@ function saveBrainProfile(values) {
829
850
  removed: changed.removed || [],
830
851
  bytesFreed: changed.bytesFreed || 0,
831
852
  mirrored: mirrored.ok,
832
- backup: mirrored.backup ? mirrored.backup.replace(HOME, '~') : null,
853
+ backup: mirrored.backup ? mirrored.backup.replace(CONSOLE_ROOT, '~') : null,
833
854
  log: mirrored.ok
834
855
  ? (profile === PROFILE_RUVECTOR
835
856
  ? `RuVector Only is active — ${changed.removed.length} unselected artifact(s) removed`
@@ -917,7 +938,7 @@ function saveBrainPower(values) {
917
938
  ok: true,
918
939
  off: state.off,
919
940
  since: state.since,
920
- backup: mirrored.backup ? mirrored.backup.replace(HOME, '~') : null,
941
+ backup: mirrored.backup ? mirrored.backup.replace(CONSOLE_ROOT, '~') : null,
921
942
  values: { brainEnabled: !state.off },
922
943
  // Honest about the half-failure rather than reporting a clean success: the switch is what counts
923
944
  // and it moved, but say so plainly if the visible record did not follow it.
@@ -1087,7 +1108,7 @@ function setLesson(body) {
1087
1108
  // setImmediate either (deferring synchronous work still blocks the loop when it runs). A machine with
1088
1109
  // no fleet scan yet is reported `warming`, never as a fabricated zero.
1089
1110
  let ACTIVITY_MACHINE_CACHE = null;
1090
- const CONSOLE_CACHE_PATH = path.join(HOME, '.cache/ruvnet-brain/console-cache.json');
1111
+ const CONSOLE_CACHE_PATH = path.join(CONSOLE_ROOT, '.cache/ruvnet-brain/console-cache.json');
1091
1112
 
1092
1113
  // ── Warm-cache serving (2026-07-17, the demo-hang fix) ─────────────────────────────────────────────
1093
1114
  // Every read-model here does multi-second synchronous work: gatherState ~13s, gatherStack ~22s,
@@ -1537,7 +1558,7 @@ function refreshFleetCache() {
1537
1558
  const seen = new Set();
1538
1559
  // Scan every candidate root (issue #19) — this is what made machine-wide totals read 0 on a
1539
1560
  // machine whose projects live under ~/source instead of ~/Code.
1540
- for (const root of candidateRoots()) {
1561
+ for (const root of consoleCandidateRoots()) {
1541
1562
  for (const s of findMemoryStores(root)) {
1542
1563
  const resolved = path.resolve(s.project);
1543
1564
  if (seen.has(resolved)) continue; // a project visible under two roots (e.g. a symlink) counts once
@@ -1816,11 +1837,11 @@ function brainVersionOnDisk() {
1816
1837
  try { return getVersion(); } catch { return null; }
1817
1838
  }
1818
1839
  function readInstallChannel() {
1819
- const reg = readJSON(path.join(HOME, '.claude/plugins/installed_plugins.json'));
1840
+ const reg = readJSON(path.join(SYSTEM_HOME, '.claude/plugins/installed_plugins.json'));
1820
1841
  const entries = reg && reg.plugins && reg.plugins['ruvnet-brain@ruvnet-brain'];
1821
1842
  const e = Array.isArray(entries) ? entries[0] : null;
1822
1843
  if (!e || !e.installPath || !fs.existsSync(e.installPath)) return { installed: false };
1823
- const km = readJSON(path.join(HOME, '.claude/plugins/known_marketplaces.json'));
1844
+ const km = readJSON(path.join(SYSTEM_HOME, '.claude/plugins/known_marketplaces.json'));
1824
1845
  const src = km && km['ruvnet-brain'] && km['ruvnet-brain'].source;
1825
1846
  const pinned = !!(src && (src.ref || src.tag || src.commit)); // no pin recorded → tracking latest
1826
1847
  return {
@@ -1828,7 +1849,7 @@ function readInstallChannel() {
1828
1849
  version: path.basename(e.installPath) || e.version || null, // the plugin cache version dir IS the truth
1829
1850
  channel: pinned ? 'pinned' : 'latest',
1830
1851
  lastUpdated: e.lastUpdated || null,
1831
- cacheDir: String(e.installPath).replace(HOME, '~'),
1852
+ cacheDir: String(e.installPath).replace(SYSTEM_HOME, '~'),
1832
1853
  repo: (src && src.repo) || null,
1833
1854
  };
1834
1855
  }
@@ -1969,7 +1990,7 @@ function gatherState(cwd, { fleet = true } = {}) {
1969
1990
  // agentic-flow, and the Savings card claiming "Smart routing: ON" while the Capabilities card said
1970
1991
  // "not installed" — in the same render — was the contradiction that made the whole page untrustworthy.
1971
1992
  // The card now carries the same measurement the capability row uses, so the two cannot disagree.
1972
- savings.routingInstalled = fs.existsSync(path.join(HOME, '.npm-global/bin/agentic-flow'));
1993
+ savings.routingInstalled = fs.existsSync(path.join(SYSTEM_HOME, '.npm-global/bin/agentic-flow'));
1973
1994
  try { savings.routerEngine = gatherRouterEngine(); } catch { savings.routerEngine = null; }
1974
1995
  try {
1975
1996
  const cat = loadCatalog();
@@ -2015,7 +2036,7 @@ function gatherState(cwd, { fleet = true } = {}) {
2015
2036
  token: TOKEN,
2016
2037
  generatedAt: new Date().toISOString(),
2017
2038
  preStateHash,
2018
- host: { user: os.userInfo().username, platform: process.platform, node: process.version, npmPrefix: NPM_PREFIX.replace(HOME, '~'), brainVersion: brainVersionOnDisk() },
2039
+ host: { user: os.userInfo().username, platform: process.platform, node: process.version, npmPrefix: NPM_PREFIX.replace(SYSTEM_HOME, '~'), brainVersion: brainVersionOnDisk() },
2019
2040
  sections: { wiring, memory, savings, config, userSettings, brainPower, gates, recommendations },
2020
2041
  };
2021
2042
  // Cache the last good state so repeat page-loads paint instantly, same as the stack audit does.
@@ -2036,7 +2057,7 @@ function gatherStack() {
2036
2057
  // ISSUE #22 — carry `source` ('npm-global' | 'plugin') + marketplace through so the console can show
2037
2058
  // (and count) tools installed via the Claude Code plugin marketplace, not just `npm install -g` ones.
2038
2059
  const rows = a.rows.map((r) => ({ name: r.name, installed: r.installed, target: r.target, tag: r.tag, state: r.state, source: r.source ?? 'npm-global', marketplace: r.marketplace ?? null }));
2039
- const shadows = a.shadows.map((s) => ({ name: s.name, version: s.version, global: s.global, dir: String(s.dir).replace(HOME, '~'), stale: !!(s.global && s.version !== s.global) }));
2060
+ const shadows = a.shadows.map((s) => ({ name: s.name, version: s.version, global: s.global, dir: String(s.dir).replace(SYSTEM_HOME, '~'), stale: !!(s.global && s.version !== s.global) }));
2040
2061
  const by = (st) => rows.filter((r) => r.state === st).length;
2041
2062
  const summary = { total: rows.length, behind: by('BEHIND'), broken: by('BROKEN'), ahead: by('AHEAD'), current: by('CURRENT'), unresolved: by('UNRESOLVED'), shadows: shadows.length, stale: a.stale.length };
2042
2063
  const recommendations = buildStackRecommendations({ rows: a.rows, stale: a.stale });
@@ -2064,29 +2085,32 @@ function runNode(scriptRelPath, args) {
2064
2085
  const r = spawnSync(process.execPath, [path.join(REPO, scriptRelPath), ...args], { encoding: 'utf8', timeout: 16 * 60 * 1000, cwd: REPO });
2065
2086
  return { ok: r.status === 0, code: r.status, log: `${r.stdout || ''}${r.stderr || ''}`.trim().slice(-4000) };
2066
2087
  }
2088
+ function elapsedMs(startedAt) {
2089
+ return Math.round((performance.now() - startedAt) * 1000) / 1000;
2090
+ }
2067
2091
 
2068
2092
  // A wiring recommendation's `project` id is relative to whichever candidate root it was found under
2069
2093
  // (issue #19) — reconstruct the absolute path by checking each root, so reconcile/undo can act on a
2070
2094
  // project under ~/source just as well as one under ~/Code.
2071
2095
  function resolveProjectDir(project) {
2072
- for (const root of candidateRoots()) {
2096
+ for (const root of consoleCandidateRoots()) {
2073
2097
  const p = path.join(root, project);
2074
2098
  if (fs.existsSync(p)) return p;
2075
2099
  }
2076
- return path.join(HOME, 'Code', project); // last-resort fallback: the previous fixed behavior
2100
+ return path.join(CONSOLE_ROOT, 'Code', project); // last-resort fallback: the previous fixed behavior
2077
2101
  }
2078
2102
  // Re-derive the currently-valid recommendation set, so apply can only ever act on something STILL true.
2079
2103
  /**
2080
2104
  * Observe the learner's REAL state, for the health recommendations.
2081
2105
  *
2082
- * Deliberately reads the GLOBAL learner (`cwd: HOME`), because that is the store the capture flush
2106
+ * Deliberately reads the global learner, because that is the store the capture flush
2083
2107
  * actually writes to. Reading the project-local `.claude-flow/neural` instead is exactly the mistake
2084
2108
  * that made the console display a dead learner (5 trajectories, last trained 6 days earlier) while
2085
2109
  * the live one held 412 — rUv documents this fragmentation as issue #2245, "four contradictory
2086
2110
  * sources". Until it is unified upstream we read the store that learning writes, never the corpse.
2087
2111
  */
2088
2112
  function observeLearning() {
2089
- const queueDir = path.join(os.homedir(), '.cache', 'ruvnet-brain', 'learn');
2113
+ const queueDir = path.join(SYSTEM_HOME, '.cache', 'ruvnet-brain', 'learn');
2090
2114
  let queueDepth = 0;
2091
2115
  try {
2092
2116
  for (const f of fs.readdirSync(queueDir)) {
@@ -2097,10 +2121,10 @@ function observeLearning() {
2097
2121
 
2098
2122
  let lastTrainSeconds = null; let trajectories = 0;
2099
2123
  try {
2100
- const r = spawnSync(path.join(os.homedir(), '.npm-global/bin/ruflo'),
2124
+ const r = spawnSync(path.join(SYSTEM_HOME, '.npm-global/bin/ruflo'),
2101
2125
  ['hooks', 'intelligence', '--status'],
2102
2126
  {
2103
- cwd: os.homedir(),
2127
+ cwd: SYSTEM_HOME,
2104
2128
  env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
2105
2129
  encoding: 'utf8',
2106
2130
  timeout: 20_000,
@@ -2167,11 +2191,15 @@ function currentValidIds(onlyId = null) {
2167
2191
  return { ids, auditRows };
2168
2192
  }
2169
2193
  function apply(ids) {
2194
+ const startedAt = performance.now();
2195
+ const phaseMs = { revalidationMs: 0, undoJournalMs: 0, childRemedyMs: 0 };
2170
2196
  const results = [];
2171
2197
  for (const id of ids) {
2172
2198
  // Re-read immediately before EACH fix. A batch can change the validity of the next item; one
2173
2199
  // pre-flight snapshot for the whole list would let item 2 run against the world item 1 changed.
2200
+ const revalidationStartedAt = performance.now();
2174
2201
  const { ids: validNow } = currentValidIds(id);
2202
+ phaseMs.revalidationMs += elapsedMs(revalidationStartedAt);
2175
2203
  if (!validNow.has(id)) { results.push({ id, ok: false, skipped: true, error: 'worldMoved', log: 'Skipped — this is already resolved, or your machine changed since the page loaded. Nothing was done. Reload to see the current state.' }); continue; }
2176
2204
 
2177
2205
  // ONE dispatch, through the registry (scripts/remedy-registry.mjs). This used to be a chain of
@@ -2213,16 +2241,22 @@ function apply(ids) {
2213
2241
  // flag exists so it cannot recur here.
2214
2242
  if (plan.exec.usesServerProject) args = [...args, '--project', process.cwd()];
2215
2243
  if (plan.exec.needsReceipt) {
2216
- const receipt = path.join(HOME, '.cache', 'ruvnet-brain', 'undo', `${plan.key}-${stamp()}.json`);
2244
+ const receipt = path.join(CONSOLE_ROOT, '.cache', 'ruvnet-brain', 'undo', `${plan.key}-${stamp()}.json`);
2217
2245
  undoSpec.receipt = receipt;
2218
2246
  args = [...args, '--receipt', receipt];
2219
2247
  }
2220
2248
 
2249
+ const journalStartedAt = performance.now();
2221
2250
  const undoToken = journalUndo(undoSpec);
2251
+ phaseMs.undoJournalMs += elapsedMs(journalStartedAt);
2252
+ const remedyStartedAt = performance.now();
2222
2253
  const res = runNode(plan.exec.script, args);
2254
+ phaseMs.childRemedyMs += elapsedMs(remedyStartedAt);
2223
2255
  results.push({ id, ...res, undoToken });
2224
2256
  }
2225
- return { results };
2257
+ // This receipt contains only aggregate durations, never the applied ids, paths, command output,
2258
+ // or undo tokens. It makes an end-user-visible delay diagnosable without exposing their machine.
2259
+ return { results, timings: { ...phaseMs, totalMs: elapsedMs(startedAt) } };
2226
2260
  }
2227
2261
 
2228
2262
  function autoEligibleIds(recommendations = []) {
@@ -2294,7 +2328,7 @@ function validateConfigPatch(values) {
2294
2328
  */
2295
2329
  function saveConfig(values) {
2296
2330
  try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); }
2297
- catch (e) { return { ok: false, log: `could not create ${CONFIG_DIR.replace(HOME, '~')}: ${e.message}` }; }
2331
+ catch (e) { return { ok: false, log: `could not create ${CONFIG_DIR.replace(CONSOLE_ROOT, '~')}: ${e.message}` }; }
2298
2332
 
2299
2333
  const { clean, rejected } = validateConfigPatch(values);
2300
2334
  // Nothing valid to write is not a save. Saying "Saved." here would be the dead-button failure with
@@ -2356,7 +2390,7 @@ function saveConfig(values) {
2356
2390
  // A successfully encrypted credential retires the legacy plaintext field on this same commit.
2357
2391
  if (credentialChange?.ok) delete next.openrouterKey;
2358
2392
  try { writeAtomic(CONFIG_PATH, JSON.stringify(next, null, 2) + '\n'); }
2359
- catch (e) { return { ok: false, backup, log: `write failed: ${e.message}${backup ? `; your previous settings are at ${backup.replace(HOME, '~')}` : ''}` }; }
2393
+ catch (e) { return { ok: false, backup, log: `write failed: ${e.message}${backup ? `; your previous settings are at ${backup.replace(CONSOLE_ROOT, '~')}` : ''}` }; }
2360
2394
  try { fs.chmodSync(CONFIG_PATH, 0o600); } catch { /* best effort on non-posix */ }
2361
2395
 
2362
2396
  // The undo token is journalled only AFTER the write succeeded. Recording an undo for a save that
@@ -2370,7 +2404,7 @@ function saveConfig(values) {
2370
2404
  secretPath: credentialChange?.path ?? null,
2371
2405
  secretExisted: credentialChange?.existed ?? null,
2372
2406
  });
2373
- return { ok: true, backup: backup ? backup.replace(HOME, '~') : null, undoToken, rejected };
2407
+ return { ok: true, backup: backup ? backup.replace(CONSOLE_ROOT, '~') : null, undoToken, rejected };
2374
2408
  });
2375
2409
 
2376
2410
  if (held.timedOut) {
@@ -2464,7 +2498,7 @@ function undo(undoToken) {
2464
2498
  try {
2465
2499
  const bytes = fs.readFileSync(entry.backup);
2466
2500
  held = withLock(CONFIG_PATH, () => writeAtomic(CONFIG_PATH, bytes));
2467
- } catch (e) { return { ok: false, log: `restore failed: ${e.message} — your backup at ${entry.backup.replace(HOME, '~')} is intact` }; }
2501
+ } catch (e) { return { ok: false, log: `restore failed: ${e.message} — your backup at ${entry.backup.replace(CONSOLE_ROOT, '~')} is intact` }; }
2468
2502
  if (held.timedOut) return { ok: false, log: `another process is writing your settings and did not finish within ${LOCK_WAIT_MS}ms — NOTHING was restored and your backup is intact; try again` };
2469
2503
  try { fs.chmodSync(CONFIG_PATH, 0o600); } catch { /* best effort on non-posix */ }
2470
2504
  const effectFailures = restoreConfigEffects(entry);
@@ -2524,10 +2558,10 @@ function undo(undoToken) {
2524
2558
  const base = `${path.basename(entry.db)}.rescue-`;
2525
2559
  let baks = [];
2526
2560
  try { baks = fs.readdirSync(dir).filter((n) => n.startsWith(base)).sort(); } catch { /* dir gone */ }
2527
- if (!baks.length) return { ok: false, log: `no pre-repair backup found next to ${entry.db.replace(HOME, '~')} — nothing was restored` };
2561
+ if (!baks.length) return { ok: false, log: `no pre-repair backup found next to ${entry.db.replace(CONSOLE_ROOT, '~')} — nothing was restored` };
2528
2562
  const from = path.join(dir, baks[baks.length - 1]);
2529
2563
  try { fs.copyFileSync(from, entry.db); }
2530
- catch (e) { return { ok: false, log: `could not restore ${from.replace(HOME, '~')}: ${e.message}` }; }
2564
+ catch (e) { return { ok: false, log: `could not restore ${from.replace(CONSOLE_ROOT, '~')}: ${e.message}` }; }
2531
2565
  markUndoConsumed(undoToken);
2532
2566
  return { ok: true, log: `restored your memory store from the snapshot taken before the repair (${baks[baks.length - 1]})` };
2533
2567
  }
@@ -2801,7 +2835,7 @@ function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = f
2801
2835
  // precise moment a first-time user is deciding whether this thing works. The child does that
2802
2836
  // scan now (see --refresh-cache), and gatherActivity reports `warming` until it lands.
2803
2837
  if (open) openBrowser(url);
2804
- // Browser acceptance pre-warms a disposable HOME and disables only this redundant second scan.
2838
+ // Browser acceptance pre-warms a disposable fixture root and disables only this redundant second scan.
2805
2839
  // The production default is unchanged: every ordinary console start refreshes in the background.
2806
2840
  if (process.env.RUVNET_CONSOLE_DISABLE_BACKGROUND_REFRESH !== '1') kickRefresh({ force: true });
2807
2841
  });
@@ -33,9 +33,9 @@ export function verdictOf(results) {
33
33
  return results.reduce((worst, r) => (RANK.indexOf(r.state) < RANK.indexOf(worst) ? r.state : worst), 'PASS');
34
34
  }
35
35
 
36
- export function candidateLineage() {
36
+ export function candidateLineage(root = ROOT) {
37
37
  const git = (args) => {
38
- const r = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
38
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
39
39
  return r.status === 0 ? r.stdout.trim() : '';
40
40
  };
41
41
  return {
@@ -49,11 +49,8 @@ export function verdictWithLineage(results, lineage) {
49
49
  return lineage?.dirty ? 'FAIL' : verdictOf(results);
50
50
  }
51
51
 
52
- const exists = (rel) => fs.existsSync(path.join(ROOT, rel));
53
- const read = (rel) => { try { return fs.readFileSync(path.join(ROOT, rel), 'utf8'); } catch { return null; } };
54
-
55
52
  /** Run a command; UNKNOWN (not FAIL) when the runner itself could not execute. */
56
- function run(cmd, args, timeoutMs = 120_000, env = process.env) {
53
+ function runAt(root, cmd, args, timeoutMs = 120_000, env = process.env) {
57
54
  // npm installs `npm.cmd` / `npx.cmd` command shims on Windows. Direct spawn does not resolve the
58
55
  // extension through PATHEXT, and .cmd files require cmd.exe, so a healthy detector otherwise
59
56
  // reads UNKNOWN with ENOENT. Invoke the command interpreter explicitly rather than `shell:true`;
@@ -62,7 +59,7 @@ function run(cmd, args, timeoutMs = 120_000, env = process.env) {
62
59
  const executable = windowsCommandShim ? (process.env.ComSpec || 'cmd.exe') : cmd;
63
60
  const spawnArgs = windowsCommandShim ? ['/d', '/c', `${cmd}.cmd`, ...args] : args;
64
61
  const r = spawnSync(executable, spawnArgs, {
65
- cwd: ROOT,
62
+ cwd: root,
66
63
  encoding: 'utf8',
67
64
  timeout: timeoutMs,
68
65
  env,
@@ -71,6 +68,18 @@ function run(cmd, args, timeoutMs = 120_000, env = process.env) {
71
68
  return { state: r.status === 0 ? 'PASS' : 'FAIL', why: r.status === 0 ? 'exit 0' : `exit ${r.status}`, out: r.stdout };
72
69
  }
73
70
 
71
+ /** An explicit detector root makes destructive mutants isolated and concurrency-safe. */
72
+ function detectorScope({ root = ROOT, runCommand = null } = {}) {
73
+ return {
74
+ root,
75
+ exists: (relative) => fs.existsSync(path.join(root, relative)),
76
+ read: (relative) => {
77
+ try { return fs.readFileSync(path.join(root, relative), 'utf8'); } catch { return null; }
78
+ },
79
+ run: runCommand || ((cmd, args, timeoutMs, env) => runAt(root, cmd, args, timeoutMs, env)),
80
+ };
81
+ }
82
+
74
83
  // ─── the eight invariants ────────────────────────────────────────────────────────────────────
75
84
  // Each names the dimension it gates and the dated failure it descends from. A detector with no
76
85
  // incident behind it is a checkbox; every one of these was bought with a real defect.
@@ -80,7 +89,8 @@ export const INVARIANTS = [
80
89
  name: 'INSTALL-FAILS-LOUD',
81
90
  dimension: 'D8',
82
91
  incident: 'verifyInstall() warned and continued; a stranger scored 18/100 on an install that could not fail',
83
- detect() {
92
+ detect(options) {
93
+ const { exists, read } = detectorScope(options);
84
94
  if (!exists('.github/workflows/stranger-matrix.yml')) return { state: 'FAIL', why: 'no stranger matrix' };
85
95
  const inst = read('bin/install.mjs');
86
96
  if (inst === null) return { state: 'UNKNOWN', why: 'bin/install.mjs unreadable' };
@@ -95,7 +105,8 @@ export const INVARIANTS = [
95
105
  name: 'INTERFACE-CORPUS',
96
106
  dimension: 'D7',
97
107
  incident: 'shell-parsing defects recurred across issues #12/#13/#41/#44, then three stale ADRs reached main because document currency was outside every release gate',
98
- detect() {
108
+ detect(options) {
109
+ const { exists, run } = detectorScope(options);
99
110
  if (!exists('tests/regression/interface-gate-corpus.test.mjs')) return { state: 'FAIL', why: 'no incident corpus' };
100
111
  if (!exists('scripts/doc-currency.mjs')) return { state: 'FAIL', why: 'no document-currency gate' };
101
112
  const corpus = run('npx', ['vitest', 'run', 'tests/regression/interface-gate-corpus.test.mjs', '--reporter=dot']);
@@ -110,23 +121,29 @@ export const INVARIANTS = [
110
121
  name: 'LATENCY-DECISION-LANE',
111
122
  dimension: 'D6',
112
123
  incident: 'latency breaches only warned; timing regressions did not block shipping',
113
- detect: () => (exists('scripts/qe/card-lane-gate.mjs')
114
- ? run('node', ['scripts/qe/card-lane-gate.mjs'])
115
- : { state: 'FAIL', why: 'no decision-lane gate' }),
124
+ detect(options) {
125
+ const { exists, run } = detectorScope(options);
126
+ return exists('scripts/qe/card-lane-gate.mjs')
127
+ ? run('node', ['scripts/qe/card-lane-gate.mjs'])
128
+ : { state: 'FAIL', why: 'no decision-lane gate' };
129
+ },
116
130
  },
117
131
  {
118
132
  name: 'COEXIST-BYTE-EQUAL',
119
133
  dimension: 'D5',
120
134
  incident: 'the merged-registry lint found 63 findings across 42 registrations; the prior suite saw 15',
121
- detect: () => (exists('tests/mesh/coexistence.test.mjs')
122
- ? run('npx', ['vitest', 'run', 'tests/mesh', '--reporter=dot'])
123
- : { state: 'FAIL', why: 'no coexistence suite' }),
135
+ detect(options) {
136
+ const { exists, run } = detectorScope(options);
137
+ return exists('tests/mesh/coexistence.test.mjs')
138
+ ? run('npx', ['vitest', 'run', 'tests/mesh', '--reporter=dot'])
139
+ : { state: 'FAIL', why: 'no coexistence suite' };
140
+ },
124
141
  },
125
142
  {
126
143
  name: 'LEARNING-REPLAY',
127
144
  dimension: 'D4',
128
145
  incident: 'L4 asserted the hook\'s own prose contained words — it proved the brain SPOKE, never that anything LISTENED',
129
- async detect() {
146
+ async detect(options = {}) {
130
147
  // DELEGATED to scripts/learning-replay.mjs's own checkArtifact(), not reimplemented here.
131
148
  //
132
149
  // This detector originally required `artifact.sha === HEAD`, which is WRONG in a way worth
@@ -141,7 +158,9 @@ export const INVARIANTS = [
141
158
  try { mod = await import(new URL('./learning-replay.mjs', import.meta.url).href); }
142
159
  catch { return { state: 'UNKNOWN', why: 'counterfactual replay harness absent' }; }
143
160
  let r;
144
- try { r = mod.checkPortfolio(); } catch (e) { return { state: 'UNKNOWN', why: `replay portfolio unreadable: ${e.message}` }; }
161
+ try {
162
+ r = options.checkReplay ? options.checkReplay() : mod.checkPortfolio({ repo: options.root || ROOT });
163
+ } catch (e) { return { state: 'UNKNOWN', why: `replay portfolio unreadable: ${e.message}` }; }
145
164
  // `.status` is the field checkArtifact() actually returns — read, not assumed. Reaching for
146
165
  // `.verdict` (the name used INSIDE the nested artifact) silently yielded undefined and made a
147
166
  // real PASS read UNKNOWN: the delegation "worked" while reporting the opposite of the truth.
@@ -157,7 +176,8 @@ export const INVARIANTS = [
157
176
  name: 'SIGNAL-WATCH-FIRES',
158
177
  dimension: 'D3',
159
178
  incident: 'the OWNER had to report that CI was red — every local surface stayed calm',
160
- detect() {
179
+ detect(options) {
180
+ const { exists, read, run } = detectorScope(options);
161
181
  if (!exists('scripts/signal-watch.mjs')) return { state: 'FAIL', why: 'no signal watcher' };
162
182
  const raw = read('plugin/hooks/hooks.json');
163
183
  if (raw === null) return { state: 'UNKNOWN', why: 'plugin/hooks/hooks.json unreadable' };
@@ -207,15 +227,19 @@ export const INVARIANTS = [
207
227
  name: 'SCENARIOS-CURRENT',
208
228
  dimension: 'D2',
209
229
  incident: 'ADR-053 specified a mental-model scenario list; it was simply absent',
210
- detect: () => (exists('tests/experience/report.mjs')
211
- ? run('node', ['tests/experience/report.mjs'])
212
- : { state: 'FAIL', why: 'no scenario report' }),
230
+ detect(options) {
231
+ const { exists, run } = detectorScope(options);
232
+ return exists('tests/experience/report.mjs')
233
+ ? run('node', ['tests/experience/report.mjs'])
234
+ : { state: 'FAIL', why: 'no scenario report' };
235
+ },
213
236
  },
214
237
  {
215
238
  name: 'GUARANTEE-RUNS',
216
239
  dimension: 'D1',
217
240
  incident: 'the product guarantee skipped on every CI runner; REQUIRE_BRAIN=1 was set nowhere in the repo',
218
- detect() {
241
+ detect(options) {
242
+ const { read, run } = detectorScope(options);
219
243
  const ci = read('.github/workflows/ci.yml');
220
244
  if (ci === null) return { state: 'UNKNOWN', why: 'ci.yml unreadable' };
221
245
  if (!/REQUIRE_BRAIN/.test(ci)) {
@@ -247,12 +271,12 @@ export function headSha() {
247
271
  /** Strings a non-PASS verdict mechanically bans from release surfaces (ADR-058). */
248
272
  export const BANNED_WHEN_DEGRADED = ['healthy', 'proven', 'all pass'];
249
273
 
250
- export async function evaluate(invariants = INVARIANTS) {
251
- const lineage = candidateLineage();
274
+ export async function evaluate(invariants = INVARIANTS, options = {}) {
275
+ const lineage = candidateLineage(options.root || ROOT);
252
276
  const sha = lineage.sha;
253
277
  const results = [];
254
278
  for (const inv of invariants) {
255
- const r = await inv.detect();
279
+ const r = await inv.detect(options);
256
280
  results.push({ name: inv.name, dimension: inv.dimension, state: r.state, why: r.why, sha });
257
281
  }
258
282
  return { sha, lineage, results, verdict: verdictWithLineage(results, lineage) };
@@ -47,7 +47,7 @@ export function createStabilizationReceipt({ root, artifactPath, qe, audit }) {
47
47
  schemaVersion: 1,
48
48
  phase: 'stabilization-candidate',
49
49
  mode: 'stabilization',
50
- reason: 'Owner-authorized 4.0.4 stabilization release; >=95 promotion remains open work',
50
+ reason: `Owner-authorized ${manifest.version} stabilization release; >=95 promotion remains open work`,
51
51
  targetScore: 95,
52
52
  scoreClaimed: false,
53
53
  sha,