ruvnet-brain 4.3.39 β†’ 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 (49) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +150 -42
  3. package/kb/brain-profile.mjs +17 -2
  4. package/kb/forge-update.mjs +92 -28
  5. package/kb/lifecycle-evidence-retention.mjs +12 -9
  6. package/kb/refresh-run.mjs +17 -1
  7. package/kb/update-storage-transaction.mjs +44 -10
  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/coverage-integrity.mjs +51 -4
  16. package/plugin/scripts/design-wall.sh +1 -0
  17. package/plugin/scripts/ground-before-write.sh +1 -0
  18. package/plugin/scripts/ground-ruvnet.sh +3 -3
  19. package/plugin/scripts/grounding-answer.mjs +129 -0
  20. package/plugin/scripts/grounding-stamp.sh +32 -31
  21. package/plugin/scripts/grounding-turn-evidence.mjs +99 -5
  22. package/plugin/scripts/grounding-turn-gate.mjs +25 -6
  23. package/plugin/scripts/hook-shim.mjs +3 -0
  24. package/plugin/scripts/kling-preflight.sh +1 -0
  25. package/plugin/scripts/learn-capture.sh +1 -0
  26. package/plugin/scripts/project-progression-sources.mjs +16 -4
  27. package/plugin/scripts/project-progression-store.mjs +49 -1
  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 +224 -38
  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 +145 -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 +155 -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.39 β€” updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.3.39-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,322 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';
