ruvnet-brain 4.3.40 β†’ 4.4.1

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.
Files changed (49) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +179 -36
  3. package/kb/brain-profile.mjs +17 -2
  4. package/kb/forge-update.mjs +6 -2
  5. package/kb/lifecycle-evidence-retention.mjs +12 -9
  6. package/kb/refresh-run.mjs +17 -1
  7. package/kb/update-storage-transaction.mjs +33 -5
  8. package/package.json +1 -1
  9. package/plugin/.claude-plugin/plugin.json +1 -1
  10. package/plugin/.codex-plugin/plugin.json +1 -1
  11. package/plugin/hooks/codex-hooks.json +2 -2
  12. package/plugin/hooks/hooks.json +1 -1
  13. package/plugin/scripts/capability-registry.mjs +3 -3
  14. package/plugin/scripts/codex-hook-wrapper.mjs +7 -2
  15. package/plugin/scripts/design-wall.sh +1 -0
  16. package/plugin/scripts/ground-before-write.sh +1 -0
  17. package/plugin/scripts/ground-ruvnet.sh +3 -3
  18. package/plugin/scripts/grounding-answer.mjs +129 -0
  19. package/plugin/scripts/grounding-stamp.sh +32 -31
  20. package/plugin/scripts/grounding-turn-evidence.mjs +146 -5
  21. package/plugin/scripts/grounding-turn-gate.mjs +25 -6
  22. package/plugin/scripts/hook-shim.mjs +3 -0
  23. package/plugin/scripts/kling-preflight.sh +1 -0
  24. package/plugin/scripts/learn-capture.sh +1 -0
  25. package/plugin/scripts/project-progression-reader.mjs +10 -0
  26. package/plugin/scripts/project-progression-sources.mjs +16 -4
  27. package/plugin/scripts/project-progression-store.mjs +201 -6
  28. package/plugin/scripts/protect-brain-state.sh +1 -0
  29. package/plugin/scripts/route-dispatch.sh +1 -0
  30. package/plugin/scripts/session-snapshot-hook.mjs +383 -37
  31. package/plugin/scripts/session-start-health.mjs +24 -3
  32. package/plugin/scripts/session-start-update-plane.mjs +1 -1
  33. package/plugin/scripts/update-apply.mjs +22 -2
  34. package/scripts/console-instances.mjs +203 -0
  35. package/scripts/console-runtime-identity.mjs +2 -0
  36. package/scripts/corpus-canary.mjs +130 -18
  37. package/scripts/customer-seams.mjs +84 -0
  38. package/scripts/customer-state-matrix.mjs +363 -0
  39. package/scripts/full-suite-gate.mjs +162 -0
  40. package/scripts/grounding-turn-replay.mjs +11 -3
  41. package/scripts/hook-qualify-core.mjs +346 -0
  42. package/scripts/hook-qualify-hosts.mjs +115 -0
  43. package/scripts/hook-qualify.mjs +101 -0
  44. package/scripts/host-cli.mjs +115 -0
  45. package/scripts/qe/agentic-qe-4.3.mjs +0 -1
  46. package/scripts/route-gold-rank.mjs +156 -0
  47. package/scripts/route-index-memory.mjs +51 -0
  48. package/scripts/route-latency-warm.mjs +123 -0
  49. package/scripts/wired-check.mjs +17 -3
package/README.md CHANGED
@@ -7,7 +7,7 @@ Created: 2026-06-29 22:36:38 EDT
7
7
 
8
8
  # 🧠 RuvNet Brain
9
9
 
10
- ### 🧠 RuvNet Brain β€” [![RuvNet Brain version 4.3.40 β€” updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.3.40-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
10
+ ### 🧠 RuvNet Brain β€” [![RuvNet Brain version 4.4.1 β€” updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.4.1-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
11
11
 
12
12
  **A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack β€” delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
13
13
 
@@ -562,7 +562,7 @@ node forge-ask-all.mjs --dir . --q "How does RuVector implement HNSW vector sear
562
562
 
563
563
  This project versions in the open (see the live badge up top for the exact plugin version; the downloadable knowledge bundle is a separate track) β€” we don't claim β€œdone,” β€œcomplete,” or β€œzero hallucinations.” Where it stands:
564
564
 
565
- - βœ… **The grounding brain is real and proven** β€” 199 public stores Β· 161,365 public source chunks, dual embeddings, cross-encoder rerank, plugin (MCP tool + explicit skills; automatic hooks retired), all re-runnable.
565
+ - βœ… **The grounding brain is real and proven** β€” 199 public stores Β· 161,371 public source chunks, dual embeddings, cross-encoder rerank, plugin (MCP tool + explicit skills; automatic hooks retired), all re-runnable.
566
566
  - βœ… **Code-level depth** β€” the code-rich repos are indexed to full function bodies; β€œhow is it implemented?” returns the implementation. Verified in the shipped bundle (clean-room 3/3).
567
567
  - βœ… **Routing holds** β€” named 47/48, described 26/28, scenario 7/8; behavioral L1–L3 all pass (**L4 downgraded β€” it measures that the brain spoke, not that anything listened**); private stores fenced out of the public bundle (zero-leak verified).
568
568
  - ⚠️ **Two routing residuals** (above) β€” surfaced, not hidden.
package/bin/install.mjs CHANGED
@@ -21,9 +21,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
21
21
  import readline from 'node:readline';
22
22
  import crypto from 'node:crypto';
23
23
  import { applyBrainProfile, readBrainProfile } from '../kb/brain-profile.mjs';
24
- import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, recordRefreshAdvisory,
24
+ import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, physicalPath, recordRefreshAdvisory,
25
25
  recordRefreshPhase, settleRefreshRun, UPDATE_REFRESH_PHASES } from '../kb/refresh-run.mjs';
26
26
  import { pruneLifecycleEvidence } from '../kb/lifecycle-evidence-retention.mjs';
