ruvnet-brain 4.3.20 → 4.3.25

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 (143) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +383 -78
  3. package/console/app.js +141 -9
  4. package/console/index.html +51 -24
  5. package/console/scope.css +137 -0
  6. package/console/scope.html +144 -0
  7. package/console/scope.js +209 -0
  8. package/console/tips.html +1 -0
  9. package/kb/corpus-release-identity.mjs +239 -0
  10. package/kb/update-storage-transaction.mjs +20 -3
  11. package/package.json +9 -2
  12. package/plugin/.claude-plugin/plugin.json +2 -2
  13. package/plugin/.codex-plugin/plugin.json +1 -1
  14. package/plugin/commands/checkpoint.md +61 -0
  15. package/plugin/hooks/codex-hooks.json +64 -1
  16. package/plugin/hooks/hook-contracts.json +299 -6
  17. package/plugin/hooks/hooks.json +81 -1
  18. package/plugin/mcp/server.mjs +23 -0
  19. package/plugin/scripts/advocacy-catalog.mjs +245 -0
  20. package/plugin/scripts/advocacy-route.mjs +460 -0
  21. package/plugin/scripts/continuation-gate.mjs +25 -2
  22. package/plugin/scripts/continuation-objective.mjs +7 -1
  23. package/plugin/scripts/continuity-hook-policy.mjs +190 -15
  24. package/plugin/scripts/coverage-integrity.mjs +7 -0
  25. package/plugin/scripts/gates.mjs +113 -10
  26. package/plugin/scripts/grounding-turn-gate.mjs +167 -0
  27. package/plugin/scripts/grounding-turn-mark.mjs +91 -0
  28. package/plugin/scripts/hook-shim.mjs +14 -0
  29. package/plugin/scripts/host-shell-boundary.mjs +43 -0
  30. package/plugin/scripts/nightly-scheduler.mjs +37 -4
  31. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  32. package/plugin/scripts/project-progression-contract.mjs +16 -0
  33. package/plugin/scripts/project-progression-hook.mjs +3 -0
  34. package/plugin/scripts/project-progression-producer.mjs +252 -0
  35. package/plugin/scripts/project-progression-reader.mjs +271 -0
  36. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  37. package/plugin/scripts/project-progression-sources.mjs +220 -0
  38. package/plugin/scripts/project-progression-store.mjs +106 -13
  39. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  40. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  41. package/plugin/scripts/session-start-budget.mjs +59 -0
  42. package/plugin/scripts/session-start-core.mjs +234 -457
  43. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  44. package/plugin/scripts/session-start-health.mjs +64 -0
  45. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  46. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  47. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  48. package/plugin/scripts/session-start-signals.mjs +73 -0
  49. package/plugin/scripts/session-start-trace.mjs +86 -0
  50. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  51. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  52. package/plugin/scripts/update-apply.mjs +2 -32
  53. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  54. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  55. package/scripts/adr-072-completion.mjs +1 -1
  56. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  57. package/scripts/approved-runtime.mjs +197 -0
  58. package/scripts/brain-novice-50.mjs +16 -1
  59. package/scripts/brain-score.mjs +23 -5
  60. package/scripts/build-bundle.mjs +971 -530
  61. package/scripts/build-concepts.mjs +36 -116
  62. package/scripts/console-engine.test.mjs +8 -7
  63. package/scripts/console-runtime-identity.mjs +4 -0
  64. package/scripts/corpus-aggregates.mjs +94 -77
  65. package/scripts/corpus-candidate.mjs +475 -222
  66. package/scripts/corpus-next-seed.mjs +225 -0
  67. package/scripts/corpus-promotion.mjs +58 -0
  68. package/scripts/corpus-reconcile.mjs +411 -105
  69. package/scripts/doc-currency.mjs +16 -1
  70. package/scripts/dual-host-deliberation.mjs +25 -2
  71. package/scripts/dual-host-suggest.mjs +17 -1
  72. package/scripts/falsify.mjs +13 -3
  73. package/scripts/gist-receipts.mjs +482 -87
  74. package/scripts/github-health-watch.mjs +12 -2
  75. package/scripts/handoff-asset.mjs +34 -0
  76. package/scripts/hook-retirement-check.mjs +8 -1
  77. package/scripts/host-registry.mjs +1 -1
  78. package/scripts/ingest-gists.mjs +74 -101
  79. package/scripts/job-heartbeat.sh +77 -14
  80. package/scripts/learning-replay-execution.mjs +10 -4
  81. package/scripts/nightly-gists.sh +27 -13
  82. package/scripts/nightly-two-run-proof.mjs +1 -1
  83. package/scripts/nightly-watchdog.mjs +61 -4
  84. package/scripts/onboarding-console.mjs +319 -27
  85. package/scripts/oracle/produce-questions.mjs +293 -0
  86. package/scripts/oracle/producer-hosts.mjs +235 -0
  87. package/scripts/oracle/repo-recall.mjs +448 -0
  88. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  89. package/scripts/oracle/source-tree.mjs +165 -0
  90. package/scripts/oracle/source-units.mjs +391 -0
  91. package/scripts/oracle/spike-run.mjs +98 -0
  92. package/scripts/oracle/unit-inventory.mjs +141 -0
  93. package/scripts/oracle/unit-sampling.mjs +128 -0
  94. package/scripts/oracle/validate-labels.mjs +250 -0
  95. package/scripts/private-overlay.mjs +248 -0
  96. package/scripts/product-integrity-contract.mjs +1 -1
  97. package/scripts/proxy/claude-proxied.sh +6 -0
  98. package/scripts/proxy/proxy-revert.sh +5 -0
  99. package/scripts/proxy/proxy-up.sh +6 -0
  100. package/scripts/proxy/proxy-verify.mjs +4 -0
  101. package/scripts/public-inputs.mjs +409 -0
  102. package/scripts/public-verification-inputs.mjs +112 -26
  103. package/scripts/public-verification-lane.mjs +1 -1
  104. package/scripts/published-surface-probe.mjs +34 -4
  105. package/scripts/qe/card-lane-gate.mjs +16 -1
  106. package/scripts/qe/session-start-gate.mjs +16 -1
  107. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  108. package/scripts/record-lesson.mjs +4 -1
  109. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  110. package/scripts/release-abort-stale.mjs +5 -1
  111. package/scripts/release-authority.mjs +104 -12
  112. package/scripts/release-channel-kind.mjs +86 -0
  113. package/scripts/release-convergence-watchdog.mjs +7 -2
  114. package/scripts/release-projection.mjs +177 -72
  115. package/scripts/release-transaction-provider.mjs +23 -6
  116. package/scripts/release.mjs +252 -17
  117. package/scripts/retrieval-canary.mjs +87 -0
  118. package/scripts/rvf-index-audit.mjs +573 -13
  119. package/scripts/rvf-wire.mjs +269 -0
  120. package/scripts/seal-gist-receipt.mjs +65 -0
  121. package/scripts/selfcheck.mjs +42 -21
  122. package/scripts/source-coverage.mjs +253 -24
  123. package/scripts/status-honesty.mjs +25 -0
  124. package/scripts/sync-census.mjs +0 -0
  125. package/scripts/sync-version.mjs +2 -0
  126. package/scripts/trismart.mjs +42 -0
  127. package/scripts/updater-manifest.mjs +162 -0
  128. package/scripts/verify-channels.mjs +17 -5
  129. package/scripts/wired-check.mjs +48 -10
  130. package/tri-smart-skill/QUICKSTART.md +37 -0
  131. package/tri-smart-skill/README.md +92 -0
  132. package/tri-smart-skill/install.cmd +14 -0
  133. package/tri-smart-skill/install.command +13 -0
  134. package/tri-smart-skill/install.mjs +51 -0
  135. package/tri-smart-skill/install.sh +9 -0
  136. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  137. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  138. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  139. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  140. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  141. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  142. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  143. package/scripts/corpus-seed-publish.mjs +0 -110
