ruvnet-brain 4.3.40 β†’ 4.4.0

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 (48) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +111 -27
  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 +99 -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-sources.mjs +16 -4
  26. package/plugin/scripts/project-progression-store.mjs +49 -1
  27. package/plugin/scripts/protect-brain-state.sh +1 -0
  28. package/plugin/scripts/route-dispatch.sh +1 -0
  29. package/plugin/scripts/session-snapshot-hook.mjs +224 -38
  30. package/plugin/scripts/session-start-health.mjs +24 -3
  31. package/plugin/scripts/session-start-update-plane.mjs +1 -1
  32. package/plugin/scripts/update-apply.mjs +22 -2
  33. package/scripts/console-instances.mjs +145 -0
  34. package/scripts/console-runtime-identity.mjs +2 -0
  35. package/scripts/corpus-canary.mjs +130 -18
  36. package/scripts/customer-seams.mjs +84 -0
  37. package/scripts/customer-state-matrix.mjs +363 -0
  38. package/scripts/full-suite-gate.mjs +155 -0
  39. package/scripts/grounding-turn-replay.mjs +11 -3
  40. package/scripts/hook-qualify-core.mjs +346 -0
  41. package/scripts/hook-qualify-hosts.mjs +115 -0
  42. package/scripts/hook-qualify.mjs +101 -0
  43. package/scripts/host-cli.mjs +115 -0
  44. package/scripts/qe/agentic-qe-4.3.mjs +0 -1
  45. package/scripts/route-gold-rank.mjs +156 -0
  46. package/scripts/route-index-memory.mjs +51 -0
  47. package/scripts/route-latency-warm.mjs +123 -0
  48. 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.0 β€” updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.4.0-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,8 @@ 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 { runHostCli, waitForHostCli } from '../scripts/host-cli.mjs';
73
76
  import {
74
77
  writeInstalledRuntimeIdentity, recordCorpusTransportIdentity, isCorpusReleaseTag, rejectedReleasePath,
75
78
  } from '../kb/corpus-release-identity.mjs';
@@ -228,6 +231,13 @@ function tryRun(cmd, args, opts = {}) {
228
231
  const r = spawnSync(cmd, args, { stdio: 'inherit', shell: IS_WIN, ...opts });
229
232
  return !r.error && r.status === 0;
230
233
  }
234
+ // The claude/codex CLIs update themselves and can be absent for seconds (scripts/host-cli.mjs):
235
+ // retried with a bounded backoff, then ONE clear line instead of raw shell errors.
236
+ function tryHostCli(cmd, args, opts = {}) {
237
+ const r = runHostCli(cmd, args, opts);
238
+ if (r.missingBinary) warn(r.message);
239
+ return !r.missingBinary && !r.error && r.status === 0;
240
+ }
231
241
 
232
242
  // ── download with redirect-following + progress ──────────────────────────────────────────────────
233
243
  function download(url, dest, redirects = 0) {
@@ -1271,24 +1281,33 @@ export function installConsoleRuntime(cacheDir, sourceRoot = REPO_ROOT) {
1271
1281
  }
1272
1282
  }
1273
1283
 