27
+ import { recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
27
28
  import {
28
29
  requiredEmbedderModels,
29
30
  missingEmbedderModels,
@@ -70,6 +71,10 @@ import {
70
71
  CONSOLE_RUNTIME_SURFACE, CONSOLE_RUNTIME_IDENTITY_FILE, consoleRuntimeDigest,
71
72
  } from '../scripts/console-runtime-identity.mjs';
72
73
  import { shellDiff as pluginShellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
74
+ import { readConsoleReceipts, replaceStaleConsoles } from '../scripts/console-instances.mjs';
75
+ import { cleanLegacyRufloDebris } from '../plugin/scripts/project-progression-store.mjs';
76
+ import { resolveProjectStore } from '../plugin/scripts/project-store-resolver.mjs';
77
+ import { runHostCli, waitForHostCli } from '../scripts/host-cli.mjs';
73
78
  import {
74
79
  writeInstalledRuntimeIdentity, recordCorpusTransportIdentity, isCorpusReleaseTag, rejectedReleasePath,
75
80
  } from '../kb/corpus-release-identity.mjs';
@@ -228,6 +233,13 @@ function tryRun(cmd, args, opts = {}) {
228
233
  const r = spawnSync(cmd, args, { stdio: 'inherit', shell: IS_WIN, ...opts });
229
234
  return !r.error && r.status === 0;
230
235
  }
236
+ // The claude/codex CLIs update themselves and can be absent for seconds (scripts/host-cli.mjs):
237
+ // retried with a bounded backoff, then ONE clear line instead of raw shell errors.
238
+ function tryHostCli(cmd, args, opts = {}) {
239
+ const r = runHostCli(cmd, args, opts);
240
+ if (r.missingBinary) warn(r.message);
241
+ return !r.missingBinary && !r.error && r.status === 0;
242
+ }
231
243
 
232
244
  // ── download with redirect-following + progress ──────────────────────────────────────────────────
233
245
  function download(url, dest, redirects = 0) {
@@ -1271,24 +1283,57 @@ export function installConsoleRuntime(cacheDir, sourceRoot = REPO_ROOT) {
1271
1283
  }
1272
1284
  }
1273
1285
 
1286
+ // Receipts of Consoles that died (pid gone, port silent) are pruned, not counted: the owner's Mac
1287
+ // reported pending-console-restart forever from two receipts left on 2026-09-16/17
1288
+ // (scripts/console-instances.mjs).
1289
+ /**
1290
+ * --doctor's view of a recorded convergence receipt. Its Console state is a snapshot from the last sync;
1291
+ * a recorded pending-console-restart is re-read against the LIVE receipts, so a Console that has since
1292
+ * exited (or a receipt it left when it died) stops failing --doctor. Returns a new object.
1293
+ */
1294
+ export function withLiveConsoleState(recorded, { receiptDir, alive, probe } = {}) {
1295
+ if (recorded?.consoleRuntime?.state !== 'pending-console-restart' || !recorded.consoleRuntime.sourceSha256) return recorded;
1296
+ const { replacementFailures, ...kept } = recorded.consoleRuntime;
1297
+ const live = { ...kept, ...consoleRestartState(recorded.consoleRuntime, { receiptDir, alive, probe }) };
1298
+ if (live.state !== 'ready' && replacementFailures) live.replacementFailures = replacementFailures;
1299
+ return { ...recorded, consoleRuntime: live };
1300
+ }
1301
+
1302
+ /**
1303
+ * 4.3.40's ruflo leftovers inside this project's `.swarm` (cleanLegacyRufloDebris). --update removes
1304
+ * them; --doctor only reports (dryRun). Either way a REFUSED artifact (unexpected contents, a symlink) is
1305
+ * printed, never dropped silently. Not a project (or no .swarm): nothing to say.
1306
+ */
1307
+ export function reportLegacyRufloDebris({ projectDir = process.cwd(), dryRun = false } = {}) {
1308
+ let storeDir;
1309
+ try { storeDir = path.dirname(resolveProjectStore({ projectDir }).canonicalAgentDbPath); } catch { return null; }
1310
+ if (!fs.existsSync(storeDir)) return null;
1311
+ const result = cleanLegacyRufloDebris(storeDir, { dryRun });
1312
+ for (const entry of result.removed) {
1313
+ // Dry run applies the same checks (allowlist, mirror proof, in-use window); only the final
1314
+ // unchanged-since-proof comparison can still keep it at --update time.
1315
+ if (dryRun) info(`legacy ruflo debris from 4.3.40 in ${entry}: passes every check; --update removes it unless it is written to before then`);
1316
+ else ok(`removed legacy ruflo debris from 4.3.40: ${entry}`);
1317
+ }
1318
+ for (const { path: entry, reason, kept } of result.refused) {
1319
+ // A KEPT nested AgentDB holds rows (or may still be written): never suggest deleting it by hand.
1320
+ if (kept) warn(`left 4.3.40's nested ruflo store in place β€” ${entry}: ${reason}. It is checked again on every update.`);
1321
+ else warn(`left legacy ruflo debris in place β€” ${entry}: ${reason}. Inspect it; remove it yourself if it is ruflo's.`);
1322
+ }
1323
+ return result;
1324
+ }
1325
+
1274
1326
  export function consoleRestartState(identity, {
1275
1327
  receiptDir = path.join(process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'console-instances'),
1328
+ alive, probe,
1276
1329
  } = {}) {
1277
- let receipts = [];
1278
- try {
1279
- receipts = fs.readdirSync(receiptDir)
1280
- .filter((name) => name.endsWith('.json'))
1281
- .map((name) => {
1282
- try { return JSON.parse(fs.readFileSync(path.join(receiptDir, name), 'utf8')); }
1283
- catch { return null; }
1284
- })
1285
- .filter((receipt) => receipt?.product === 'ruvnet-brain-console' && receipt.schema === 1);
1286
- } catch { /* no running Console receipts is the ordinary ready state */ }
1287
- const staleInstances = receipts.filter((receipt) => receipt.sourceSha256 !== identity.sourceSha256).length;
1330
+ const { live, pruned } = readConsoleReceipts(receiptDir, { ...(alive ? { alive } : {}), ...(probe ? { probe } : {}) });
1331
+ const staleInstances = live.filter(({ receipt }) => receipt.sourceSha256 !== identity.sourceSha256).length;
1288
1332
  return {
1289
1333
  state: staleInstances > 0 ? 'pending-console-restart' : 'ready',
1290
- instanceReceipts: receipts.length,
1334
+ instanceReceipts: live.length,
1291
1335
  staleInstances,
1336
+ ...(pruned.length ? { prunedDeadReceipts: pruned.length } : {}),
1292
1337
  };
1293
1338
  }
1294
1339
 
@@ -1406,7 +1451,9 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1406
1451
  const manualMarketplace = `claude plugin marketplace add ${marketplaceSource}`;
1407
1452
  const manualInstall = 'claude plugin install ruvnet-brain@ruvnet-brain --scope user';
1408
1453
 
1409
- if (!have('claude')) {
1454
+ const claudeCli = waitForHostCli('claude');
1455
+ if (!claudeCli.present) {
1456
+ if (claudeCli.message) warn(claudeCli.message);
1410
1457
  warn(`I couldn't run the \`claude\` command from this shell.`);
1411
1458
  info(`That's normal if you use Claude Code as the ${c.bold('VS Code extension')} or ${c.bold('desktop app')} β€” the`);
1412
1459
  info(`command just isn't on your terminal's PATH. ${c.green('The brain itself is fully downloaded.')}`);
@@ -1423,15 +1470,15 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1423
1470
  ? inspectPluginShellBoundary(before.installPath)
1424
1471
  : { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
1425
1472
  const addedMarket = before.managed
1426
- ? tryRun('claude', ['plugin', 'marketplace', 'update', 'ruvnet-brain'])
1427
- : tryRun('claude', ['plugin', 'marketplace', 'add', marketplaceSource]);
1473
+ ? tryHostCli('claude', ['plugin', 'marketplace', 'update', 'ruvnet-brain'])
1474
+ : tryHostCli('claude', ['plugin', 'marketplace', 'add', marketplaceSource]);
1428
1475
  // Deliberately NOT reassuring here. This used to say "it may already be added β€” that's fine",
1429
1476
  // which is a GUESS about someone else's machine, and when it was wrong the user finished the
1430
1477
  // install with a working search_ruvnet, no slash commands, and a message telling them all was
1431
1478
  // well. The real state is checked below; nothing is declared fine until it has been looked at.
1432
1479
  if (!addedMarket) info(`marketplace add didn't report success β€” checking what actually landed…`);
1433
1480
 
1434
- tryRun('claude', before.installed
1481
+ tryHostCli('claude', before.installed
1435
1482
  ? ['plugin', 'update', 'ruvnet-brain@ruvnet-brain', '--scope', 'user']
1436
1483
  : ['plugin', 'install', 'ruvnet-brain@ruvnet-brain', '--scope', 'user']);
1437
1484
 
@@ -1442,17 +1489,22 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1442
1489
  const installedHookRetirement = claudeInstalledHookRetirementStatus({ plugin: installed });
1443
1490
  if (installed.installed && versionSatisfies(installed.version, expectedVersion) && installedHookRetirement.ok) {
1444
1491
  ok(`plugin installed at user scope (global, alongside Ruflo / RuVector) β€” exact version ${installed.version}`);
1445
- if (shellBoundary.restartRequired) {
1446
- warn(`boot-level plugin declarations changed; restart Claude Code once to load them (${shellBoundary.paths.join(', ') || shellBoundary.reason}).`);
1492
+ if (shellBoundary.restartRequired && shellBoundary.known) {
1493
+ info(` boot-level plugin declarations changed (${shellBoundary.paths.join(', ')}): new Claude Code sessions load them; already-open windows keep the old hook definitions until they are reopened.`);
1494
+ } else if (shellBoundary.restartRequired) {
1495
+ warn(`could not verify the plugin's boot-level declarations (${shellBoundary.reason}); restart Claude Code once to be sure they are loaded.`);
1447
1496
  } else if (before.installed && before.version !== installed.version) {
1448
1497
  info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
1449
1498
  }
1450
- info(` commands available${shellBoundary.restartRequired ? ' after a restart' : ' immediately'}: ${c.bold('/rvbc')}, ${c.bold('/ruvnet-brain:configure')}`);
1499
+ info(` commands available${shellBoundary.restartRequired ? ' in new sessions' : ' immediately'}: ${c.bold('/rvbc')}, ${c.bold('/ruvnet-brain:configure')}`);
1451
1500
  return {
1452
1501
  host: true, wired: true, version: installed.version, manualMarketplace, manualInstall,
1453
1502
  shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
1454
1503
  restartRequired: shellBoundary.restartRequired,
1455
- ...(shellBoundary.restartRequired ? { sessionSafety: 'restart-required', sessionSafetyReason: shellBoundary.reason } : {}),
1504
+ // 'open-sessions': the new declarations are installed and PROVEN changed β€” only sessions already
1505
+ // open booted the old ones. 'unproven': the boot surface could not be compared at all.
1506
+ ...(shellBoundary.restartRequired ? { sessionSafety: 'restart-required', sessionSafetyReason: shellBoundary.reason,
1507
+ restartScope: shellBoundary.known ? 'open-sessions' : 'unproven' } : {}),
1456
1508
  };
1457
1509
  }
1458
1510
 
@@ -1856,13 +1908,16 @@ function runCodexJson(args, {
1856
1908
  codexHome = codexHomeDir(),
1857
1909
  cwd = process.cwd(),
1858
1910
  } = {}) {
1859
- const r = spawnSync(codexBin, args, {
1911
+ const r = runHostCli(codexBin, args, {
1912
+ stdio: 'pipe',
1913
+ shell: false,
1860
1914
  cwd,
1861
1915
  env: { ...process.env, CODEX_HOME: codexHome },
1862
1916
  encoding: 'utf8',
1863
1917
  timeout: 30_000,
1864
1918
  maxBuffer: 20 * 1024 * 1024,
1865
1919
  });
1920
+ if (r.missingBinary) return { ok: false, error: r.message };
1866
1921
  if (r.error || r.status !== 0) {
1867
1922
  const detail = String(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim();
1868
1923
  return { ok: false, error: detail };
@@ -1978,7 +2033,7 @@ export function wireCodexPlugin({
1978
2033
  if (announce) {
1979
2034
  ok(`Codex Brain plugin installed and enabled (${after.version || 'version unknown'}).`);
1980
2035
  if (shellBoundary.restartRequired) {
1981
- warn(`boot-level plugin declarations changed; restart Codex once to load them (${shellBoundary.paths.join(', ') || shellBoundary.reason}).`);
2036
+ warn(`boot-level plugin declarations changed; restart Codex, then review them in /hooks (${shellBoundary.paths.join(', ') || shellBoundary.reason}).`);
1982
2037
  } else if (before.installed && before.version !== after.version) {
1983
2038
  info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
1984
2039
  }
@@ -1993,6 +2048,10 @@ export function wireCodexPlugin({
1993
2048
  ...(shellBoundary.restartRequired ? {
1994
2049
  sessionSafety: 'restart-required',
1995
2050
  sessionSafetyReason: shellBoundary.reason,
2051
+ // Never 'open-sessions' for Codex: changed hook definitions show as PENDING until reviewed in
2052
+ // /hooks (doctor fails closed on pending trust; the SessionStart notice says to trust them), and no
2053
+ // measurement shows a fresh Codex session running them without that step. Unproven = restart.
2054
+ restartScope: 'unproven',
1996
2055
  } : {}),
1997
2056
  };
1998
2057
  }
@@ -2754,8 +2813,13 @@ async function doctor() {
2754
2813
  let hostConvergence = { healthy: true, state: 'not-recorded' };
2755
2814
  if (fs.existsSync(convergencePath)) {
2756
2815
  try {
2757
- hostConvergence = classifyHostConvergence(JSON.parse(fs.readFileSync(convergencePath, 'utf8')));
2758
- if (hostConvergence.healthy) ok(`host convergence receipt: ${hostConvergence.state}`);
2816
+ const recorded = withLiveConsoleState(JSON.parse(fs.readFileSync(convergencePath, 'utf8')),
2817
+ { receiptDir: path.join(path.dirname(convergencePath), 'console-instances') });
2818
+ hostConvergence = classifyHostConvergence(recorded);
2819
+ if (hostConvergence.healthy) {
2820
+ ok(`host convergence receipt: ${hostConvergence.state}`);
2821
+ if (hostConvergence.notice) info(hostConvergence.notice);
2822
+ }
2759
2823
  else {
2760
2824
  warn(`host convergence incomplete: ${hostConvergence.state}`);
2761
2825
  info(`Retry the same generation: ${c.bold('npx ruvnet-brain --update')}${hostConvergence.action ? `; ${hostConvergence.action}` : ''}`);
@@ -2765,6 +2829,7 @@ async function doctor() {
2765
2829
  warn(`host convergence receipt is invalid: ${error.message}`);
2766
2830
  }
2767
2831
  }
2832
+ try { reportLegacyRufloDebris({ dryRun: true }); } catch (error) { warn(`legacy ruflo debris check failed: ${error.message}`); }
2768
2833
  const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(cacheDir);
2769
2834
  const nightlyHealth = schedulerStatus({ platform: process.platform, env: process.env,
2770
2835
  brainHome, kbDir: cacheDir });
@@ -3274,6 +3339,18 @@ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = n
3274
3339
  if (!requireResult) return { verdict: 'legacy-success', fallback: false, exitCode: 0 };
3275
3340
  return { verdict: 'invalid-result', fallback: false, exitCode: 1 };
3276
3341
  }
3342
+ // The updater refused BECAUSE full-KB copies already sit beside the brain ("refusing to create another
3343
+ // full-KB copy"). A fresh install is exactly another full copy (it preserves the prior generation), so
3344
+ // the fallback would turn the refusal into +1 copy per run (measured: 2 -> 3, +1.3 GB). Report instead.
3345
+ if (/^unresolved rollback state exists/.test(String(result?.reason || ''))) {
3346
+ return { verdict: 'refused-retained-copies', fallback: false, exitCode: status || 1 };
3347
+ }
3348
+ // Exit 2 is "manifest unreachable, nothing touched". The fallback exists for a DEAD manifest URL (an old
3349
+ // bundle polling a path that 404s); a rate limit, a 5xx or no network is transient, and a full fresh
3350
+ // reinstall over it re-downloads the brain and preserves another full copy each time. Retry later instead.
3351
+ if (status === 2 && /returned HTTP (?:403|408|429|5\d\d)\b|network failure/.test(String(result?.reason || ''))) {
3352
+ return { verdict: 'transient-network', fallback: false, exitCode: 2 };
3353
+ }
3277
3354
  return { verdict: 'failed', fallback: fallbackAllowed, exitCode: status || 1 };
3278
3355
  }
3279
3356
 
@@ -3370,6 +3447,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3370
3447
  wireCodexHost: detectCodexHost = wireCodexHost,
3371
3448
  wireCodexPlugin: installCodexPlugin = wireCodexPlugin,
3372
3449
  hostLockPath = path.join(brainHome, 'host-convergence.lock'),
3450
+ replaceConsoles = null, // test seam; production runs replaceStaleConsoles
3373
3451
  runStableSpine = (apply) => spawnSync(
3374
3452
  process.execPath,
3375
3453
  [apply, '--auto', '--expected-version', PACKAGE_VERSION],
@@ -3425,6 +3503,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3425
3503
  codexReceipt.restartRequired = true;
3426
3504
  codexReceipt.sessionSafety = results.codex.sessionSafety || null;
3427
3505
  codexReceipt.sessionSafetyReason = results.codex.sessionSafetyReason || null;
3506
+ codexReceipt.restartScope = results.codex.restartScope || 'unproven';
3428
3507
  }
3429
3508
  const claudeReceipt = {
3430
3509
  state: results.claude.host ? 'ready' : 'absent',
@@ -3434,6 +3513,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3434
3513
  claudeReceipt.restartRequired = true;
3435
3514
  claudeReceipt.sessionSafety = results.claude.sessionSafety || null;
3436
3515
  claudeReceipt.sessionSafetyReason = results.claude.sessionSafetyReason || null;
3516
+ claudeReceipt.restartScope = results.claude.restartScope || 'unproven';
3437
3517
  }
3438
3518
  if (okApplied) {
3439
3519
  // ISSUE #153 β€” a running host may freeze an old plugin root. Reclaim only generations whose
@@ -3455,12 +3535,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3455
3535
  warn(`stale plugin generations were not pruned (${e.message}); nothing was removed`);
3456
3536
  }
3457
3537
  const receiptPath = path.join(brainHome, 'host-convergence.json');
3458
- try {
3459
- runtimeTransaction.activate();
3460
- results.consoleRuntime = {
3461
- ...runtimeTransaction.identity,
3462
- ...consoleRestartState(runtimeTransaction.identity, { receiptDir: consoleReceiptDir }),
3463
- };
3538
+ const writeConvergenceReceipt = () => {
3464
3539
  fs.mkdirSync(brainHome, { recursive: true });
3465
3540
  const tmp = `${receiptPath}.tmp-${process.pid}`;
3466
3541
  fs.writeFileSync(tmp, JSON.stringify({
@@ -3473,10 +3548,39 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3473
3548
  consoleRuntime: results.consoleRuntime,
3474
3549
  }, null, 2));
3475
3550
  fs.renameSync(tmp, receiptPath);
3551
+ };
3552
+ try {
3553
+ runtimeTransaction.activate();
3554
+ results.consoleRuntime = {
3555
+ ...runtimeTransaction.identity,
3556
+ ...consoleRestartState(runtimeTransaction.identity, { receiptDir: consoleReceiptDir }),
3557
+ };
3558
+ writeConvergenceReceipt();
3476
3559
  runtimeTransaction.commit();
3477
3560
  } catch (error) {
3478
3561
  return fail({ applyStatus: applied.status, error: `Console runtime convergence failed: ${error.message}` });
3479
3562
  }
3563
+ // A Console still serving the previous runtime is replaced here, by the activated runtime's own
3564
+ // launcher (the 'stale-running' path of scripts/onboarding-console.mjs, without --open), so an
3565
+ // update never ends with "restart Console". Anything it could not replace is recorded with why.
3566
+ if (results.consoleRuntime.state === 'pending-console-restart') {
3567
+ try {
3568
+ const replaceArgs = { entry: runtimeTransaction.entry, identity: runtimeTransaction.identity, receiptDir: consoleReceiptDir };
3569
+ results.consoleReplacement = replaceConsoles ? replaceConsoles(replaceArgs) : replaceStaleConsoles(replaceArgs);
3570
+ const failures = results.consoleReplacement.filter((item) => !item.replaced);
3571
+ for (const item of results.consoleReplacement) {
3572
+ if (item.replaced) ok(`Console on port ${item.port} replaced with the current runtime (pid ${item.newPid})`);
3573
+ }
3574
+ results.consoleRuntime = {
3575
+ ...runtimeTransaction.identity,
3576
+ ...consoleRestartState(runtimeTransaction.identity, { receiptDir: consoleReceiptDir }),
3577
+ ...(failures.length ? { replacementFailures: failures.map((item) => `port ${item.port ?? '?'}: ${item.reason}`) } : {}),
3578
+ };
3579
+ writeConvergenceReceipt();
3580
+ } catch (error) {
3581
+ warn(`could not replace the running Console automatically (${error.message})`);
3582
+ }
3583
+ }
3480
3584
  }
3481
3585
  if (!okApplied) return fail({ applyStatus: applied.status, error: applied.error?.message || 'Stable Spine activation failed' });
3482
3586
  const convergence = classifyHostConvergence({
@@ -3500,19 +3604,36 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
3500
3604
  return { healthy: false, state: 'version-mismatch', action: `required version ${expectedVersion}` };
3501
3605
  }
3502
3606
  const hostStates = Object.values(receipt.hosts || {});
3503
- const badHost = hostStates.find((host) => !['ready', 'disabled', 'absent'].includes(host?.state)
3607
+ // A host that is ready at the expected version and whose ONLY gap is PROVEN changed boot-level
3608
+ // declarations is converged: new sessions load the new hooks; only windows already open booted the
3609
+ // old ones (owner's Mac, 4.3.40 -> 4.4.0: a fresh `claude -p` loaded 4.4.0 and ran its hooks). That is
3610
+ // reported, not failed. An UNPROVEN boot surface (it could not be compared) still requires a restart.
3611
+ const openSessionsOnly = (host) => host?.state === 'ready' && versionSatisfies(host.version, expectedVersion)
3612
+ && host.restartRequired === true && host.restartScope === 'open-sessions';
3613
+ const openSessions = Object.entries(receipt.hosts || {}).filter(([, host]) => openSessionsOnly(host)).map(([name]) => name);
3614
+ const badHost = hostStates.find((host) => !openSessionsOnly(host) && (!['ready', 'disabled', 'absent'].includes(host?.state)
3504
3615
  || (host.state === 'ready' && !versionSatisfies(host.version, expectedVersion))
3505
- || (host.state === 'ready' && host.restartRequired === true));
3616
+ || (host.state === 'ready' && host.restartRequired === true)));
3506
3617
  if (badHost?.restartRequired === true) {
3507
3618
  return { healthy: false, state: 'host-restart-required', action: badHost.sessionSafetyReason || 'restart the host, then re-run --doctor' };
3508
3619
  }
3509
3620
  if (badHost) return { healthy: false, state: 'host-pending', action: 're-run host synchronization' };
3510
3621
  if (receipt.consoleRuntime?.state !== 'ready') {
3511
- return { healthy: false, state: receipt.consoleRuntime?.state || 'console-unproven', action: 'restart Console, then re-run --doctor' };
3622
+ const why = Array.isArray(receipt.consoleRuntime?.replacementFailures) && receipt.consoleRuntime.replacementFailures.length
3623
+ ? `the installer could not replace the running Console (${receipt.consoleRuntime.replacementFailures.join('; ')}); ` : '';
3624
+ return { healthy: false, state: receipt.consoleRuntime?.state || 'console-unproven', action: `${why}restart Console, then re-run --doctor` };
3512
3625
  }
3626
+ if (openSessions.length) return { healthy: true, state: 'channels-converged', openSessions, notice: openSessionsNotice(openSessions, receipt.desiredVersion) };
3513
3627
  return { healthy: true, state: 'channels-converged' };
3514
3628
  }
3515
3629
 
3630
+ const HOST_LABELS = { claude: 'Claude Code', codex: 'Codex' };
3631
+ /** The one accurate line for converged hosts whose already-open windows booted the old declarations. */
3632
+ export function openSessionsNotice(hosts, version) {
3633
+ const names = hosts.map((host) => HOST_LABELS[host] || host).join('/');
3634
+ return `new ${names} sessions use ${version}; already-open windows keep the old hook definitions until they are reopened`;
3635
+ }
3636
+
3516
3637
  async function runUpdate() {
3517
3638
  printBanner('update');
3518
3639
  const kbDir = resolvedKbDir();
@@ -3525,6 +3646,7 @@ async function runUpdate() {
3525
3646
  process.exitCode = 1;
3526
3647
  return;
3527
3648
  }
3649
+ if (convergence.convergence?.notice) info(convergence.convergence.notice);
3528
3650
  try {
3529
3651
  const managed = applyManagedCatalogUpdate({
3530
3652
  routerDir: path.join(os.homedir(), '.claude', 'model-router'),
@@ -3587,6 +3709,21 @@ async function runUpdate() {
3587
3709
  };
3588
3710
  process.once('exit', exitGuard);
3589
3711
  info(`brain dir: ${c.bold(kbDir)}`);
3712
+ // RECOVER AN INTERRUPTED UPDATE FIRST, before anything below writes into the brain. Two measured
3713
+ // reasons: (1) a kill between the two directory renames leaves no usable kb/ β€” the brain sits in a
3714
+ // receipted kb.rollback-<id>, and the recovery that renames it back lives inside the updater that is
3715
+ // now missing; (2) the preflight below re-stamps RUNTIME-IDENTITY.json into kb/, after which recovery
3716
+ // can never prove kb/ still equals the identity sealed at LOCKED, so any pre-activation kill wedged
3717
+ // every later update at RECOVERY_REQUIRED. Same function the updater runs, under this refresh lock.
3718
+ // Receipts hold the REAL paths the updater knew, so recovery is addressed by the physical path.
3719
+ if (fs.existsSync(path.join(path.dirname(kbDir), `.${path.basename(kbDir)}.update-transactions`))) {
3720
+ try {
3721
+ const recovered = recoverIncompleteStorageTransactions(physicalPath(kbDir));
3722
+ if (recovered.length) ok(`restored the brain from an interrupted update (${recovered.map((r) => `${r.transactionId}: ${r.from}`).join(', ')})`);
3723
+ } catch (error) {
3724
+ warn(`an interrupted update could not be recovered automatically: ${error.message}`);
3725
+ }
3726
+ }
3590
3727
  let updateStatus = 1;
3591
3728
  // NO updater at all = no brain installed here (or a pre-self-updater bundle). That is a USER
3592
3729
  // message, not a fallback trigger: fail LOUD with the re-run-installer help and exit β€” never
@@ -3645,6 +3782,10 @@ async function runUpdate() {
3645
3782
  result: updaterResult,
3646
3783
  requireResult: supportsResultReceipt,
3647
3784
  });
3785
+ if (outcome.verdict === 'refused-retained-copies') {
3786
+ warn('nothing was changed: full copies of earlier brain generations already sit beside this one (listed above).');
3787
+ info('Check that you no longer need them, remove them, then re-run npx ruvnet-brain --update');
3788
+ }
3648
3789
  if (outcome.fallback && FLAG_HOST_SYNC_ONLY) {
3649
3790
  // Host synchronization has a narrower contract than a full update: it must converge the
3650
3791
  // executable plugin/spine to the published package even when an optional large KB asset is
@@ -3742,9 +3883,11 @@ async function runUpdate() {
3742
3883
  if (!convergence.ok) {
3743
3884
  warn(`host synchronization is incomplete β€” runtime stays on the prior verified generation${convergence.error ? ` (${convergence.error})` : ''}`);
3744
3885
  updateStatus = 1;
3745
- }
3886
+ } else if (convergence.convergence?.notice) info(convergence.convergence.notice);
3887
+ try { reportLegacyRufloDebris(); } catch (error) { warn(`legacy ruflo debris cleanup failed: ${error.message}`); }
3746
3888
  recordRefreshPhase(refreshReceipt, 'host-convergence', convergence.ok && convergence.convergence?.healthy === true ? 'PASS' : 'FAIL', {
3747
3889
  state: convergence.convergence?.state || null, error: convergence.error || null,
3890
+ ...(convergence.convergence?.openSessions ? { openSessions: convergence.convergence.openSessions } : {}),
3748
3891
  execution: { kind: 'executed', runId: refreshReceipt.runId },
3749
3892
  });
3750
3893
  }
@@ -4951,7 +5094,7 @@ async function offerStack(env) {
4951
5094
  for (const m of missing) {
4952
5095
  if (m.shell) {
4953
5096
  info(`installing ${m.what} … ${c.dim(m.say)}`);
4954
- const ran = tryRun(m.shell[0], m.shell[1]);
5097
+ const ran = ['claude', 'codex'].includes(m.shell[0]) ? tryHostCli(m.shell[0], m.shell[1]) : tryRun(m.shell[0], m.shell[1]);
4955
5098
  // Don't trust the exit code alone β€” e.g. `claude mcp add` exits non-zero on "already exists",
4956
5099
  // which is functionally success. Re-check the real state (m.verify) before warning.
4957
5100
  if (ran || (m.verify && m.verify())) ok(`${m.what} added`);
@@ -61,16 +61,31 @@ function profileOwnership(dir) {
61
61
  ? Object.entries(source.stores) : null;
62
62
  if (!entries) throw new Error('invalid SOURCE ownership policy');
63
63
  const privateNames = new Set(fence.privateStores.map((s) => s.toLowerCase()));
64
+ // Published store names include dots (`dspy.ts`, `ruv.io`). A name may not start with a dot, hold `..`
65
+ // or a separator, or end in an artifact suffix (`x.big` would collide with store x's `x.big.rvf`).
66
+ const safeName = (name) => typeof name === 'string' && /^[a-z0-9][a-z0-9._-]*$/i.test(name) && !name.includes('..')
67
+ && !/\.(?:big|rvf)$/i.test(name);
64
68
  const managed = new Set();
69
+ const optedOut = new Set();
65
70
  const seen = new Set();
66
71
  for (const [name, value] of entries) {
67
- if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9_-]*$/i.test(name)
72
+ if (!safeName(name)
68
73
  || !value || typeof value !== 'object' || Array.isArray(value)
69
74
  || (value.kbName != null && value.kbName !== name)
70
75
  || (value.updateManaged != null && typeof value.updateManaged !== 'boolean')
71
76
  || seen.has(name.toLowerCase())) throw new Error('invalid SOURCE store ownership');
72
77
  seen.add(name.toLowerCase());
73
- if (value.updateManaged !== false && !privateNames.has(name.toLowerCase())) managed.add(name);
78
+ if (value.updateManaged === false) optedOut.add(name.toLowerCase());
79
+ else if (!privateNames.has(name.toLowerCase())) managed.add(name);
80
+ }
81
+ // The release's PUBLIC ledger is the other ownership record: stores it publishes but SOURCE does not list
82
+ // (concepts, ruv-gists) are still release-owned, and forge-update's profiled check expects them gone.
83
+ const publicFile = path.join(dir, 'PUBLIC-RVF-GENERATIONS.json');
84
+ if (fs.existsSync(publicFile)) {
85
+ for (const name of Object.keys(read('PUBLIC-RVF-GENERATIONS.json').stores || {})) {
86
+ const key = String(name).toLowerCase();
87
+ if (safeName(name) && !privateNames.has(key) && !optedOut.has(key)) managed.add(name);
88
+ }
74
89
  }
75
90
  return { managed, read };
76
91
  }
@@ -650,7 +650,9 @@ export function restorePrivateOverlayState({ kbDir, overlay }) {
650
650
  const cardsFile = path.join(kbDir, 'capability-cards.md');
651
651
  const source = JSON.parse(fs.readFileSync(sourceFile, 'utf8'));
652
652
  const generations = JSON.parse(fs.readFileSync(generationsFile, 'utf8'));
653
- const aliases = JSON.parse(fs.readFileSync(aliasesFile, 'utf8'));
653
+ // A public bundle may ship no repo-aliases.json at all (build-bundle: "aliases will not resolve"); the
654
+ // private aliases then start from an empty map instead of failing the whole update on ENOENT.
655
+ const aliases = fs.existsSync(aliasesFile) ? JSON.parse(fs.readFileSync(aliasesFile, 'utf8')) : {};
654
656
  const mergedSource = mergePrivateEntries(source.stores, overlay.sourceStores, 'SOURCE.json');
655
657
  const mergedGenerations = mergePrivateEntries(generations.stores, overlay.generationStores, 'RVF-GENERATIONS.json');
656
658
  const mergedAliases = mergePrivateEntries(aliases, overlay.aliases, 'repo-aliases.json');
@@ -1640,7 +1642,9 @@ async function main() {
1640
1642
  if (!APPLY) {
1641
1643
  writeCheckOutcome({ currencyVerdict: verdict.verdict, currencyReason: verdict.reason,
1642
1644
  candidateKind: candidateIdentity.kind, storeCount: targets.length });
1643
- if (anyBehind) { console.log(`\nA newer build exists. Run: node forge-update.mjs --apply`); process.exit(10); }
1645
+ // The npx door upgrades this updater before applying; an old installed updater run directly can fail
1646
+ // the guard on a newer bundle (customer-state-matrix D8, 2026-09-30).
1647
+ if (anyBehind) { console.log(`\nA newer build exists. Run: npx ruvnet-brain@latest --update`); process.exit(10); }
1644
1648
  console.log(`\nAll stores current. Nothing to do.`); process.exit(0);
1645
1649
  }
1646
1650
 
@@ -1,6 +1,7 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { physicalPath } from './refresh-run.mjs';
4
5
 
5
6
  const TERMINAL_REFRESH = new Set(['SUCCEEDED', 'FAILED', 'ABANDONED']);
6
7
  const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
@@ -113,10 +114,11 @@ function newest(rows, predicate) {
113
114
 
114
115
  function trustedEvidenceRoot(root, unsafe) {
115
116
  try {
116
- for (const entry of [path.dirname(root), root]) {
117
- const stat = fs.lstatSync(entry);
118
- if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('evidence root is not a trusted directory (symbolic or special entry)');
119
- }
117
+ // The PARENT is where the user put the brain (`~/.cache/ruvnet-brain` may be a link to another disk):
118
+ // it must be a directory once resolved. The evidence root itself must not be a link.
119
+ if (!fs.statSync(physicalPath(path.dirname(root))).isDirectory()) throw new Error('evidence root parent is not a directory');
120
+ const stat = fs.lstatSync(root);
121
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('evidence root is not a trusted directory (symbolic or special entry)');
120
122
  return true;
121
123
  } catch (error) {
122
124
  if (error.code !== 'ENOENT') unsafe.push({ path: root, reason: error.message });
@@ -130,7 +132,7 @@ function snapshot(options) {
130
132
  const refresh = trustedEvidenceRoot(roots.refresh, unsafe) ? scanRefresh(roots.refresh, unsafe) : [];
131
133
  const transactions = trustedEvidenceRoot(roots.transactions, unsafe) ? scanTransactions(roots.transactions, unsafe) : [];
132
134
  const preserveRefresh = new Set((options.preserveRefreshRunIds || []).map(String));
133
- const preserveTransactions = new Set((options.preserveTransactionPaths || []).map((entry) => path.resolve(entry)));
135
+ const preserveTransactions = new Set((options.preserveTransactionPaths || []).map((entry) => physicalPath(entry)));
134
136
  const protectedRefresh = new Map();
135
137
  const protectRefresh = (row, reason) => { if (row) protectedRefresh.set(row.path, reason); };
136
138
  for (const row of refresh) {
@@ -147,17 +149,18 @@ function snapshot(options) {
147
149
  if (!protectedRefresh.has(row.path)) continue;
148
150
  for (const reference of transactionReferences(row.receipt)) {
149
151
  const resolved = path.resolve(String(reference || ''));
150
- const relative = path.relative(roots.transactions, resolved);
152
+ // The updater records real paths; the caller may spell the brain through a link. Same space, both sides.
153
+ const relative = path.relative(physicalPath(roots.transactions), physicalPath(resolved));
151
154
  if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || relative.includes(path.sep)) {
152
155
  unsafe.push({ path: row.path, reason: `forged external transaction reference: ${String(reference)}` });
153
- } else retainedTransactionPaths.add(resolved);
156
+ } else retainedTransactionPaths.add(physicalPath(resolved));
154
157
  }
155
158
  }
156
159
  const protectedTransactions = new Map();
157
160
  for (const row of transactions) {
158
161
  if (!TERMINAL_TRANSACTION.has(row.latest.state)) protectedTransactions.set(row.path, `nonterminal ${row.latest.state}`);
159
- if (preserveTransactions.has(row.path)) protectedTransactions.set(row.path, 'explicitly preserved transaction');
160
- if (retainedTransactionPaths.has(row.path)) protectedTransactions.set(row.path, 'referenced by retained refresh receipt');
162
+ if (preserveTransactions.has(physicalPath(row.path))) protectedTransactions.set(row.path, 'explicitly preserved transaction');
163
+ if (retainedTransactionPaths.has(physicalPath(row.path))) protectedTransactions.set(row.path, 'referenced by retained refresh receipt');
161
164
  }
162
165
  const bytes = [...refresh, ...transactions].reduce((sum, row) => sum + row.bytes, 0)
163
166
  + unsafe.filter(({ reason }) => /quarantine remains/.test(reason)).reduce((sum, { path: entry }) => {
@@ -78,6 +78,19 @@ export function inspectRefreshOwner(owner, { hostname = os.hostname(), inspectPr
78
78
  return ownerState(owner, { hostname, inspectProcess, isAlive });
79
79
  }
80
80
 
81
+ /**
82
+ * The ONE physical-path rule for brain directories. A user may reach the brain through a symlink
83
+ * (`~/.cache -> /Volumes/<disk>`) while the updater knows it by its real path (Node resolves
84
+ * import.meta.url), so identity checks compare this, never spellings. A directory that does not
85
+ * exist right now (kb/ mid-rename) resolves through its parent, so the answer does not flip.
86
+ */
87
+ export function physicalPath(dir) {
88
+ const resolved = path.resolve(String(dir || ''));
89
+ try { return fs.realpathSync.native(resolved); } catch { /* absent: resolve through the parent */ }
90
+ try { return path.join(fs.realpathSync.native(path.dirname(resolved)), path.basename(resolved)); }
91
+ catch { return resolved; }
92
+ }
93
+
81
94
  export const refreshLockPath = (kbDir) =>
82
95
  path.join(path.dirname(path.resolve(kbDir)), `.${path.basename(path.resolve(kbDir))}.refresh-run.lock`);
83
96
 
@@ -156,7 +169,10 @@ export function acquireRefreshLock({
156
169
  const inherited = String(env.RUVNET_REFRESH_RUN_TOKEN || '');
157
170
  if (inherited) {
158
171
  const owner = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
159
- if (owner.schemaVersion !== 3 || owner.token !== inherited || path.resolve(owner.kbDir) !== root
172
+ // Same DIRECTORY, not same spelling: the installer locks RUVNET_BRAIN_KB as given (possibly through
173
+ // a symlinked ~/.cache), the child updater knows its KB by import.meta.url, which Node resolves to
174
+ // the real path. Compare physical identity; a genuinely different directory still refuses.
175
+ if (owner.schemaVersion !== 3 || owner.token !== inherited || physicalPath(owner.kbDir) !== physicalPath(root)
160
176
  || path.resolve(owner.receiptPath || '') !== refreshReceiptPath(owner.brainHome, owner.runId)) {
161
177
  throw new Error(`refresh lock inheritance does not match ${lockPath}`);
162
178
  }