@@ -102,8 +105,9 @@ const PACKAGE_VERSION = (() => {
102
105
  const REPO = 'stuinfla/ruvnet-brain';
103
106
  const RELEASE_API = `https://api.github.com/repos/${REPO}/releases/latest`;
104
107
  const ASSET_NAME = 'ruvnet-brain.zip';
105
- // Known-good BUNDLE tag, used when we can't reach GitHub (offline / rate-limited / no releases),
106
- // and by --pin. Default behavior is "get the latest Release"; this is only the safety net.
108
+ // Known-good BUNDLE tag, used ONLY by --pin. It is no longer a silent fallback for a failed
109
+ // latest-release lookup: that bundle predates ReleaseCoverage and cannot pass validation, so a lookup
110
+ // failure now stops with its real cause (resolveRelease / releaseLookupFailure).
107
111
  //
108
112
  // This MUST NOT be derived from this package's own version. The installer and the brain bundle are
109
113
  // two independent version streams (README: "Three independent things version separately here β€” by
@@ -227,6 +231,13 @@ function tryRun(cmd, args, opts = {}) {
227
231
  const r = spawnSync(cmd, args, { stdio: 'inherit', shell: IS_WIN, ...opts });
228
232
  return !r.error && r.status === 0;
229
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
+ }
230
241
 
231
242
  // ── download with redirect-following + progress ──────────────────────────────────────────────────
232
243
  function download(url, dest, redirects = 0) {
@@ -299,7 +310,9 @@ function fetchJson(url, redirects = 0) {
299
310
  }
300
311
  if (statusCode !== 200) {
301
312
  res.resume();
302
- return reject(new Error(`GitHub API returned HTTP ${statusCode}`));
313
+ const reset = headers['x-ratelimit-remaining'] === '0' && Number(headers['x-ratelimit-reset']);
314
+ return reject(new Error(`GitHub API returned HTTP ${statusCode}${reset
315
+ ? ` (anonymous rate limit used up; it resets at ${new Date(reset * 1000).toISOString()})` : ''}`));
303
316
  }
304
317
  let body = '';
305
318
  res.setEncoding('utf8');
@@ -321,8 +334,8 @@ function fetchJson(url, redirects = 0) {
321
334
  // ── step: resolve which Release to download (latest by default; safe fallback) ───────────────────
322
335
  // Default behavior: ask GitHub for the LATEST Release and use its ruvnet-brain.zip asset.
323
336
  // --version <tag> forces a tag; --pin skips the network check and uses the bundled known-good tag.
324
- // Any failure (offline / rate-limited / no releases) FALLS BACK to the pinned known-good Release,
325
- // narrated clearly so the user knows exactly what happened.
337
+ // Any failure (offline / rate-limited / no releases) THROWS with the HTTP status or network error and
338
+ // a retry hint; callers that must download stop on it, the staleness check reports "could not check".
326
339
  /**
327
340
  * Which asset of a Release actually holds the brain bundle.
328
341
  *
@@ -387,12 +400,31 @@ async function resolveRelease() {
387
400
  ok(`latest Release is ${c.bold(tag)}`);
388
401
  return { tag, url, source: 'latest' };
389
402
  } catch (e) {
390
- warn(`couldn't check for the latest version (${e.message})`);
391
- info(`using the known-good ${c.bold(RELEASE_VERSION)} instead β€” the install is still safe and complete`);
392
- return { tag: RELEASE_VERSION, url: fallbackUrl(RELEASE_VERSION), source: 'fallback' };
403
+ // FAIL LOUD. This used to return the hardcoded RELEASE_VERSION and promise "the install is still
404
+ // safe and complete" β€” but that bundle predates ReleaseCoverage, so two steps later it failed
405
+ // validation with "COVERAGE.json is missing", and the reason (a rate limit, a network blip) was
406
+ // gone from the screen. Measured 2026-09-30 on the owner's recovery rail. Only --pin and
407
+ // --version choose a tag the lookup did not return.
408
+ const failure = releaseLookupFailure(e);
409
+ throw Object.assign(new Error(failure.message), { hint: failure.hint });
393
410
  }
394
411
  }
395
412
 
413
+ /** Why the latest-release lookup failed, and what to do about it. Pure, for testing. */
414
+ export function releaseLookupFailure(error) {
415
+ const detail = (error && error.message) || String(error);
416
+ const limited = /HTTP (?:403|429)\b/.test(detail);
417
+ return {
418
+ message: `couldn't look up the latest RuvNet Brain release (${detail}).`,
419
+ hint: [
420
+ limited
421
+ ? 'GitHub allows a limited number of anonymous release checks per hour from one network address. Wait until the reset time above, then re-run the same command.'
422
+ : 'Check your connection, then re-run the same command in a minute.',
423
+ `Nothing was downloaded or installed. To install one specific release instead: add --version <tag> (tags: https://github.com/${REPO}/releases).`,
424
+ ].join('\n'),
425
+ };
426
+ }
427
+
396
428
  // ── step: resolve the cache dir ──────────────────────────────────────────────────────────────────
397
429
  function resolveCacheDir() {
398
430
  const custom = process.env.RUVNET_BRAIN_KB;
@@ -1249,24 +1281,33 @@ export function installConsoleRuntime(cacheDir, sourceRoot = REPO_ROOT) {
1249
1281
  }
1250
1282
  }
1251
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
+
1252
1300
  export function consoleRestartState(identity, {
1253
1301
  receiptDir = path.join(process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'console-instances'),
1302
+ alive, probe,
1254
1303
  } = {}) {
1255
- let receipts = [];
1256
- try {
1257
- receipts = fs.readdirSync(receiptDir)
1258
- .filter((name) => name.endsWith('.json'))
1259
- .map((name) => {
1260
- try { return JSON.parse(fs.readFileSync(path.join(receiptDir, name), 'utf8')); }
1261
- catch { return null; }
1262
- })
1263
- .filter((receipt) => receipt?.product === 'ruvnet-brain-console' && receipt.schema === 1);
1264
- } catch { /* no running Console receipts is the ordinary ready state */ }
1265
- 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;
1266
1306
  return {
1267
1307
  state: staleInstances > 0 ? 'pending-console-restart' : 'ready',
1268
- instanceReceipts: receipts.length,
1308
+ instanceReceipts: live.length,
1269
1309
  staleInstances,
1310
+ ...(pruned.length ? { prunedDeadReceipts: pruned.length } : {}),
1270
1311
  };
1271
1312
  }
1272
1313
 
@@ -1384,7 +1425,9 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1384
1425
  const manualMarketplace = `claude plugin marketplace add ${marketplaceSource}`;
1385
1426
  const manualInstall = 'claude plugin install ruvnet-brain@ruvnet-brain --scope user';
1386
1427
 
1387
- if (!have('claude')) {
1428
+ const claudeCli = waitForHostCli('claude');
1429
+ if (!claudeCli.present) {
1430
+ if (claudeCli.message) warn(claudeCli.message);
1388
1431
  warn(`I couldn't run the \`claude\` command from this shell.`);
1389
1432
  info(`That's normal if you use Claude Code as the ${c.bold('VS Code extension')} or ${c.bold('desktop app')} β€” the`);
1390
1433
  info(`command just isn't on your terminal's PATH. ${c.green('The brain itself is fully downloaded.')}`);
@@ -1401,15 +1444,15 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1401
1444
  ? inspectPluginShellBoundary(before.installPath)
1402
1445
  : { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
1403
1446
  const addedMarket = before.managed
1404
- ? tryRun('claude', ['plugin', 'marketplace', 'update', 'ruvnet-brain'])
1405
- : tryRun('claude', ['plugin', 'marketplace', 'add', marketplaceSource]);
1447
+ ? tryHostCli('claude', ['plugin', 'marketplace', 'update', 'ruvnet-brain'])
1448
+ : tryHostCli('claude', ['plugin', 'marketplace', 'add', marketplaceSource]);
1406
1449
  // Deliberately NOT reassuring here. This used to say "it may already be added β€” that's fine",
1407
1450
  // which is a GUESS about someone else's machine, and when it was wrong the user finished the
1408
1451
  // install with a working search_ruvnet, no slash commands, and a message telling them all was
1409
1452
  // well. The real state is checked below; nothing is declared fine until it has been looked at.
1410
1453
  if (!addedMarket) info(`marketplace add didn't report success β€” checking what actually landed…`);
1411
1454
 
1412
- tryRun('claude', before.installed
1455
+ tryHostCli('claude', before.installed
1413
1456
  ? ['plugin', 'update', 'ruvnet-brain@ruvnet-brain', '--scope', 'user']
1414
1457
  : ['plugin', 'install', 'ruvnet-brain@ruvnet-brain', '--scope', 'user']);
1415
1458
 
@@ -1834,13 +1877,16 @@ function runCodexJson(args, {
1834
1877
  codexHome = codexHomeDir(),
1835
1878
  cwd = process.cwd(),
1836
1879
  } = {}) {
1837
- const r = spawnSync(codexBin, args, {
1880
+ const r = runHostCli(codexBin, args, {
1881
+ stdio: 'pipe',
1882
+ shell: false,
1838
1883
  cwd,
1839
1884
  env: { ...process.env, CODEX_HOME: codexHome },
1840
1885
  encoding: 'utf8',
1841
1886
  timeout: 30_000,
1842
1887
  maxBuffer: 20 * 1024 * 1024,
1843
1888
  });
1889
+ if (r.missingBinary) return { ok: false, error: r.message };
1844
1890
  if (r.error || r.status !== 0) {
1845
1891
  const detail = String(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim();
1846
1892
  return { ok: false, error: detail };
@@ -2732,7 +2778,9 @@ async function doctor() {
2732
2778
  let hostConvergence = { healthy: true, state: 'not-recorded' };
2733
2779
  if (fs.existsSync(convergencePath)) {
2734
2780
  try {
2735
- 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);
2736
2784
  if (hostConvergence.healthy) ok(`host convergence receipt: ${hostConvergence.state}`);
2737
2785
  else {
2738
2786
  warn(`host convergence incomplete: ${hostConvergence.state}`);
@@ -3252,6 +3300,18 @@ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = n
3252
3300
  if (!requireResult) return { verdict: 'legacy-success', fallback: false, exitCode: 0 };
3253
3301
  return { verdict: 'invalid-result', fallback: false, exitCode: 1 };
3254
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
+ }
3255
3315
  return { verdict: 'failed', fallback: fallbackAllowed, exitCode: status || 1 };
3256
3316
  }
3257
3317
 
@@ -3348,6 +3408,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3348
3408
  wireCodexHost: detectCodexHost = wireCodexHost,
3349
3409
  wireCodexPlugin: installCodexPlugin = wireCodexPlugin,
3350
3410
  hostLockPath = path.join(brainHome, 'host-convergence.lock'),
3411
+ replaceConsoles = null, // test seam; production runs replaceStaleConsoles
3351
3412
  runStableSpine = (apply) => spawnSync(
3352
3413
  process.execPath,
3353
3414
  [apply, '--auto', '--expected-version', PACKAGE_VERSION],
@@ -3433,12 +3494,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3433
3494
  warn(`stale plugin generations were not pruned (${e.message}); nothing was removed`);
3434
3495
  }
3435
3496
  const receiptPath = path.join(brainHome, 'host-convergence.json');
3436
- try {
3437
- runtimeTransaction.activate();
3438
- results.consoleRuntime = {
3439
- ...runtimeTransaction.identity,
3440
- ...consoleRestartState(runtimeTransaction.identity, { receiptDir: consoleReceiptDir }),
3441
- };
3497
+ const writeConvergenceReceipt = () => {
3442
3498
  fs.mkdirSync(brainHome, { recursive: true });
3443
3499
  const tmp = `${receiptPath}.tmp-${process.pid}`;
3444
3500
  fs.writeFileSync(tmp, JSON.stringify({
@@ -3451,10 +3507,39 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3451
3507
  consoleRuntime: results.consoleRuntime,
3452
3508
  }, null, 2));
3453
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();
3454
3518
  runtimeTransaction.commit();
3455
3519
  } catch (error) {
3456
3520
  return fail({ applyStatus: applied.status, error: `Console runtime convergence failed: ${error.message}` });
3457
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
+ }
3458
3543
  }
3459
3544
  if (!okApplied) return fail({ applyStatus: applied.status, error: applied.error?.message || 'Stable Spine activation failed' });
3460
3545
  const convergence = classifyHostConvergence({
@@ -3486,7 +3571,9 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
3486
3571
  }
3487
3572
  if (badHost) return { healthy: false, state: 'host-pending', action: 're-run host synchronization' };
3488
3573
  if (receipt.consoleRuntime?.state !== 'ready') {
3489
- 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` };
3490
3577
  }
3491
3578
  return { healthy: true, state: 'channels-converged' };
3492
3579
  }
@@ -3565,6 +3652,21 @@ async function runUpdate() {
3565
3652
  };
3566
3653
  process.once('exit', exitGuard);
3567
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
+ }
3568
3670
  let updateStatus = 1;
3569
3671
  // NO updater at all = no brain installed here (or a pre-self-updater bundle). That is a USER
3570
3672
  // message, not a fallback trigger: fail LOUD with the re-run-installer help and exit β€” never
@@ -3623,6 +3725,10 @@ async function runUpdate() {
3623
3725
  result: updaterResult,
3624
3726
  requireResult: supportsResultReceipt,
3625
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
+ }
3626
3732
  if (outcome.fallback && FLAG_HOST_SYNC_ONLY) {
3627
3733
  // Host synchronization has a narrower contract than a full update: it must converge the
3628
3734
  // executable plugin/spine to the published package even when an optional large KB asset is
@@ -3650,7 +3756,7 @@ async function runUpdate() {
3650
3756
  let fr;
3651
3757
  if (hasPrivateOverlay) {
3652
3758
  warn("\nthe installed updater failed; using authenticated staged recovery to preserve private stores…\n");
3653
- const release = await resolveRelease();
3759
+ const release = await resolveRelease().catch((error) => die(error.message, error.hint));
3654
3760
  const bundle = await obtainBundle(release);
3655
3761
  if (!bundle.zipPath) throw new Error('private-overlay recovery requires a downloadable signed bundle');
3656
3762
  const sigPath = `${bundle.zipPath}.sig`;
@@ -4929,7 +5035,7 @@ async function offerStack(env) {
4929
5035
  for (const m of missing) {
4930
5036
  if (m.shell) {
4931
5037
  info(`installing ${m.what} … ${c.dim(m.say)}`);
4932
- 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]);
4933
5039
  // Don't trust the exit code alone β€” e.g. `claude mcp add` exits non-zero on "already exists",
4934
5040
  // which is functionally success. Re-check the real state (m.verify) before warning.
4935
5041
  if (ran || (m.verify && m.verify())) ok(`${m.what} added`);
@@ -5333,8 +5439,9 @@ function showHelp() {
5333
5439
  console.log(`
5334
5440
  RuvNet Brain installer
5335
5441
 
5336
- By default this installs the LATEST published Release (it asks GitHub which one that is),
5337
- and falls back to a known-good version if GitHub can't be reached.
5442
+ By default this installs the LATEST published Release (it asks GitHub which one that is).
5443
+ If GitHub can't be reached or rate-limits the check, it STOPS with the reason and downloads
5444
+ nothing; re-run later, or pick a release yourself with --version <tag>.
5338
5445
 
5339
5446
  Usage:
5340
5447
  npx ruvnet-brain Install the brain + Claude Code plugin (recommended, npm)
@@ -5476,9 +5583,9 @@ the installer reports that boot-level declarations changed.
5476
5583
  installedTag = installedBrainVersion(cacheDir); // 'unknown' when SOURCE.json has no releaseTag
5477
5584
  try {
5478
5585
  resolvedRelease = await resolveRelease();
5479
- // ONLY a genuine `latest` lookup counts as "what current means". resolveRelease() does NOT
5480
- // throw when the GitHub API fails β€” it returns the hardcoded known-good pin with
5481
- // source:'fallback'. Treating that as latest inverts this whole fix: a rate-limited lookup
5586
+ // ONLY a genuine `latest` lookup counts as "what current means". A failed lookup now THROWS
5587
+ // (caught below: "could not check"); it used to return the hardcoded known-good pin with
5588
+ // source:'fallback'. Treating that as latest inverted this whole fix: a rate-limited lookup
5482
5589
  // would report installed v3.4.21-dev "β†’ latest v2.9.0" and DOWNGRADE a perfectly current
5483
5590
  // machine. (Caught by exercising the failure path against a 404 repo β€” the first version of
5484
5591
  // this fix did exactly that.) A pinned/forced resolution is likewise the operator's explicit
@@ -5541,7 +5648,8 @@ the installer reports that boot-level declarations changed.
5541
5648
  const localZipPresent =
5542
5649
  FLAG_LOCAL || fs.existsSync(path.join(REPO_ROOT, 'dist', 'ruvnet-brain.zip'));
5543
5650
  // Reuse the staleness check's resolution when it already ran β€” one network round-trip, not two.
5544
- const release = localZipPresent ? null : (resolvedRelease || await resolveRelease());
5651
+ const release = localZipPresent ? null
5652
+ : (resolvedRelease || await resolveRelease().catch((error) => die(error.message, error.hint)));
5545
5653
  const { zipPath, sourceDir, tmpDir, downloaded, sigError } = await obtainBundle(release);
5546
5654
  // Verify the Ed25519 signature BEFORE extracting a downloaded bundle into the user's config
5547
5655
  // (SEC-0010 #6 β€” trust root = the pubkey EMBEDDED in this file, so an attacker who swaps the
@@ -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
  }