1284
+ // Receipts of Consoles that died (pid gone, port silent) are pruned, not counted: the owner's Mac
1285
+ // reported pending-console-restart forever from two receipts left on 2026-09-16/17
1286
+ // (scripts/console-instances.mjs).
1287
+ /**
1288
+ * --doctor's view of a recorded convergence receipt. Its Console state is a snapshot from the last sync;
1289
+ * a recorded pending-console-restart is re-read against the LIVE receipts, so a Console that has since
1290
+ * exited (or a receipt it left when it died) stops failing --doctor. Returns a new object.
1291
+ */
1292
+ export function withLiveConsoleState(recorded, { receiptDir, alive, probe } = {}) {
1293
+ if (recorded?.consoleRuntime?.state !== 'pending-console-restart' || !recorded.consoleRuntime.sourceSha256) return recorded;
1294
+ const { replacementFailures, ...kept } = recorded.consoleRuntime;
1295
+ const live = { ...kept, ...consoleRestartState(recorded.consoleRuntime, { receiptDir, alive, probe }) };
1296
+ if (live.state !== 'ready' && replacementFailures) live.replacementFailures = replacementFailures;
1297
+ return { ...recorded, consoleRuntime: live };
1298
+ }
1299
+
1274
1300
  export function consoleRestartState(identity, {
1275
1301
  receiptDir = path.join(process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'console-instances'),
1302
+ alive, probe,
1276
1303
  } = {}) {
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;
1304
+ const { live, pruned } = readConsoleReceipts(receiptDir, { ...(alive ? { alive } : {}), ...(probe ? { probe } : {}) });
1305
+ const staleInstances = live.filter(({ receipt }) => receipt.sourceSha256 !== identity.sourceSha256).length;
1288
1306
  return {
1289
1307
  state: staleInstances > 0 ? 'pending-console-restart' : 'ready',
1290
- instanceReceipts: receipts.length,
1308
+ instanceReceipts: live.length,
1291
1309
  staleInstances,
1310
+ ...(pruned.length ? { prunedDeadReceipts: pruned.length } : {}),
1292
1311
  };
1293
1312
  }
1294
1313
 
@@ -1406,7 +1425,9 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1406
1425
  const manualMarketplace = `claude plugin marketplace add ${marketplaceSource}`;
1407
1426
  const manualInstall = 'claude plugin install ruvnet-brain@ruvnet-brain --scope user';
1408
1427
 