package/bin/install.mjs CHANGED
@@ -32,8 +32,9 @@ import { applyManagedCatalogUpdate } from '../scripts/model-router-catalog.mjs';
32
32
  import { cmpVersion } from '../scripts/stack-sync.mjs';
33
33
  import { validateCoverageDirectory } from '../plugin/scripts/coverage-integrity.mjs';
34
34
  import {
35
- CONTINUITY_EVENTS,
36
35
  continuityContractIds,
36
+ continuityHookId,
37
+ continuityRegistrations,
37
38
  isAllowedContinuityRegistration,
38
39
  } from '../plugin/scripts/continuity-hook-policy.mjs';
39
40
  import {
@@ -67,6 +68,10 @@ const versionSatisfies = (installed, expected) => {
67
68
  import {
68
69
  CONSOLE_RUNTIME_SURFACE, CONSOLE_RUNTIME_IDENTITY_FILE, consoleRuntimeDigest,
69
70
  } from '../scripts/console-runtime-identity.mjs';
71
+ import { shellDiff as pluginShellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
72
+ import {
73
+ writeInstalledRuntimeIdentity, recordCorpusTransportIdentity, isCorpusReleaseTag, rejectedReleasePath,
74
+ } from '../kb/corpus-release-identity.mjs';
70
75
 
71
76
  // SEC-0010 #6 — the Ed25519 PUBLIC key is EMBEDDED here (not a separate file) so the installer's
72
77
  // trust root travels with the installer code itself: an attacker who swaps the downloaded bundle
@@ -317,6 +322,32 @@ function fetchJson(url, redirects = 0) {
317
322
  // --version <tag> forces a tag; --pin skips the network check and uses the bundled known-good tag.
318
323
  // Any failure (offline / rate-limited / no releases) FALLS BACK to the pinned known-good Release,
319
324
  // narrated clearly so the user knows exactly what happened.
325
+ /**
326
+ * Which asset of a Release actually holds the brain bundle.
327
+ *
328
+ * THE UNAMBIGUOUS SINGLE ZIP — the lesson kb/forge-update.mjs's resolveBundleUrl() already learned
329
+ * (issue #35) and this installer had not. Matching ONLY the conventional `ruvnet-brain.zip` means a
330
+ * release whose bundle asset is named anything else falls through to `fallbackUrl(tag)`, a URL
331
+ * assembled from that same conventional name — so the "fallback" is a guaranteed 404, not a
332
+ * download. The path that reaches it is a FRESH install: the one case with no brain already on disk
333
+ * to keep working. ADR-086 step 16 makes this reachable in practice, because `releases/latest` can
334
+ * now be a corpus release.
335
+ *
336
+ * A release carrying exactly one .zip is not ambiguous about which zip is the bundle. Two or more
337
+ * and guessing would be worse than the honest warning, so it falls through as before.
338
+ *
339
+ * Pure and exported so the choice is testable without a network round-trip.
340
+ * @returns {{url: string, origin: 'exact-name'|'single-zip'|'conventional-url', assetName: string|null}}
341
+ */
342
+ export function resolveReleaseAsset({ tag, assets, assetName = ASSET_NAME }) {
343
+ const list = Array.isArray(assets) ? assets : [];
344
+ const exact = list.find((a) => a && a.name === assetName && a.browser_download_url);
345
+ if (exact) return { url: exact.browser_download_url, origin: 'exact-name', assetName: exact.name };
346
+ const zips = list.filter((a) => a && typeof a.name === 'string' && a.name.endsWith('.zip') && a.browser_download_url);
347
+ if (zips.length === 1) return { url: zips[0].browser_download_url, origin: 'single-zip', assetName: zips[0].name };
348
+ return { url: fallbackUrl(tag), origin: 'conventional-url', assetName: null };
349
+ }
350
+
320
351
  async function resolveRelease() {
321
352
  step(
322
353
  'Finding the latest brain to install',
@@ -346,9 +377,10 @@ async function resolveRelease() {
346
377
  const rel = await fetchJson(RELEASE_API);
347
378
  const tag = rel && rel.tag_name;
348
379
  if (!tag) throw new Error('latest Release has no tag_name');
349
- const asset = Array.isArray(rel.assets) ? rel.assets.find((a) => a.name === ASSET_NAME) : null;
350
- const url = asset && asset.browser_download_url ? asset.browser_download_url : fallbackUrl(tag);
351
- if (!asset) {
380
+ const { url, origin, assetName } = resolveReleaseAsset({ tag, assets: rel.assets });
381
+ if (origin === 'single-zip') {
382
+ warn(`latest Release ${tag} has no ${ASSET_NAME} — using its only .zip asset, ${c.bold(assetName)}`);
383
+ } else if (origin === 'conventional-url') {
352
384
  warn(`latest Release ${tag} has no ${ASSET_NAME} asset listed — using the conventional download URL`);
353
385
  }
354
386
  ok(`latest Release is ${c.bold(tag)}`);
@@ -484,7 +516,51 @@ export function copyLocalBundleInto(sourceDir, cacheDir) {
484
516
  return copied;
485
517
  }
486
518
 
487
- export async function unzipInto(zipPath, cacheDir, sourceDir = null) {
519
+ // ── the trusted coverage validator lives beside the updater, and only the INSTALLER puts it there ──
520
+ // kb/forge-update.mjs (`loadTrustedCoverageValidator`) judges every downloaded bundle with
521
+ // `KB_DIR/coverage-integrity.mjs` and dies — "installed coverage validator is missing; re-run the
522
+ // current installer before self-update" — when it is absent. The bundle cannot be the source: the
523
+ // validator vets the bundle, so it must come from the signed npm package. Measured 2026-09-12: no
524
+ // production path had ever placed it (build-bundle's import walk cannot see the updater's dynamic
525
+ // load; this installer imported the module for its own checks and never copied it), so every
526
+ // 4.3.21 `--update` — the nightly included — died in the updater and fell back to a fresh install,
527
+ // which a private-overlay brain refuses. Idempotent, byte-compared, atomic; a symlink is replaced by
528
+ // a real file because a link is not a trusted regular file.
529
+ const TRUSTED_VALIDATOR_SOURCE = path.join(REPO_ROOT, 'plugin', 'scripts', 'coverage-integrity.mjs');
530
+ export function placeTrustedCoverageValidator(kbDir, { source = TRUSTED_VALIDATOR_SOURCE,
531
+ brainVersion = PACKAGE_VERSION } = {}) {
532
+ let bytes;
533
+ try { bytes = fs.readFileSync(source); }
534
+ catch (error) { throw new Error(`trusted coverage validator is missing from this package (${source}): ${error.message}`); }
535
+ const target = path.join(kbDir, 'coverage-integrity.mjs');
536
+ let existing = null;
537
+ try { existing = fs.lstatSync(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }
538
+ const unchanged = existing && existing.isFile() && !existing.isSymbolicLink() && fs.readFileSync(target).equals(bytes);
539
+ if (!unchanged) {
540
+ const staged = `${target}.${process.pid}.${Date.now()}.tmp`;
541
+ fs.writeFileSync(staged, bytes, { mode: 0o644 });
542
+ if (existing && existing.isSymbolicLink()) fs.unlinkSync(target); // never write through a link
543
+ fs.renameSync(staged, target);
544
+ }
545
+ // STAMP THE APPROVED RUNTIME IN THE SAME BREATH AS PLACING ITS EXECUTABLES (ADR-086 step 16).
546
+ //
547
+ // This is the one moment where "which runtime is this brain running" is a measured fact rather
548
+ // than an assertion: the bytes were just written from THIS package, so the version and the hashes
549
+ // are recorded together and cannot drift. kb/forge-update.mjs re-hashes them before it will accept
550
+ // a corpus release — Dual: "Pinning survives only through enforced equality to the approved
551
+ // shipped runtime and its executable hashes. Copying current-main package.json or preserving a
552
+ // version string alone is insufficient." Re-stamped on every placement (install AND `--update`
553
+ // preflight), so an upgraded runtime immediately supersedes the previous pin.
554
+ const runtime = writeInstalledRuntimeIdentity(kbDir, { brainVersion });
555
+ return { action: unchanged ? 'unchanged' : (existing ? 'replaced' : 'placed'), path: target, runtimeIdentity: runtime };
556
+ }
557
+ /** `--update` preflight: place the validator only where an updater exists to consume it. */
558
+ export function ensureUpdaterPrerequisites(kbDir) {
559
+ if (!fs.existsSync(path.join(kbDir, 'forge-update.mjs'))) return { updater: false, validator: null };
560
+ return { updater: true, validator: placeTrustedCoverageValidator(kbDir) };
561
+ }
562
+
563
+ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTag = null } = {}) {
488
564
  step(
489
565
  'Unpacking the brain into place',
490
566
  'so the plugin finds forge-mcp-all.mjs and the vector stores right where it looks',
@@ -565,6 +641,25 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null) {
565
641
  `The archive layout may have changed. Re-run, or report this at https://github.com/stuinfla/ruvnet-brain/issues`,
566
642
  );
567
643
  }
644
+ // A freshly installed brain must be able to self-update on its first night: the bundle never
645
+ // carries the trusted validator its own updater demands, so the installer lays it into the stage.
646
+ placeTrustedCoverageValidator(stageDir);
647
+ // THE TRANSPORT IDENTITY LANDS WITH THE BYTES. When `releases/latest` is a corpus release, the
648
+ // tag that authenticated these bytes (`corpus-sha256-<64 hex>`) is recorded in the STAGE, so the
649
+ // single rename below promotes the tree and its provenance together or promotes neither. Writing
650
+ // it after activation would leave a window where a crash yields an installed corpus this brain
651
+ // cannot name — and an unnamed corpus is a corpus the updater re-downloads every night.
652
+ // `brainVersion` / `releaseTag` are untouched: they are the runtime the bundle was built by, and
653
+ // a content address is not a version of that runtime (ADR-086 step 16, "keep runtime version
654
+ // distinct"). A non-corpus tag CLEARS any stale corpus tag — see recordCorpusTransportIdentity.
655
+ if (releaseTag) {
656
+ try { recordCorpusTransportIdentity(stageDir, { releaseTag }); }
657
+ catch (error) {
658
+ fs.rmSync(stageDir, { recursive: true, force: true });
659
+ die(`could not record the release identity into the staged brain (${error.message})`,
660
+ 'The live brain was not touched. Re-run the installer.');
661
+ }
662
+ }
568
663
  const stagedCoverage = validateCoverageDirectory(stageDir, { expectedVersion: PACKAGE_VERSION });
569
664
  if (!stagedCoverage.valid) {
570
665
  fs.rmSync(stageDir, { recursive: true, force: true });
@@ -646,7 +741,8 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null) {
646
741
  `Candidate retained for inspection at ${stageDir}.`);
647
742
  }
648
743
  if (hadPrior) warn(`PRESERVED_UNCLASSIFIED: prior generation retained at ${preservedDir}. ` +
649
- 'Not eligible for automatic cleanup; repeated installs can grow disk usage. Inspect manually before removal.');
744
+ 'The updater (kb/forge-update.mjs) releases it only once every byte is proven to survive in the live brain; ' +
745
+ 'until then it stays, and repeated installs can grow disk usage.');
650
746
  ok(`brain unpacked to ${cacheDir}`);
651
747
  return { status: 'ACTIVATED', priorGeneration: hadPrior
652
748
  ? { status: 'PRESERVED_UNCLASSIFIED', path: preservedDir, automaticCleanupEligible: false }
@@ -1182,6 +1278,55 @@ export function claudePluginStatus({ home = os.homedir() } = {}) {
1182
1278
  }
1183
1279
  }
1184
1280
 
1281
+ // A plugin version can change without changing anything a running host freezes at boot. Compare
1282
+ // the actual installed payload with this candidate before updating it so body-only releases do not
1283
+ // manufacture a restart requirement. Unknown paths fail closed: if we cannot prove the boundary,
1284
+ // the caller must retain the restart notice rather than silently promise hot loading.
1285
+ function inspectPluginShellBoundary(installedRoot, candidateRoot = path.join(REPO_ROOT, 'plugin')) {
1286
+ if (!installedRoot || !candidateRoot) {
1287
+ return { known: false, changed: true, paths: [], restartRequired: true, reason: 'plugin boot surface could not be located' };
1288
+ }
1289
+ try {
1290
+ const paths = pluginShellDiff(installedRoot, candidateRoot);
1291
+ return {
1292
+ known: true,
1293
+ changed: paths.length > 0,
1294
+ paths,
1295
+ restartRequired: paths.length > 0,
1296
+ reason: paths.length ? `boot-level declarations changed: ${paths.join(', ')}` : 'body-only plugin update',
1297
+ };
1298
+ } catch (error) {
1299
+ return { known: false, changed: true, paths: [], restartRequired: true,
1300
+ reason: `plugin boot surface comparison failed: ${error.message}` };
1301
+ }
1302
+ }
1303
+
1304
+ function codexInstalledPluginRoot({ codexHome = codexHomeDir(), status } = {}) {
1305
+ const row = status?.row || {};
1306
+ for (const value of [status?.installPath, status?.path, row.installPath, row.path]) {
1307
+ if (typeof value === 'string' && value) {
1308
+ try {
1309
+ const real = fs.realpathSync(value);
1310
+ if (fs.statSync(real).isDirectory()) return real;
1311
+ } catch { /* try the cache scan below */ }
1312
+ }
1313
+ }
1314
+ const cacheRoot = path.join(codexHome, 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain');
1315
+ let candidates = [];
1316
+ try {
1317
+ candidates = fs.readdirSync(cacheRoot, { withFileTypes: true })
1318
+ .filter((entry) => entry.isDirectory())
1319
+ .map((entry) => path.join(cacheRoot, entry.name))
1320
+ .filter((dir) => {
1321
+ try {
1322
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, '.codex-plugin', 'plugin.json'), 'utf8'));
1323
+ return manifest?.name === 'ruvnet-brain' && (!status?.version || manifest.version === status.version);
1324
+ } catch { return false; }
1325
+ });
1326
+ } catch { /* absent cache is handled as unknown below */ }
1327
+ return candidates.sort().at(-1) || null;
1328
+ }
1329
+
1185
1330
  function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false } = {}) {
1186
1331
  step(
1187
1332
  'Wiring the Claude Code plugin',
@@ -1204,6 +1349,9 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1204
1349
 
1205
1350
  const before = claudePluginStatus();
1206
1351
  if (requireManaged && !before.managed) return { host: false, wired: false, action: 'unmanaged' };
1352
+ const shellBoundary = before.installed
1353
+ ? inspectPluginShellBoundary(before.installPath)
1354
+ : { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
1207
1355
  const addedMarket = before.managed
1208
1356
  ? tryRun('claude', ['plugin', 'marketplace', 'update', 'ruvnet-brain'])
1209
1357
  : tryRun('claude', ['plugin', 'marketplace', 'add', marketplaceSource]);
@@ -1224,8 +1372,18 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1224
1372
  const installedHookRetirement = claudeInstalledHookRetirementStatus({ plugin: installed });
1225
1373
  if (installed.installed && versionSatisfies(installed.version, expectedVersion) && installedHookRetirement.ok) {
1226
1374
  ok(`plugin installed at user scope (global, alongside Ruflo / RuVector) — exact version ${installed.version}`);
1227
- info(` commands available after a restart: ${c.bold('/rvbc')}, ${c.bold('/ruvnet-brain:configure')}`);
1228
- return { host: true, wired: true, version: installed.version, manualMarketplace, manualInstall };
1375
+ if (shellBoundary.restartRequired) {
1376
+ warn(`boot-level plugin declarations changed; restart Claude Code once to load them (${shellBoundary.paths.join(', ') || shellBoundary.reason}).`);
1377
+ } else if (before.installed && before.version !== installed.version) {
1378
+ info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
1379
+ }
1380
+ info(` commands available${shellBoundary.restartRequired ? ' after a restart' : ' immediately'}: ${c.bold('/rvbc')}, ${c.bold('/ruvnet-brain:configure')}`);
1381
+ return {
1382
+ host: true, wired: true, version: installed.version, manualMarketplace, manualInstall,
1383
+ shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
1384
+ restartRequired: shellBoundary.restartRequired,
1385
+ ...(shellBoundary.restartRequired ? { sessionSafety: 'restart-required', sessionSafetyReason: shellBoundary.reason } : {}),
1386
+ };
1229
1387
  }
1230
1388
 
1231
1389
  // The honest failure. The brain still WORKS — this is the difference between a broken install and
@@ -1654,6 +1812,7 @@ export function codexPluginStatus(options = {}) {
1654
1812
  installed: Boolean(row?.installed),
1655
1813
  enabled: Boolean(row?.enabled),
1656
1814
  version: row?.version || null,
1815
+ installPath: row?.installPath || row?.path || null,
1657
1816
  row: row || null,
1658
1817
  };
1659
1818
  }
@@ -1691,9 +1850,22 @@ export function wireCodexPlugin({
1691
1850
  if (announce) warn(`Codex Brain plugin is installed but disabled by user or policy — left disabled (${CODEX_PLUGIN_ID}).`);
1692
1851
  return { host: true, action: 'disabled', ...before };
1693
1852
  }
1853
+ // An existing Codex session can keep the plugin generation it loaded at boot. Compare the
1854
+ // installed bytes with the source candidate before mutating the marketplace so body-only updates
1855
+ // remain live while a changed/unknown boot surface gets one explicit restart request.
1856
+ const shellBoundary = before.installed
1857
+ ? inspectPluginShellBoundary(
1858
+ codexInstalledPluginRoot({ codexHome, status: before }),
1859
+ path.join(REPO_ROOT, 'plugin'),
1860
+ )
1861
+ : { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
1694
1862
  if (before.installed && before.enabled && versionSatisfies(before.version, expectedVersion)) {
1695
1863
  if (announce) ok(`Codex Brain plugin already installed and enabled (${before.version || 'version unknown'}) — no changes.`);
1696
- return { host: true, action: 'unchanged', ...before };
1864
+ return {
1865
+ host: true, action: 'unchanged', ...before,
1866
+ shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
1867
+ restartRequired: false,
1868
+ };
1697
1869
  }
1698
1870
 
1699
1871
  if (runJson === runCodexJson && !localMarketplace) {
@@ -1735,17 +1907,23 @@ export function wireCodexPlugin({
1735
1907
  }
1736
1908
  if (announce) {
1737
1909
  ok(`Codex Brain plugin installed and enabled (${after.version || 'version unknown'}).`);
1738
- warn('Existing Codex app-server sessions may retain the previous plugin path; restart Codex before using the updated plugin.');
1910
+ if (shellBoundary.restartRequired) {
1911
+ warn(`boot-level plugin declarations changed; restart Codex once to load them (${shellBoundary.paths.join(', ') || shellBoundary.reason}).`);
1912
+ } else if (before.installed && before.version !== after.version) {
1913
+ info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
1914
+ }
1739
1915
  }
1740
1916
  return {
1741
1917
  host: true,
1742
1918
  action: before.installed ? 'updated' : 'installed',
1743
1919
  ...after,
1744
- // Codex currently exposes no generation/session lease API. This explicit guard prevents the
1745
- // installer from implying that a native cache update is safe for an already-running session.
1746
- sessionSafety: 'restart-required',
1747
- restartRequired: true,
1748
- sessionSafetyReason: 'Codex host cache generations are owned by Codex and have no lease API',
1920
+ shellChanged: shellBoundary.changed,
1921
+ shellChangedPaths: shellBoundary.paths,
1922
+ restartRequired: shellBoundary.restartRequired,
1923
+ ...(shellBoundary.restartRequired ? {
1924
+ sessionSafety: 'restart-required',
1925
+ sessionSafetyReason: shellBoundary.reason,
1926
+ } : {}),
1749
1927
  };
1750
1928
  }
1751
1929
 
@@ -1818,14 +1996,25 @@ export function classifyCodexLifecycle(plugin, listed = null) {
1818
1996
  if (!plugin.enabled) return { state: 'disabled', plugin, hooks: [] };
1819
1997
  if (!listed.ok) return { state: 'probe-failed', plugin, hooks: [], error: listed.error };
1820
1998
  const groups = Array.isArray(listed.value?.data) ? listed.value.data : [];
1821
- const hooks = groups.flatMap((group) => Array.isArray(group?.hooks) ? group.hooks : [])
1999
+ const hooks = groups.flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : [])
2000
+ .map((hook) => ({ ...hook, event: hook?.event ?? group?.event ?? null })))
1822
2001
  .filter((hook) => hook?.pluginId === CODEX_PLUGIN_ID);
1823
2002
  const errors = groups.flatMap((group) => Array.isArray(group?.errors) ? group.errors : []);
1824
2003
  if (errors.length) {
1825
2004
  return { state: 'missing-runtime-hooks', plugin, hooks, errors };
1826
2005
  }
1827
- if (hooks.length === 0) return { state: 'inactive-by-design', plugin, hooks, errors };
1828
- return { state: 'unexpected-runtime-hooks', plugin, hooks, errors };
2006
+ // A REGISTERED CONTINUITY HOOK IS NOT A RETIRED ONE.
2007
+ //
2008
+ // This used to treat EVERY Brain-owned runtime registration as stale, because at the time the
2009
+ // policy permitted none on Codex. The doctor therefore reported the SessionStart restore and the
2010
+ // Stop continuation gate — the two handlers the policy itself requires — as "retired Brain
2011
+ // lifecycle hooks", told the user to upgrade, and returned a failing exit code for a correctly
2012
+ // wired machine. The authority on what belongs is the policy, so ask it instead of assuming zero.
2013
+ const conforming = hooks.filter((hook) => continuityHookId(hook?.command, hook?.event));
2014
+ const unexpected = hooks.filter((hook) => !continuityHookId(hook?.command, hook?.event));
2015
+ if (unexpected.length) return { state: 'unexpected-runtime-hooks', plugin, hooks: unexpected, errors };
2016
+ if (conforming.length === 0) return { state: 'inactive-by-design', plugin, hooks, errors };
2017
+ return { state: 'continuity-registered', plugin, hooks: conforming, errors };
1829
2018
  }
1830
2019
 
1831
2020
  export async function codexLifecycleStatus(options = {}) {
@@ -1847,6 +2036,16 @@ export function codexLifecycleGuidance(status) {
1847
2036
  detail: 'Any Brain-owned runtime registration is stale and must not be trusted or executed.',
1848
2037
  action: `Upgrade ${CODEX_PLUGIN_ID}, then start a fresh Codex session and re-run --doctor.`,
1849
2038
  };
2039
+ case 'continuity-registered':
2040
+ return {
2041
+ healthy: true,
2042
+ intentional: true,
2043
+ summary: `Codex carries ${hookCount} declared Brain continuity hook${hookCount === 1 ? '' : 's'}.`,
2044
+ detail: 'Each one is named in plugin/hooks/hook-contracts.json. Codex capture is SessionEnd only:'
2045
+ + ' a 2026-09-11 probe observed SessionStart, UserPromptSubmit and SessionEnd firing, and did not'
2046
+ + ' observe Stop or PreCompact, so no capture handler was registered on those.',
2047
+ action: null,
2048
+ };
1850
2049
  case 'inactive-by-design':
1851
2050
  return {
1852
2051
  healthy: true,
@@ -1971,22 +2170,31 @@ export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'sourc
1971
2170
  // Project-local host settings must remain empty. The package registries may carry only
1972
2171
  // the two continuity handlers; every former gate is still retired.
1973
2172
  const packageRegistry = relative === 'plugin/hooks/hooks.json' || relative === 'plugin/hooks/codex-hooks.json';
1974
- if (!packageRegistry || !isAllowedContinuityRegistration(row)) registrations.push(row);
2173
+ const host = relative === 'plugin/hooks/codex-hooks.json' ? 'codex' : 'claude';
2174
+ if (!packageRegistry || !isAllowedContinuityRegistration({ ...row, host })) registrations.push(row);
1975
2175
  }
1976
2176
  }
1977
2177
  }
1978
2178
  if (relative === 'plugin/hooks/hooks.json' || relative === 'plugin/hooks/codex-hooks.json') {
1979
- for (const [event, spec] of Object.entries(CONTINUITY_EVENTS)) {
1980
- const count = (doc.hooks[event] ?? []).flatMap((group) => group?.hooks ?? [])
1981
- .filter((hook, index, hooks) => isAllowedContinuityRegistration({
1982
- event,
1983
- matcher: (doc.hooks[event] ?? []).find((group) => (group.hooks ?? []).includes(hook))?.matcher,
1984
- command: hook?.command,
1985
- })).length;
1986
- if (count !== 1) errors.push(`${relative}: continuity ${spec.id} must have exactly one registration (found ${count})`);
2179
+ // PAIRS, NOT IDS. `session-snapshot` is legitimately registered at Stop, PreCompact and
2180
+ // SessionEnd, so "exactly one registration per id" is the wrong invariant; "exactly one per
2181
+ // (event, id), on the hosts that are proven to deliver that event" is the right one.
2182
+ const host = relative === 'plugin/hooks/codex-hooks.json' ? 'codex' : 'claude';
2183
+ const expected = continuityRegistrations(host);
2184
+ for (const spec of expected) {
2185
+ const groups = doc.hooks[spec.event] ?? [];
2186
+ const count = groups.flatMap((group) => (group?.hooks ?? []).map((hook) => ({ group, hook })))
2187
+ .filter(({ group, hook }) => String(group?.matcher ?? '') === spec.matcher
2188
+ && isAllowedContinuityRegistration({ event: spec.event, matcher: group?.matcher, command: hook?.command, host })
2189
+ && continuityHookId(hook?.command, spec.event)?.id === spec.id).length;
2190
+ if (count !== 1) {
2191
+ errors.push(`${relative}: continuity ${spec.event}:${spec.id} must have exactly one registration (found ${count})`);
2192
+ }
1987
2193
  }
1988
2194
  for (const event of Object.keys(doc.hooks)) {
1989
- if (!CONTINUITY_EVENTS[event]) errors.push(`${relative}: legacy automatic event ${event} remains registered`);
2195
+ if (!expected.some((spec) => spec.event === event)) {
2196
+ errors.push(`${relative}: legacy automatic event ${event} remains registered`);
2197
+ }
1990
2198
  }
1991
2199
  }
1992
2200
  } catch (error) {
@@ -1997,12 +2205,19 @@ export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'sourc
1997
2205
  const contractsFile = 'plugin/hooks/hook-contracts.json';
1998
2206
  const contracts = JSON.parse(fs.readFileSync(path.join(root, contractsFile), 'utf8'));
1999
2207
  checkedFiles.push(contractsFile);
2000
- const ids = Array.isArray(contracts.contracts) ? contracts.contracts.map((c) => c?.id) : [];
2001
- if (ids.length !== continuityContractIds().length || continuityContractIds().some((id) => !ids.includes(id))) {
2002
- errors.push(`${contractsFile}: contracts must list only the continuity handlers (${continuityContractIds().join(', ')})`);
2208
+ const pairs = Array.isArray(contracts.contracts) ? contracts.contracts.map((c) => `${c?.event}:${c?.id}`) : [];
2209
+ const expectedPairs = continuityContractIds();
2210
+ if (pairs.length !== expectedPairs.length || expectedPairs.some((pair) => !pairs.includes(pair))) {
2211
+ errors.push(`${contractsFile}: contracts must list exactly the continuity handlers (${expectedPairs.join(', ')})`);
2003
2212
  }
2004
- if (!Array.isArray(contracts.matcherAllowlist) || contracts.matcherAllowlist.length !== continuityContractIds().length) {
2005
- errors.push(`${contractsFile}: matcherAllowlist must list the two continuity matchers`);
2213
+ // One allowlist entry per DISTINCT (event, matcher): the matcher is a property of the event, so
2214
+ // three snapshot registrations on three events need three entries, not three copies of one.
2215
+ const expectedMatchers = [...new Set(continuityRegistrations().map((spec) => `${spec.event}:${spec.matcher}`))];
2216
+ const declaredMatchers = Array.isArray(contracts.matcherAllowlist)
2217
+ ? contracts.matcherAllowlist.map((row) => `${row?.event}:${row?.matcher}`) : [];
2218
+ if (declaredMatchers.length !== expectedMatchers.length
2219
+ || expectedMatchers.some((entry) => !declaredMatchers.includes(entry))) {
2220
+ errors.push(`${contractsFile}: matcherAllowlist must list exactly the continuity matchers (${expectedMatchers.join(', ')})`);
2006
2221
  }
2007
2222
  } catch (error) {
2008
2223
  errors.push(`plugin/hooks/hook-contracts.json: ${error.message}`);
@@ -2058,17 +2273,21 @@ export function claudeInstalledHookRetirementStatus({ home = os.homedir(), plugi
2058
2273
  }
2059
2274
  }
2060
2275
  }
2061
- for (const [event, spec] of Object.entries(CONTINUITY_EVENTS)) {
2062
- const count = (doc.hooks[event] ?? []).flatMap((group) => group?.hooks ?? [])
2063
- .filter((hook) => isAllowedContinuityRegistration({
2064
- event,
2065
- matcher: (doc.hooks[event] ?? []).find((group) => (group.hooks ?? []).includes(hook))?.matcher,
2066
- command: hook?.command,
2067
- })).length;
2068
- if (count !== 1) errors.push(`installed Claude continuity ${spec.id} must have exactly one registration (found ${count})`);
2276
+ const expected = continuityRegistrations('claude');
2277
+ for (const spec of expected) {
2278
+ const count = (doc.hooks[spec.event] ?? [])
2279
+ .flatMap((group) => (group?.hooks ?? []).map((hook) => ({ group, hook })))
2280
+ .filter(({ group, hook }) => String(group?.matcher ?? '') === spec.matcher
2281
+ && isAllowedContinuityRegistration({ event: spec.event, matcher: group?.matcher, command: hook?.command, host: 'claude' })
2282
+ && continuityHookId(hook?.command, spec.event)?.id === spec.id).length;
2283
+ if (count !== 1) {
2284
+ errors.push(`installed Claude continuity ${spec.event}:${spec.id} must have exactly one registration (found ${count})`);
2285
+ }
2069
2286
  }
2070
2287
  for (const event of Object.keys(doc.hooks)) {
2071
- if (!CONTINUITY_EVENTS[event]) errors.push(`installed Claude legacy automatic event ${event} remains registered`);
2288
+ if (!expected.some((spec) => spec.event === event)) {
2289
+ errors.push(`installed Claude legacy automatic event ${event} remains registered`);
2290
+ }
2072
2291
  }
2073
2292
  }
2074
2293
  } catch (error) {
@@ -2212,7 +2431,19 @@ async function smokeQuery(cacheDir) {
2212
2431
  const secs = ((Date.now() - started) / 1000).toFixed(1);
2213
2432
  const out = `${r.stdout || ''}`;
2214
2433
  if (r.status !== 0 || !out.trim()) {
2215
- warn('no answer came back (first-run model download or offline) — the brain is installed; it\'ll warm on your first real question');
2434
+ // NAME THE ACTUAL CAUSE, DO NOT GUESS A REASSURING ONE.
2435
+ //
2436
+ // This said "first-run model download or offline" unconditionally. It is one plausible cause
2437
+ // among several, asserted as though it had been checked — and it is the reassuring one, so a
2438
+ // crash, a timeout, and a missing module all read as "nothing is wrong, it will warm up".
2439
+ // Observed on this machine: a smoke query that produced no answer in 240s was reported as a
2440
+ // first-run download. spawnSync already tells us which it was; say that instead.
2441
+ const cause = r.error ? `could not launch the reader: ${r.error.message}`
2442
+ : r.signal === 'SIGTERM' ? `timed out after ${secs}s (240s limit) with no answer`
2443
+ : r.signal ? `the reader was killed by ${r.signal} after ${secs}s`
2444
+ : r.status !== 0 ? `the reader exited ${r.status} after ${secs}s`
2445
+ : `the reader exited 0 after ${secs}s but printed nothing`;
2446
+ warn(`no answer came back — ${cause}`);
2216
2447
  // SHOW THE ACTUAL ERROR (issue #37 bug 2, Agentist-Elder, 2026-07-21).
2217
2448
  //
2218
2449
  // This captured stderr and then threw it away, so every hard failure — a crash, a missing
@@ -2232,7 +2463,9 @@ async function smokeQuery(cacheDir) {
2232
2463
  for (const line of lines.slice(0, 12)) info(c.dim(` ${line.slice(0, 200)}`));
2233
2464
  if (lines.length > 12) info(c.dim(` … ${lines.length - 12} more line(s)`));
2234
2465
  }
2235
- return { ran: true, grounded: false, reason: 'no-answer', stderr: err.slice(0, 4000) };
2466
+ // The reason travels with the verdict so the doctor's "Grounding NOT proven (<reason>)" line
2467
+ // names the real cause too, instead of the generic token.
2468
+ return { ran: true, grounded: false, reason: `no-answer: ${cause}`, secs, stderr: err.slice(0, 4000) };
2236
2469
  }
2237
2470
 
2238
2471
  const verifier = await loadCitationVerifier(cacheDir);
@@ -2530,11 +2763,16 @@ async function doctor() {
2530
2763
  const v = verifyInstall(cacheDir);
2531
2764
  const smoke = await smokeQuery(cacheDir);
2532
2765
  const allGreen = v.repos > 0 && v.reader && v.mcp;
2533
- console.log(
2534
- `\n ${allGreen ? c.green('✓ Healthy.') : c.yellow('! Needs attention.')} ${
2535
- allGreen ? 'The brain is installed and reachable.' : 'Re-run the installer to fix the warnings above.'
2536
- }`,
2537
- );
2766
+ // ONE VERDICT, AND IT COMES AFTER ITS EVIDENCE.
2767
+ //
2768
+ // This line used to print "✓ Healthy." here, from `allGreen` — which reads only repos/reader/mcp —
2769
+ // and then a SECOND verdict, "✗ FAILING", printed ~120 lines later from the much wider `failed`.
2770
+ // A --doctor run genuinely emitted both, and exited 0. Two verdicts is not a cosmetic problem: a
2771
+ // reader stops at the first one, so the tool told people they were healthy while its own exit-code
2772
+ // logic had already decided otherwise. The single verdict is now emitted at the end, where every
2773
+ // input to `failed` exists; this position keeps only the installed-and-reachable READING.
2774
+ console.log(`\n ${allGreen ? c.green('Install: present and reachable.')
2775
+ : c.yellow('Install: incomplete — see the warnings above.')}`);
2538
2776
  // Installed-and-reachable and actually-grounded are different claims. Keep them separate, so a
2539
2777
  // healthy install can never be mistaken for proven grounding.
2540
2778
  if (smoke.grounded === true) {
@@ -2653,8 +2891,11 @@ async function doctor() {
2653
2891
  || codexReadinessFailed
2654
2892
  || !hostConvergence.healthy
2655
2893
  || Boolean(rufloOperational && !rufloOperational.healthy);
2656
- if (failed && !hookResult && !groundingUnprovenPersisted) {
2657
- console.log(` ${c.red('✗ FAILING')} — the warnings above are real. Re-run ${c.bold('npx ruvnet-brain')} to repair.`);
2894
+ // THE ONE VERDICT. Always printed, always consistent with the exit code, never alongside another.
2895
+ if (failed) {
2896
+ console.log(`\n ${c.red('✗ FAILING')} — the warnings above are real. Re-run ${c.bold('npx ruvnet-brain')} to repair.`);
2897
+ } else {
2898
+ console.log(`\n ${c.green('✓ Healthy.')} The brain is installed, reachable, and its checks pass.`);
2658
2899
  }
2659
2900
  return failed ? 1 : 0;
2660
2901
  }
@@ -2722,6 +2963,20 @@ function cmpTag(a, b) {
2722
2963
  return A.pre > B.pre ? 1 : -1;
2723
2964
  }
2724
2965
 
2966
+ /**
2967
+ * The CORPUS transport tag this brain last received, or null.
2968
+ *
2969
+ * Deliberately separate from installedBrainVersion(): that one answers "which runtime built the KB
2970
+ * on disk" (a semver), this one answers "which published corpus archive is on disk" (a content
2971
+ * address). Conflating them is the defect ADR-086 step 16 exists to fix.
2972
+ */
2973
+ function installedCorpusTag(cacheDir) {
2974
+ try {
2975
+ const tag = JSON.parse(fs.readFileSync(path.join(cacheDir, 'SOURCE.json'), 'utf8')).corpusReleaseTag;
2976
+ return isCorpusReleaseTag(tag) ? tag : null;
2977
+ } catch { return null; }
2978
+ }
2979
+
2725
2980
  function installedBrainVersion(cacheDir) {
2726
2981
  // Same read the telemetry ping uses: the bundle stamps its Release tag into SOURCE.json.
2727
2982
  // "unknown" is honest for a locally-built or pre-stamping bundle — never guess a tag.
@@ -2802,7 +3057,7 @@ function reportVersionDrift(cacheDir) {
2802
3057
  if (!state.drift) return state;
2803
3058
  warn(`the brain (${c.bold(state.kb)}) and the Claude Code plugin (${c.bold(state.wrapper)}) have drifted apart —`);
2804
3059
  info(`that's normal (they update on separate schedules) and neither one is broken. To bring the`);
2805
- info(`plugin up to date: ${c.bold('claude plugin marketplace update ruvnet-brain')} ${c.dim('(then restart Claude Code)')}`);
3060
+ info(`plugin up to date: ${c.bold('claude plugin marketplace update ruvnet-brain')} ${c.dim('(body updates go live without a restart; boot-surface changes are called out)')}`);
2806
3061
  return state;
2807
3062
  }
2808
3063
 
@@ -2826,7 +3081,11 @@ function feedbackHealthLines(cacheDir) {
2826
3081
  return [
2827
3082
  `${s.repos} repo stores on disk · reader ${s.reader ? 'ok' : 'MISSING'} · search_ruvnet ${s.mcp ? 'ok' : 'MISSING'} · plugin ${s.plugin ? 'ok' : 'NOT INSTALLED (no /rvbc)'}`,
2828
3083
  `toolkit: Ruflo ${env.ruflo ? 'present' : 'not found'} · RuVector ${env.ruvector ? 'present' : 'not found'} · claude CLI ${env.claude ? 'present' : 'not found'}`,
2829
- allGreen ? 'verdict: Healthy — installed and reachable' : 'verdict: Needs attention — re-run npx ruvnet-brain',
3084
+ // NOT called a "verdict": this reads only repos/reader/mcp, while --doctor's verdict also weighs
3085
+ // grounding, Codex wiring, nightly health, host convergence and the hook policy. Two lines both
3086
+ // labelled "verdict" that answer different questions can disagree in public, which is the exact
3087
+ // Healthy-and-FAILING confusion the doctor's single verdict was collapsed to remove.
3088
+ allGreen ? 'install reading: present and reachable' : 'install reading: incomplete — re-run npx ruvnet-brain',
2830
3089
  ];
2831
3090
  }
2832
3091
 
@@ -3061,6 +3320,15 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3061
3320
  codexReceipt.sessionSafety = results.codex.sessionSafety || null;
3062
3321
  codexReceipt.sessionSafetyReason = results.codex.sessionSafetyReason || null;
3063
3322
  }
3323
+ const claudeReceipt = {
3324
+ state: results.claude.host ? 'ready' : 'absent',
3325
+ version: results.claude.version || null,
3326
+ };
3327
+ if (results.claude?.restartRequired === true) {
3328
+ claudeReceipt.restartRequired = true;
3329
+ claudeReceipt.sessionSafety = results.claude.sessionSafety || null;
3330
+ claudeReceipt.sessionSafetyReason = results.claude.sessionSafetyReason || null;
3331
+ }
3064
3332
  if (okApplied) {
3065
3333
  // ISSUE #153 — a running host may freeze an old plugin root. Reclaim only generations whose
3066
3334
  // modern leases prove no live consumer; legacy roots without that proof stay on disk.
@@ -3093,7 +3361,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3093
3361
  desiredVersion: PACKAGE_VERSION,
3094
3362
  verifiedAt: new Date().toISOString(),
3095
3363
  hosts: {
3096
- claude: { state: results.claude.host ? 'ready' : 'absent', version: results.claude.version || null },
3364
+ claude: claudeReceipt,
3097
3365
  codex: codexReceipt,
3098
3366
  },
3099
3367
  consoleRuntime: results.consoleRuntime,
@@ -3108,7 +3376,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3108
3376
  const convergence = classifyHostConvergence({
3109
3377
  desiredVersion: PACKAGE_VERSION,
3110
3378
  hosts: {
3111
- claude: { state: results.claude.host ? 'ready' : 'absent', version: results.claude.version || null },
3379
+ claude: claudeReceipt,
3112
3380
  codex: codexReceipt,
3113
3381
  },
3114
3382
  consoleRuntime: results.consoleRuntime,
@@ -3229,6 +3497,17 @@ function runUpdate() {
3229
3497
  settleRefresh(1, { phase: 'source-enumeration' });
3230
3498
  return;
3231
3499
  }
3500
+ // The updater exists; make sure the trusted validator it will load exists too (package copy,
3501
+ // byte-compared). A failure here is reported, not fatal — the updater states the same absence
3502
+ // in its own words and the run then takes the documented fallback.
3503
+ try {
3504
+ const prerequisites = ensureUpdaterPrerequisites(kbDir);
3505
+ if (prerequisites.validator?.action !== 'unchanged') {
3506
+ info(c.dim(`trusted coverage validator ${prerequisites.validator.action} beside the updater`));
3507
+ }
3508
+ } catch (error) {
3509
+ warn(`could not place the trusted coverage validator (${error.message}); the updater will report what it finds`);
3510
+ }
3232
3511
  info(c.dim("running the bundle's own self-updater (backs up first, re-verifies, never half-applies)…\n"));
3233
3512
  // Relative filename + matching cwd — same launch convention as smokeQuery(); stdio:'inherit'
3234
3513
  // streams the updater's narration live and unedited.
@@ -3575,6 +3854,12 @@ export function machineFootprint() {
3575
3854
  const artifact = nightlyArtifact({ platform: process.platform, env: process.env });
3576
3855
  if (artifact.kind === 'launchd') add('Nightly updater (LaunchAgent)', artifact.path, 'npx ruvnet-brain --disable-nightly');
3577
3856
  add('Nightly scheduler registration', path.join(process.env.RUVNET_BRAIN_HOME || path.dirname(resolvedKbDir()), 'scheduler', 'registration.json'), 'npx ruvnet-brain --disable-nightly');
3857
+ // Only ever present after the updater refused an incompatible corpus release (ADR-086 step 16).
3858
+ // It lives BESIDE the KB rather than inside it so an exact-tree update cannot erase the memory of
3859
+ // the refusal — which is what keeps a refusal from being rediscovered, and re-downloaded, nightly.
3860
+ // Declared here because a file we write is a file we own and must be able to take back.
3861
+ add('Rejected-release memo (only after an incompatible corpus release)',
3862
+ rejectedReleasePath(resolvedKbDir()), 'npx ruvnet-brain --uninstall');
3578
3863
  }
3579
3864
  if (process.platform === 'darwin') {
3580
3865
  add('Spend watchdog (LaunchAgent)', spendGuardPlistPath(), 'npx ruvnet-brain --disable-spend-guard');
@@ -3755,6 +4040,7 @@ function uninstallAll() {
3755
4040
  // owns (settings.json entries, the MCP registration) and the Claude Code plugin itself are not
3756
4041
  // ours to delete, so they are handed over as commands.
3757
4042
  const AUTO = new Set(['Brain bundle (knowledge base)', 'Nightly updater (LaunchAgent)', 'Nightly scheduler registration',
4043
+ 'Rejected-release memo (only after an incompatible corpus release)',
3758
4044
  'Spend watchdog (LaunchAgent)', 'Spend watchdog script', 'CLAUDE.md block (6 lines, between markers)',
3759
4045
  'Model-router files', 'Status-bar version script', 'Status-bar preference', 'Usage-counts preference',
3760
4046
  // Two gaps closed here: the statusLine KEY is now removable in place (we know exactly what we
@@ -4379,7 +4665,11 @@ export function classifyRufloOperationalHealth({ status = '', memory = '', metri
4379
4665
 
4380
4666
  function probeRufloOperationalHealth() {
4381
4667
  const run = (args) => {
4382
- const result = spawnSync('ruflo', args, { cwd: process.cwd(), encoding: 'utf8', timeout: 10_000 });
4668
+ // Every `ruflo` invocation auto-starts a project background daemon unless this is set
4669
+ // (verified live: ~/.npm-global/lib/node_modules/ruflo/node_modules/@claude-flow/cli/dist/src/
4670
+ // services/daemon-autostart.js:85) — a read-only health probe must not leave one running.
4671
+ const result = spawnSync('ruflo', args, { cwd: process.cwd(), encoding: 'utf8', timeout: 10_000,
4672
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
4383
4673
  return `${result.stdout || ''}\n${result.stderr || ''}`;
4384
4674
  };
4385
4675
  return classifyRufloOperationalHealth({
@@ -4871,10 +5161,8 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
4871
5161
  console.log(`\n ${c.bold('Keep it fresh:')} re-run ${c.bold('npx ruvnet-brain')} any time — the brain itself always pulls the latest`);
4872
5162
  console.log(` Release regardless (that part isn't cached). For the bleeding-edge installer too, use ${c.bold('npx github:stuinfla/ruvnet-brain')}.`);
4873
5163
 
4874
- // Hosts cache plugin registries for the lifetime of a session. A restart is therefore required to
4875
- // unload callbacks from an older hook-bearing generation even though this version registers none.
4876
- console.log(`\n ${c.yellow(c.bold('One required cleanup step:'))} restart any ${hostLabel} window that was open before this update.`);
4877
- console.log(` ${c.dim('That unloads the old in-memory hook registry; new sessions load the intentional empty registry.')}`);
5164
+ console.log(`\n ${c.bold('Updates take effect:')} body-only releases go live on the next hook/MCP call without a restart.`);
5165
+ console.log(` ${c.dim('If boot-level declarations change, the installer names the affected host and requests one restart.')}`);
4878
5166
 
4879
5167
  // ── what to do now ──
4880
5168
  console.log(`\n ${c.bold('What to do now:')}`);
@@ -4982,8 +5270,8 @@ Env:
4982
5270
  download or an air-gapped machine is not a broken install). Only ever set
4983
5271
  this for a locked-down environment where you want to know immediately.
4984
5272
 
4985
- It is safe to re-run at any time. After updating from a hook-bearing version, restart open hosts so
4986
- their old in-memory hook registries unload.
5273
+ It is safe to re-run at any time. Body updates go live on the next hook/MCP call. Restart only when
5274
+ the installer reports that boot-level declarations changed.
4987
5275
  `);
4988
5276
  }
4989
5277
 
@@ -5079,21 +5367,33 @@ their old in-memory hook registries unload.
5079
5367
  ? (resolvedRelease.tag_name || resolvedRelease.tag || null)
5080
5368
  : null;
5081
5369
  } catch { latestTag = null; }
5082
- const norm = (v) => (v == null || v === 'unknown' ? null : String(v).replace(/^v/, ''));
5083
- const a = norm(installedTag), b = norm(latestTag);
5084
- // BEHIND => download. SAME => skip. AHEAD => skip, and say so honestly.
5085
- //
5086
- // This was a bare `a !== b`, which treats "newer than the latest release" as staleness. Anyone
5087
- // running a pre-release or dev build — or who simply updated in the window before a release was
5088
- // cut — was told "Brain is out of date" and pushed through a 2 GB download that would DOWNGRADE
5089
- // them. Found 2026-07-22 the moment this repo's own version moved to 3.5.0-dev ahead of the
5090
- // 3.4.22-dev release: the installer immediately declared its own newest brain stale.
5091
- //
5092
- // stack-sync.mjs has modelled AHEAD as legal from the start ("AHEAD is legal and produces NO
5093
- // recommendation — that modelling choice is what makes the alpha-vs-latest downgrade war
5094
- // structurally impossible"). The installer never learned the same lesson. It has now.
5095
- ahead = Boolean(a && b && cmpTag(a, b) > 0);
5096
- staleSkip = Boolean(b && (a === null || (a !== b && !ahead)));
5370
+ // A CORPUS RELEASE IS NOT ORDERABLE AGAINST A SEMVER (ADR-086 step 16). `corpus-sha256-<64 hex>`
5371
+ // is a content address; cmpTag() parses its leading segment with parseInt, gets NaN, floors it
5372
+ // to 0, and concludes any installed version is NEWER — so a corpus release would be reported as
5373
+ // "installed is NEWER than the latest release" and silently skipped, forever. Compare the
5374
+ // installed corpus tag against the offered corpus tag instead: equal = current, different =
5375
+ // behind, never "ahead", because content addresses have no order.
5376
+ if (isCorpusReleaseTag(latestTag)) {
5377
+ const installedCorpus = installedCorpusTag(cacheDir);
5378
+ ahead = false;
5379
+ staleSkip = installedCorpus !== latestTag;
5380
+ } else {
5381
+ const norm = (v) => (v == null || v === 'unknown' ? null : String(v).replace(/^v/, ''));
5382
+ const a = norm(installedTag), b = norm(latestTag);
5383
+ // BEHIND => download. SAME => skip. AHEAD => skip, and say so honestly.
5384
+ //
5385
+ // This was a bare `a !== b`, which treats "newer than the latest release" as staleness. Anyone
5386
+ // running a pre-release or dev build — or who simply updated in the window before a release was
5387
+ // cut — was told "Brain is out of date" and pushed through a 2 GB download that would DOWNGRADE
5388
+ // them. Found 2026-07-22 the moment this repo's own version moved to 3.5.0-dev ahead of the
5389
+ // 3.4.22-dev release: the installer immediately declared its own newest brain stale.
5390
+ //
5391
+ // stack-sync.mjs has modelled AHEAD as legal from the start ("AHEAD is legal and produces NO
5392
+ // recommendation — that modelling choice is what makes the alpha-vs-latest downgrade war
5393
+ // structurally impossible"). The installer never learned the same lesson. It has now.
5394
+ ahead = Boolean(a && b && cmpTag(a, b) > 0);
5395
+ staleSkip = Boolean(b && (a === null || (a !== b && !ahead)));
5396
+ }
5097
5397
  }
5098
5398
 
5099
5399
  if (alreadyInstalled && !FLAG_FORCE && !staleSkip) {
@@ -5152,7 +5452,12 @@ their old in-memory hook registries unload.
5152
5452
  ok(reason);
5153
5453
  }
5154
5454
  }
5155
- await unzipInto(zipPath, cacheDir, sourceDir);
5455
+ // The tag is only carried when it came from a genuine `latest` resolution — a pinned/offline
5456
+ // fallback is not evidence of which release these bytes are, and recording a guess would be
5457
+ // worse than recording nothing.
5458
+ await unzipInto(zipPath, cacheDir, sourceDir, {
5459
+ releaseTag: release && release.source === 'latest' ? (release.tag_name || release.tag || null) : null,
5460
+ });
5156
5461
  const brainProfile = readBrainProfile();
5157
5462
  if (brainProfile !== 'complete') {
5158
5463
  const scoped = applyBrainProfile(cacheDir, brainProfile);