1409
- if (!have('claude')) {
1428
+ const claudeCli = waitForHostCli('claude');
1429
+ if (!claudeCli.present) {
1430
+ if (claudeCli.message) warn(claudeCli.message);
1410
1431
  warn(`I couldn't run the \`claude\` command from this shell.`);
1411
1432
  info(`That's normal if you use Claude Code as the ${c.bold('VS Code extension')} or ${c.bold('desktop app')} β€” the`);
1412
1433
  info(`command just isn't on your terminal's PATH. ${c.green('The brain itself is fully downloaded.')}`);
@@ -1423,15 +1444,15 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1423
1444
  ? inspectPluginShellBoundary(before.installPath)
1424
1445
  : { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
1425
1446
  const addedMarket = before.managed
1426
- ? tryRun('claude', ['plugin', 'marketplace', 'update', 'ruvnet-brain'])
1427
- : tryRun('claude', ['plugin', 'marketplace', 'add', marketplaceSource]);
1447
+ ? tryHostCli('claude', ['plugin', 'marketplace', 'update', 'ruvnet-brain'])
1448
+ : tryHostCli('claude', ['plugin', 'marketplace', 'add', marketplaceSource]);
1428
1449
  // Deliberately NOT reassuring here. This used to say "it may already be added β€” that's fine",
1429
1450
  // which is a GUESS about someone else's machine, and when it was wrong the user finished the
1430
1451
  // install with a working search_ruvnet, no slash commands, and a message telling them all was
1431
1452
  // well. The real state is checked below; nothing is declared fine until it has been looked at.
1432
1453
  if (!addedMarket) info(`marketplace add didn't report success β€” checking what actually landed…`);
1433
1454
 
1434
- tryRun('claude', before.installed
1455
+ tryHostCli('claude', before.installed
1435
1456
  ? ['plugin', 'update', 'ruvnet-brain@ruvnet-brain', '--scope', 'user']
1436
1457
  : ['plugin', 'install', 'ruvnet-brain@ruvnet-brain', '--scope', 'user']);
1437
1458
 
@@ -1856,13 +1877,16 @@ function runCodexJson(args, {
1856
1877
  codexHome = codexHomeDir(),
1857
1878
  cwd = process.cwd(),
1858
1879
  } = {}) {
1859
- const r = spawnSync(codexBin, args, {
1880
+ const r = runHostCli(codexBin, args, {
1881
+ stdio: 'pipe',
1882
+ shell: false,
1860
1883
  cwd,
1861
1884
  env: { ...process.env, CODEX_HOME: codexHome },
1862
1885
  encoding: 'utf8',
1863
1886
  timeout: 30_000,
1864
1887
  maxBuffer: 20 * 1024 * 1024,
1865
1888
  });
1889
+ if (r.missingBinary) return { ok: false, error: r.message };
1866
1890
  if (r.error || r.status !== 0) {
1867
1891
  const detail = String(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim();
1868
1892
  return { ok: false, error: detail };
@@ -2754,7 +2778,9 @@ async function doctor() {
2754
2778
  let hostConvergence = { healthy: true, state: 'not-recorded' };
2755
2779
  if (fs.existsSync(convergencePath)) {
2756
2780
  try {
2757
- hostConvergence = classifyHostConvergence(JSON.parse(fs.readFileSync(convergencePath, 'utf8')));
2781
+ const recorded = withLiveConsoleState(JSON.parse(fs.readFileSync(convergencePath, 'utf8')),
2782
+ { receiptDir: path.join(path.dirname(convergencePath), 'console-instances') });
2783
+ hostConvergence = classifyHostConvergence(recorded);
2758
2784
  if (hostConvergence.healthy) ok(`host convergence receipt: ${hostConvergence.state}`);
2759
2785
  else {
2760
2786
  warn(`host convergence incomplete: ${hostConvergence.state}`);
@@ -3274,6 +3300,18 @@ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = n
3274
3300
  if (!requireResult) return { verdict: 'legacy-success', fallback: false, exitCode: 0 };
3275
3301
  return { verdict: 'invalid-result', fallback: false, exitCode: 1 };
3276
3302
  }
3303
+ // The updater refused BECAUSE full-KB copies already sit beside the brain ("refusing to create another
3304
+ // full-KB copy"). A fresh install is exactly another full copy (it preserves the prior generation), so
3305
+ // the fallback would turn the refusal into +1 copy per run (measured: 2 -> 3, +1.3 GB). Report instead.
3306
+ if (/^unresolved rollback state exists/.test(String(result?.reason || ''))) {
3307
+ return { verdict: 'refused-retained-copies', fallback: false, exitCode: status || 1 };
3308
+ }
3309
+ // Exit 2 is "manifest unreachable, nothing touched". The fallback exists for a DEAD manifest URL (an old
3310
+ // bundle polling a path that 404s); a rate limit, a 5xx or no network is transient, and a full fresh
3311
+ // reinstall over it re-downloads the brain and preserves another full copy each time. Retry later instead.
3312
+ if (status === 2 && /returned HTTP (?:403|408|429|5\d\d)\b|network failure/.test(String(result?.reason || ''))) {
3313
+ return { verdict: 'transient-network', fallback: false, exitCode: 2 };
3314
+ }
3277
3315
  return { verdict: 'failed', fallback: fallbackAllowed, exitCode: status || 1 };
3278
3316
  }
3279
3317
 
@@ -3370,6 +3408,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3370
3408
  wireCodexHost: detectCodexHost = wireCodexHost,
3371
3409
  wireCodexPlugin: installCodexPlugin = wireCodexPlugin,
3372
3410
  hostLockPath = path.join(brainHome, 'host-convergence.lock'),
3411
+ replaceConsoles = null, // test seam; production runs replaceStaleConsoles
3373
3412
  runStableSpine = (apply) => spawnSync(
3374
3413
  process.execPath,
3375
3414
  [apply, '--auto', '--expected-version', PACKAGE_VERSION],
@@ -3455,12 +3494,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3455
3494
  warn(`stale plugin generations were not pruned (${e.message}); nothing was removed`);
3456
3495
  }
3457
3496
  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
- };
3497
+ const writeConvergenceReceipt = () => {
3464
3498
  fs.mkdirSync(brainHome, { recursive: true });
3465
3499
  const tmp = `${receiptPath}.tmp-${process.pid}`;
3466
3500
  fs.writeFileSync(tmp, JSON.stringify({
@@ -3473,10 +3507,39 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3473
3507
  consoleRuntime: results.consoleRuntime,
3474
3508
  }, null, 2));
3475
3509
  fs.renameSync(tmp, receiptPath);
3510
+ };
3511
+ try {
3512
+ runtimeTransaction.activate();
3513
+ results.consoleRuntime = {
3514
+ ...runtimeTransaction.identity,
3515
+ ...consoleRestartState(runtimeTransaction.identity, { receiptDir: consoleReceiptDir }),
3516
+ };
3517
+ writeConvergenceReceipt();
3476
3518
  runtimeTransaction.commit();
3477
3519
  } catch (error) {
3478
3520
  return fail({ applyStatus: applied.status, error: `Console runtime convergence failed: ${error.message}` });
3479
3521
  }
3522
+ // A Console still serving the previous runtime is replaced here, by the activated runtime's own
3523
+ // launcher (the 'stale-running' path of scripts/onboarding-console.mjs, without --open), so an
3524
+ // update never ends with "restart Console". Anything it could not replace is recorded with why.
3525
+ if (results.consoleRuntime.state === 'pending-console-restart') {
3526
+ try {
3527
+ const replaceArgs = { entry: runtimeTransaction.entry, identity: runtimeTransaction.identity, receiptDir: consoleReceiptDir };
3528
+ results.consoleReplacement = replaceConsoles ? replaceConsoles(replaceArgs) : replaceStaleConsoles(replaceArgs);
3529
+ const failures = results.consoleReplacement.filter((item) => !item.replaced);
3530
+ for (const item of results.consoleReplacement) {
3531
+ if (item.replaced) ok(`Console on port ${item.port} replaced with the current runtime (pid ${item.newPid})`);
3532
+ }
3533
+ results.consoleRuntime = {
3534
+ ...runtimeTransaction.identity,
3535
+ ...consoleRestartState(runtimeTransaction.identity, { receiptDir: consoleReceiptDir }),
3536
+ ...(failures.length ? { replacementFailures: failures.map((item) => `port ${item.port ?? '?'}: ${item.reason}`) } : {}),
3537
+ };
3538
+ writeConvergenceReceipt();
3539
+ } catch (error) {
3540
+ warn(`could not replace the running Console automatically (${error.message})`);
3541
+ }
3542
+ }
3480
3543
  }
3481
3544
  if (!okApplied) return fail({ applyStatus: applied.status, error: applied.error?.message || 'Stable Spine activation failed' });
3482
3545
  const convergence = classifyHostConvergence({
@@ -3508,7 +3571,9 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
3508
3571
  }
3509
3572
  if (badHost) return { healthy: false, state: 'host-pending', action: 're-run host synchronization' };
3510
3573
  if (receipt.consoleRuntime?.state !== 'ready') {
3511
- return { healthy: false, state: receipt.consoleRuntime?.state || 'console-unproven', action: 'restart Console, then re-run --doctor' };
3574
+ const why = Array.isArray(receipt.consoleRuntime?.replacementFailures) && receipt.consoleRuntime.replacementFailures.length
3575
+ ? `the installer could not replace the running Console (${receipt.consoleRuntime.replacementFailures.join('; ')}); ` : '';
3576
+ return { healthy: false, state: receipt.consoleRuntime?.state || 'console-unproven', action: `${why}restart Console, then re-run --doctor` };
3512
3577
  }
3513
3578
  return { healthy: true, state: 'channels-converged' };
3514
3579
  }
@@ -3587,6 +3652,21 @@ async function runUpdate() {
3587
3652
  };
3588
3653
  process.once('exit', exitGuard);
3589
3654
  info(`brain dir: ${c.bold(kbDir)}`);
3655
+ // RECOVER AN INTERRUPTED UPDATE FIRST, before anything below writes into the brain. Two measured
3656
+ // reasons: (1) a kill between the two directory renames leaves no usable kb/ β€” the brain sits in a
3657
+ // receipted kb.rollback-<id>, and the recovery that renames it back lives inside the updater that is
3658
+ // now missing; (2) the preflight below re-stamps RUNTIME-IDENTITY.json into kb/, after which recovery
3659
+ // can never prove kb/ still equals the identity sealed at LOCKED, so any pre-activation kill wedged
3660
+ // every later update at RECOVERY_REQUIRED. Same function the updater runs, under this refresh lock.
3661
+ // Receipts hold the REAL paths the updater knew, so recovery is addressed by the physical path.
3662
+ if (fs.existsSync(path.join(path.dirname(kbDir), `.${path.basename(kbDir)}.update-transactions`))) {
3663
+ try {
3664
+ const recovered = recoverIncompleteStorageTransactions(physicalPath(kbDir));
3665
+ if (recovered.length) ok(`restored the brain from an interrupted update (${recovered.map((r) => `${r.transactionId}: ${r.from}`).join(', ')})`);
3666
+ } catch (error) {
3667
+ warn(`an interrupted update could not be recovered automatically: ${error.message}`);
3668
+ }
3669
+ }
3590
3670
  let updateStatus = 1;
3591
3671
  // NO updater at all = no brain installed here (or a pre-self-updater bundle). That is a USER
3592
3672
  // message, not a fallback trigger: fail LOUD with the re-run-installer help and exit β€” never
@@ -3645,6 +3725,10 @@ async function runUpdate() {
3645
3725
  result: updaterResult,
3646
3726
  requireResult: supportsResultReceipt,
3647
3727
  });
3728
+ if (outcome.verdict === 'refused-retained-copies') {
3729
+ warn('nothing was changed: full copies of earlier brain generations already sit beside this one (listed above).');
3730
+ info('Check that you no longer need them, remove them, then re-run npx ruvnet-brain --update');
3731
+ }
3648
3732
  if (outcome.fallback && FLAG_HOST_SYNC_ONLY) {
3649
3733
  // Host synchronization has a narrower contract than a full update: it must converge the
3650
3734
  // executable plugin/spine to the published package even when an optional large KB asset is
@@ -4951,7 +5035,7 @@ async function offerStack(env) {
4951
5035
  for (const m of missing) {
4952
5036
  if (m.shell) {
4953
5037
  info(`installing ${m.what} … ${c.dim(m.say)}`);
4954
- const ran = tryRun(m.shell[0], m.shell[1]);
5038
+ const ran = ['claude', 'codex'].includes(m.shell[0]) ? tryHostCli(m.shell[0], m.shell[1]) : tryRun(m.shell[0], m.shell[1]);
4955
5039
  // Don't trust the exit code alone β€” e.g. `claude mcp add` exits non-zero on "already exists",
4956
5040
  // which is functionally success. Re-check the real state (m.verify) before warning.
4957
5041
  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
  }
@@ -258,7 +258,23 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
258
258
  const phaseFiles = fs.readdirSync(receipts).filter((name) => /^\d{3}-[A-Z_]+\.json$/.test(name)).sort();
259
259
  if (!phaseFiles.length) throw new Error(`storage transaction receipt is empty: ${receipts}`);
260
260
  const latest = JSON.parse(fs.readFileSync(path.join(receipts, phaseFiles.at(-1)), 'utf8'));
261
- if (['NOOP', 'COMMITTED', 'ROLLED_BACK'].includes(latest.state)) continue;
261
+ if (['NOOP', 'COMMITTED', 'ROLLED_BACK'].includes(latest.state)) {
262
+ // A quarantined unsealed candidate is kept for ONE full update cycle, then released on the next
263
+ // run β€” but only if its bytes are exactly what was sealed when it was quarantined.
264
+ const quarantine = latest.quarantinedUnsealedCandidate;
265
+ if (latest.state === 'ROLLED_BACK' && quarantine && latest.quarantineReclaimed !== true
266
+ && path.resolve(quarantine) === transactionPaths(live, transactionId).failed) {
267
+ const unchanged = !fs.existsSync(quarantine) || (() => {
268
+ try { requireDigest(quarantine, latest.quarantineIdentity, 'quarantined candidate'); return true; } catch { return false; }
269
+ })();
270
+ if (unchanged) {
271
+ removeIfPresent(quarantine);
272
+ appendRecoveryReceipt(receipts, 'ROLLED_BACK', { quarantineReclaimed: true,
273
+ reason: 'released the quarantined unsealed candidate one update cycle later (bytes unchanged)' });
274
+ }
275
+ }
276
+ continue;
277
+ }
262
278
  if (latest.state === 'RECOVERY_REQUIRED') throw new Error(`storage transaction requires manual recovery: ${transactionId}`);
263
279
  const paths = latest.paths;
264
280
  const expectedPaths = transactionPaths(live, transactionId);
@@ -273,12 +289,22 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
273
289
  try {
274
290
  // Validate every retained tree before any rename or deletion. A receipt owns
275
291
  // paths, but cannot authorize discarding bytes added after the process died.
276
- if (fs.existsSync(paths.candidate)) requireDigest(paths.candidate, latest.candidate, 'interrupted candidate');
292
+ // A kill DURING candidate building (the long phase: copy, private restore, guard) leaves a candidate
293
+ // that was never sealed, so no receipt can vouch for its bytes. Refusing made every later update
294
+ // fail forever; deleting would discard bytes nothing proved disposable. It is QUARANTINED instead:
295
+ // renamed intact to this transaction's `failed` path, named in the receipt, and live (proved equal
296
+ // to the prior identity) stays in service.
297
+ const unsealedCandidate = fs.existsSync(paths.candidate) && !latest.candidate?.sha256
298
+ && ['LOCKED', 'CANDIDATE_BUILDING'].includes(latest.state);
299
+ if (fs.existsSync(paths.candidate) && !unsealedCandidate) requireDigest(paths.candidate, latest.candidate, 'interrupted candidate');
277
300
  if (fs.existsSync(paths.rollback)) requireDigest(paths.rollback, prior, 'interrupted rollback');
278
301
  if (fs.existsSync(paths.failed)) throw new Error('interrupted failed tree has no safe recovery disposition');
279
302
  if (['LOCKED', 'CANDIDATE_BUILDING', 'CANDIDATE_VERIFIED'].includes(latest.state)) {
280
303
  requireDigest(live, prior, 'interrupted live');
281
- removeIfPresent(paths.candidate);
304
+ if (unsealedCandidate) {
305
+ assertDirectory(paths.candidate, 'unsealed candidate');
306
+ fs.renameSync(paths.candidate, paths.failed);
307
+ } else removeIfPresent(paths.candidate);
282
308
  } else if (latest.state === 'OLD_RENAME_STARTED') {
283
309
  const hasLive = fs.existsSync(live);
284
310
  const hasRollback = fs.existsSync(paths.rollback);
@@ -324,10 +350,12 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
324
350
  continue;
325
351
  } else throw new Error(`unsupported interrupted state ${latest.state}`);
326
352
  const delta = storageDelta(paths, { prior, candidate: latest.candidate || null });
353
+ const quarantined = unsealedCandidate
354
+ ? { quarantinedUnsealedCandidate: paths.failed, quarantineIdentity: identitySummary(treeIdentity(paths.failed)) } : {};
327
355
  appendRecoveryReceipt(receipts, 'ROLLED_BACK', { terminalVerdict: 'interrupted-run-restored', prior,
328
- storageDelta: delta, reason: `recovered interrupted ${latest.state} transaction before new work` });
356
+ storageDelta: delta, reason: `recovered interrupted ${latest.state} transaction before new work`, ...quarantined });
329
357
  recovered.push({ transactionId, from: latest.state, terminalVerdict: 'interrupted-run-restored',
330
- storageDelta: delta });
358
+ storageDelta: delta, ...quarantined });
331
359
  } catch (error) {
332
360
  appendRecoveryReceipt(receipts, 'RECOVERY_REQUIRED', { terminalVerdict: 'recovery-required', prior,
333
361
  reason: error.message });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.3.40",
3
+ "version": "4.4.0",
4
4
  "description": "One-command installer for RuvNet Brain \u2014 a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code β€” grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
4
- "version": "4.3.40",
4
+ "version": "4.4.0",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.3.40",
3
+ "version": "4.4.0",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -92,8 +92,8 @@
92
92
  "hooks": [
93
93
  {
94
94
  "type": "command",
95
- "command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 9000 session-snapshot SessionEnd",
96
- "timeout": 10
95
+ "command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 2500 session-snapshot SessionEnd",
96
+ "timeout": 3
97
97
  }
98
98
  ]
99
99
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "RuvNet Brain lifecycle plane. The broad legacy routing, learning, and release interceptors remain retired. What is automatic is exactly: SessionStart restores the canonical project checkpoint; UserPromptSubmit runs the single unprompted-speech chokepoint, ground-ruvnet grounding injection, and a capacity-aware context hint for clearly large independent work. The capacity hook uses bounded CPU/memory-pressure evidence, never starts agents, and tells the coordinator to check live tools and clamp to the runtime cap; it is not user-facing speech. ground-ruvnet remains a directive to the model, not speech β€” ADR-040 Β§Amendment 2026-09-11. PreToolUse runs decision-gate's write route, the ONE process that may refuse a Write/Edit/MultiEdit/NotebookEdit/apply_patch (ADR-067, composing ground-before-write per ADR-0012); PostToolUse on a successful search_ruvnet mints the grounding stamp that opens that gate. grounding-turn-mark (UserPromptSubmit) records that the grounding directive fired this turn, and grounding-turn-gate (Stop) forces continuation if no search_ruvnet call was recorded since. Stop also runs the ledger-scoped continuation handler and captures a project snapshot; PreCompact and SessionEnd capture a project snapshot. Every capture is bounded and fails open; only decision-gate may block on exit code, and it fails open on its own errors β€” the Stop stdout envelopes are advisory at the shim boundary but keep the agent working.",
2
+ "description": "RuvNet Brain lifecycle plane. The broad legacy routing, learning, and release interceptors remain retired. What is automatic is exactly: SessionStart restores the canonical project checkpoint; UserPromptSubmit runs the single unprompted-speech chokepoint, ground-ruvnet grounding injection, and a capacity-aware context hint for clearly large independent work. The capacity hook uses bounded CPU/memory-pressure evidence, never starts agents, and tells the coordinator to check live tools and clamp to the runtime cap; it is not user-facing speech. ground-ruvnet remains a directive to the model, not speech β€” ADR-040 Β§Amendment 2026-09-11. PreToolUse runs decision-gate's write route, the ONE process that may refuse a Write/Edit/MultiEdit/NotebookEdit/apply_patch (ADR-067, composing ground-before-write per ADR-0012); PostToolUse on a successful search_ruvnet mints the grounding stamp that opens that gate. grounding-turn-mark (UserPromptSubmit) records that the grounding directive fired this turn, and grounding-turn-gate (Stop) forces continuation if the final answer asserts a rUv capability and no search_ruvnet call was recorded since. Stop also runs the ledger-scoped continuation handler and captures a project snapshot; PreCompact and SessionEnd capture a project snapshot. Every capture is bounded and fails open; only decision-gate may block on exit code, and it fails open on its own errors β€” the Stop stdout envelopes are advisory at the shim boundary but keep the agent working.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -451,11 +451,11 @@ export const CAPABILITIES = [
451
451
  if (d.unreadable) {
452
452
  const locked = /lock|busy|writer/i.test(String(d.unreadable));
453
453
  return row(STATE.UNKNOWN, locked
454
- ? `the memory store is currently held by another process (${d.unreadable}) β€” that is a passing lock, not a fault; re-checking in a moment should clear it`
454
+ ? `could not read the memory store: another process is holding it (${d.unreadable}) β€” that is a passing lock, not a fault; re-checking in a moment should clear it`
455
455
  : `the memory store could not be read (${d.unreadable}) β€” this is not a transient lock, so re-checking will not clear it; the store or its journal files need attention before distillation state can be established`);
456
456
  }
457
- if (d.schemaless) return row(STATE.UNKNOWN, 'the store exists but has no memory_entries table (pre-AgentDB schema, never initialised) β€” nothing to distill yet, and nothing is broken');
458
- if (typeof d.total !== 'number') return row(STATE.UNKNOWN, 'the store opened but returned no countable rows β€” distillation state not established');
457
+ if (d.schemaless) return row(STATE.UNKNOWN, 'cannot measure distillation: the store exists but has no memory_entries table (pre-AgentDB schema, never initialised) β€” nothing to distill yet, and nothing is broken');
458
+ if (typeof d.total !== 'number') return row(STATE.UNKNOWN, 'the store opened but returned no countable rows β€” distillation state could not be established');
459
459
 
460
460
  if (d.total === 0) return row(STATE.ABSENT, 'the memory store is empty, so there is nothing to distill yet');
461
461
  if (d.learns) return row(STATE.ON, `${d.patterns} reusable patterns distilled from ${d.real} memories (${(d.cover * 100).toFixed(1)}% embedded)`);
@@ -60,7 +60,7 @@ const blockingHooks = new Set([
60
60
  const DETACHED_HOOKS = new Set(['learn-flush']);
61
61
 
62
62
  /** THE BUDGET IS DERIVED FROM WHAT THE HOOK MEASURABLY COSTS, never from what looks tidy. */
63
- function timeoutFor(hookId) {
63
+ function timeoutFor(hookId, event = '') {
64
64
  const override = Number(process.env.RUVNET_CODEX_HOOK_TIMEOUT_MS);
65
65
  if (Number.isFinite(override) && override > 0) return override;
66
66
  // decision-gate's own internal budget is 4000ms (RUVNET_DECISION_BUDGET_MS) and it is allowed to
@@ -75,6 +75,11 @@ function timeoutFor(hookId) {
75
75
  if (hookId === 'ground-ruvnet' || hookId === 'unprompted-speech' || hookId === 'continuation-gate') {
76
76
  return 8_500;
77
77
  }
78
+ // SessionEnd is hard-capped at 3s by the host (see DETACHED_HOOKS above) and the codex-hooks.json
79
+ // launcher kills this wrapper at 2500ms. A 4000ms budget here was a number nobody would ever reach:
80
+ // the body planned for 8s and was SIGKILLed mid-write. 2200ms leaves the launcher its margin, and the
81
+ // body receives it as RUVNET_CODEX_BUDGET_MS (below) so it can save the new snapshot first.
82
+ if (event === 'SessionEnd') return 2_200;
78
83
  return 4_000;
79
84
  }
80
85
 
@@ -193,7 +198,7 @@ if (DETACHED_HOOKS.has(hookId)) {
193
198
  process.exit(0);
194
199
  }
195
200
 
196
- const budgetMs = timeoutFor(hookId);
201
+ const budgetMs = timeoutFor(hookId, process.argv[3] || '');
197
202
  const result = spawnSync(process.execPath, [adapter, ...process.argv.slice(2)], {
198
203
  input,
199
204
  encoding: 'utf8',
@@ -29,6 +29,7 @@ INPUT=""
29
29
  # exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
30
30
  # is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
31
31
  # the whole thing back at once and a per-iteration cap never fires.
32
+ _l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
32
33
  while IFS= read -r -t 2 _l; do
33
34
  INPUT+="$_l"
34
35
  [ ${#INPUT} -ge 65536 ] && break
@@ -43,6 +43,7 @@ INPUT=""
43
43
  # exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
44
44
  # is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
45
45
  # the whole thing back at once and a per-iteration cap never fires.
46
+ _l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
46
47
  while IFS= read -r -t 2 _l; do
47
48
  INPUT+="$_l"
48
49
  [ ${#INPUT} -ge 65536 ] && break