ruvnet-brain 4.3.21 → 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 (141) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +275 -60
  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/nightly-scheduler.mjs +37 -4
  30. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  31. package/plugin/scripts/project-progression-contract.mjs +16 -0
  32. package/plugin/scripts/project-progression-hook.mjs +3 -0
  33. package/plugin/scripts/project-progression-producer.mjs +252 -0
  34. package/plugin/scripts/project-progression-reader.mjs +271 -0
  35. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  36. package/plugin/scripts/project-progression-sources.mjs +220 -0
  37. package/plugin/scripts/project-progression-store.mjs +106 -13
  38. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  39. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  40. package/plugin/scripts/session-start-budget.mjs +59 -0
  41. package/plugin/scripts/session-start-core.mjs +234 -457
  42. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  43. package/plugin/scripts/session-start-health.mjs +64 -0
  44. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  45. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  46. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  47. package/plugin/scripts/session-start-signals.mjs +73 -0
  48. package/plugin/scripts/session-start-trace.mjs +86 -0
  49. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  50. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  51. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  52. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  53. package/scripts/adr-072-completion.mjs +1 -1
  54. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  55. package/scripts/approved-runtime.mjs +197 -0
  56. package/scripts/brain-novice-50.mjs +16 -1
  57. package/scripts/brain-score.mjs +23 -5
  58. package/scripts/build-bundle.mjs +971 -530
  59. package/scripts/build-concepts.mjs +36 -116
  60. package/scripts/console-engine.test.mjs +8 -7
  61. package/scripts/console-runtime-identity.mjs +4 -0
  62. package/scripts/corpus-aggregates.mjs +94 -77
  63. package/scripts/corpus-candidate.mjs +475 -222
  64. package/scripts/corpus-next-seed.mjs +225 -0
  65. package/scripts/corpus-promotion.mjs +58 -0
  66. package/scripts/corpus-reconcile.mjs +411 -105
  67. package/scripts/doc-currency.mjs +16 -1
  68. package/scripts/dual-host-deliberation.mjs +25 -2
  69. package/scripts/dual-host-suggest.mjs +17 -1
  70. package/scripts/falsify.mjs +13 -3
  71. package/scripts/gist-receipts.mjs +482 -87
  72. package/scripts/github-health-watch.mjs +12 -2
  73. package/scripts/handoff-asset.mjs +34 -0
  74. package/scripts/hook-retirement-check.mjs +8 -1
  75. package/scripts/host-registry.mjs +1 -1
  76. package/scripts/ingest-gists.mjs +74 -101
  77. package/scripts/job-heartbeat.sh +77 -14
  78. package/scripts/learning-replay-execution.mjs +10 -4
  79. package/scripts/nightly-gists.sh +27 -13
  80. package/scripts/nightly-two-run-proof.mjs +1 -1
  81. package/scripts/nightly-watchdog.mjs +61 -4
  82. package/scripts/onboarding-console.mjs +319 -27
  83. package/scripts/oracle/produce-questions.mjs +293 -0
  84. package/scripts/oracle/producer-hosts.mjs +235 -0
  85. package/scripts/oracle/repo-recall.mjs +448 -0
  86. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  87. package/scripts/oracle/source-tree.mjs +165 -0
  88. package/scripts/oracle/source-units.mjs +391 -0
  89. package/scripts/oracle/spike-run.mjs +98 -0
  90. package/scripts/oracle/unit-inventory.mjs +141 -0
  91. package/scripts/oracle/unit-sampling.mjs +128 -0
  92. package/scripts/oracle/validate-labels.mjs +250 -0
  93. package/scripts/private-overlay.mjs +248 -0
  94. package/scripts/product-integrity-contract.mjs +1 -1
  95. package/scripts/proxy/claude-proxied.sh +6 -0
  96. package/scripts/proxy/proxy-revert.sh +5 -0
  97. package/scripts/proxy/proxy-up.sh +6 -0
  98. package/scripts/proxy/proxy-verify.mjs +4 -0
  99. package/scripts/public-inputs.mjs +409 -0
  100. package/scripts/public-verification-inputs.mjs +112 -26
  101. package/scripts/public-verification-lane.mjs +1 -1
  102. package/scripts/published-surface-probe.mjs +34 -4
  103. package/scripts/qe/card-lane-gate.mjs +16 -1
  104. package/scripts/qe/session-start-gate.mjs +16 -1
  105. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  106. package/scripts/record-lesson.mjs +4 -1
  107. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  108. package/scripts/release-abort-stale.mjs +5 -1
  109. package/scripts/release-authority.mjs +104 -12
  110. package/scripts/release-channel-kind.mjs +86 -0
  111. package/scripts/release-convergence-watchdog.mjs +7 -2
  112. package/scripts/release-projection.mjs +177 -72
  113. package/scripts/release-transaction-provider.mjs +23 -6
  114. package/scripts/release.mjs +252 -17
  115. package/scripts/retrieval-canary.mjs +87 -0
  116. package/scripts/rvf-index-audit.mjs +573 -13
  117. package/scripts/rvf-wire.mjs +269 -0
  118. package/scripts/seal-gist-receipt.mjs +65 -0
  119. package/scripts/selfcheck.mjs +42 -21
  120. package/scripts/source-coverage.mjs +253 -24
  121. package/scripts/status-honesty.mjs +25 -0
  122. package/scripts/sync-census.mjs +0 -0
  123. package/scripts/sync-version.mjs +2 -0
  124. package/scripts/trismart.mjs +42 -0
  125. package/scripts/updater-manifest.mjs +162 -0
  126. package/scripts/verify-channels.mjs +17 -5
  127. package/scripts/wired-check.mjs +48 -10
  128. package/tri-smart-skill/QUICKSTART.md +37 -0
  129. package/tri-smart-skill/README.md +92 -0
  130. package/tri-smart-skill/install.cmd +14 -0
  131. package/tri-smart-skill/install.command +13 -0
  132. package/tri-smart-skill/install.mjs +51 -0
  133. package/tri-smart-skill/install.sh +9 -0
  134. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  135. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  136. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  137. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  138. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  139. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  140. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  141. package/scripts/corpus-seed-publish.mjs +0 -110
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- Updated: 2026-09-07 11:22:00 EDT | Version 4.3.10
1
+ Updated: 2026-09-11 08:00:00 EDT | Version 4.3.21
2
2
  Created: 2026-06-29 22:36:38 EDT
3
3
 
4
4
  <div align="center">
@@ -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.21 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.3.21-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.3.25 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.3.25-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
 
@@ -47,7 +47,7 @@ safe inverse must not be advertised as a one-click undo. See the
47
47
  [![explainer](https://img.shields.io/badge/▶%20see%20it%20live-isovision.ai%2Fruvnet--brain-e8a13a?style=flat-square)](https://isovision.ai/ruvnet-brain/)
48
48
  [![license](https://img.shields.io/badge/license-MIT-8ecae6?style=flat-square)](LICENSE)
49
49
  [![grounded](https://img.shields.io/badge/answers-cited%20rUv%20source-333?style=flat-square)](#testing--proof)
50
- [![coverage](https://img.shields.io/badge/coverage-41%25%20of%20ALL%20source%20·%20honest-b58900?style=flat-square)](#testing--proof)
50
+ [![coverage](https://img.shields.io/badge/coverage-42%25%20of%20ALL%20source%20·%20honest-b58900?style=flat-square)](#testing--proof)
51
51
 
52
52
  > **One Brain generation everywhere.** npm, the GitHub tag/release, bundle manifests, source metadata, and checksum-bound RVF generations must share the same product version. Headline claims are regenerated and checked by the claims ledger (`scripts/claims-verify.mjs`); other numbers below are hand-stamped and dated:
53
53
  > - **`plugin`** (badge above) — the Claude Code plugin itself: SKILL.md, the grounding hooks, the MCP server. Read live from [`plugin/.claude-plugin/plugin.json`](plugin/.claude-plugin/plugin.json). Updates often — this is where behavior fixes land.
@@ -525,7 +525,7 @@ node plugin/test/run-tests.mjs # full plugin QA over real JSO
525
525
  | **L4 "orchestrate"** | **downgraded — measures speech, not obedience** | L4 asserts the hook's own injected prose contains required words (`must: ['take the wheel','SPARC','swarm',…]`). That proves **the brain spoke**. It cannot fail when the advice is read and ignored — which is the failure this product exists to prevent. Counterfactual replay against a brain-off control (ADR-058 §D4) is what will earn this row back |
526
526
  | **Plugin QA** | **60 / 60** | manifests, hook firing, MCP `initialize`/`tools/list`, capability battery |
527
527
  | **Clean-room install** | **3 / 3** | download the published bundle fresh → unzip → query → grounded, cited answers |
528
- | **Unit tests** | **3,035 passing, 161 todo** · 41% of ALL source covered | `npm run test:cov` regenerates both — the coverage floor fails CI if it slips (`claims:verify` re-derives the %, it is not a hand-typed badge). 41% is the honest number over every shipped file; the previous "75%" measured a hand-picked 8-file subset |
528
+ | **Unit tests** | **3,035 passing, 161 todo** · 42% of ALL source covered | `npm run test:cov` regenerates both — the coverage floor fails CI if it slips (`claims:verify` re-derives the %, it is not a hand-typed badge). 42% is the honest number over every shipped file; the previous "75%" measured a hand-picked 8-file subset |
529
529
  | **Grounding proof** | `npx ruvnet-brain --doctor` | asks a real question, then checks the cited path really exists in the on-disk store; a citation that doesn't resolve is reported as **NOT grounded** |
530
530
  | **Held-out eval** | **grounded 100/100** · routed 63/80 | `npm run eval` — 120 frozen, hash-pinned questions across 5 strata, never used for tuning, graded on ground truth, never by a model |
531
531
 
@@ -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** — 184 public stores · 146,970 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** — 182 public stores · 143,719 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
@@ -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 {
@@ -68,6 +69,9 @@ import {
68
69
  CONSOLE_RUNTIME_SURFACE, CONSOLE_RUNTIME_IDENTITY_FILE, consoleRuntimeDigest,
69
70
  } from '../scripts/console-runtime-identity.mjs';
70
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';
71
75
 
72
76
  // SEC-0010 #6 — the Ed25519 PUBLIC key is EMBEDDED here (not a separate file) so the installer's
73
77
  // trust root travels with the installer code itself: an attacker who swaps the downloaded bundle
@@ -318,6 +322,32 @@ function fetchJson(url, redirects = 0) {
318
322
  // --version <tag> forces a tag; --pin skips the network check and uses the bundled known-good tag.
319
323
  // Any failure (offline / rate-limited / no releases) FALLS BACK to the pinned known-good Release,
320
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
+
321
351
  async function resolveRelease() {
322
352
  step(
323
353
  'Finding the latest brain to install',
@@ -347,9 +377,10 @@ async function resolveRelease() {
347
377
  const rel = await fetchJson(RELEASE_API);
348
378
  const tag = rel && rel.tag_name;
349
379
  if (!tag) throw new Error('latest Release has no tag_name');
350
- const asset = Array.isArray(rel.assets) ? rel.assets.find((a) => a.name === ASSET_NAME) : null;
351
- const url = asset && asset.browser_download_url ? asset.browser_download_url : fallbackUrl(tag);
352
- 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') {
353
384
  warn(`latest Release ${tag} has no ${ASSET_NAME} asset listed — using the conventional download URL`);
354
385
  }
355
386
  ok(`latest Release is ${c.bold(tag)}`);
@@ -485,7 +516,51 @@ export function copyLocalBundleInto(sourceDir, cacheDir) {
485
516
  return copied;
486
517
  }
487
518
 
488
- 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 } = {}) {
489
564
  step(
490
565
  'Unpacking the brain into place',
491
566
  'so the plugin finds forge-mcp-all.mjs and the vector stores right where it looks',
@@ -566,6 +641,25 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null) {
566
641
  `The archive layout may have changed. Re-run, or report this at https://github.com/stuinfla/ruvnet-brain/issues`,
567
642
  );
568
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
+ }
569
663
  const stagedCoverage = validateCoverageDirectory(stageDir, { expectedVersion: PACKAGE_VERSION });
570
664
  if (!stagedCoverage.valid) {
571
665
  fs.rmSync(stageDir, { recursive: true, force: true });
@@ -647,7 +741,8 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null) {
647
741
  `Candidate retained for inspection at ${stageDir}.`);
648
742
  }
649
743
  if (hadPrior) warn(`PRESERVED_UNCLASSIFIED: prior generation retained at ${preservedDir}. ` +
650
- '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.');
651
746
  ok(`brain unpacked to ${cacheDir}`);
652
747
  return { status: 'ACTIVATED', priorGeneration: hadPrior
653
748
  ? { status: 'PRESERVED_UNCLASSIFIED', path: preservedDir, automaticCleanupEligible: false }
@@ -1901,14 +1996,25 @@ export function classifyCodexLifecycle(plugin, listed = null) {
1901
1996
  if (!plugin.enabled) return { state: 'disabled', plugin, hooks: [] };
1902
1997
  if (!listed.ok) return { state: 'probe-failed', plugin, hooks: [], error: listed.error };
1903
1998
  const groups = Array.isArray(listed.value?.data) ? listed.value.data : [];
1904
- 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 })))
1905
2001
  .filter((hook) => hook?.pluginId === CODEX_PLUGIN_ID);
1906
2002
  const errors = groups.flatMap((group) => Array.isArray(group?.errors) ? group.errors : []);
1907
2003
  if (errors.length) {
1908
2004
  return { state: 'missing-runtime-hooks', plugin, hooks, errors };
1909
2005
  }
1910
- if (hooks.length === 0) return { state: 'inactive-by-design', plugin, hooks, errors };
1911
- 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 };
1912
2018
  }
1913
2019
 
1914
2020
  export async function codexLifecycleStatus(options = {}) {
@@ -1930,6 +2036,16 @@ export function codexLifecycleGuidance(status) {
1930
2036
  detail: 'Any Brain-owned runtime registration is stale and must not be trusted or executed.',
1931
2037
  action: `Upgrade ${CODEX_PLUGIN_ID}, then start a fresh Codex session and re-run --doctor.`,
1932
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
+ };
1933
2049
  case 'inactive-by-design':
1934
2050
  return {
1935
2051
  healthy: true,
@@ -2054,22 +2170,31 @@ export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'sourc
2054
2170
  // Project-local host settings must remain empty. The package registries may carry only
2055
2171
  // the two continuity handlers; every former gate is still retired.
2056
2172
  const packageRegistry = relative === 'plugin/hooks/hooks.json' || relative === 'plugin/hooks/codex-hooks.json';
2057
- 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);
2058
2175
  }
2059
2176
  }
2060
2177
  }
2061
2178
  if (relative === 'plugin/hooks/hooks.json' || relative === 'plugin/hooks/codex-hooks.json') {
2062
- for (const [event, spec] of Object.entries(CONTINUITY_EVENTS)) {
2063
- const count = (doc.hooks[event] ?? []).flatMap((group) => group?.hooks ?? [])
2064
- .filter((hook, index, hooks) => isAllowedContinuityRegistration({
2065
- event,
2066
- matcher: (doc.hooks[event] ?? []).find((group) => (group.hooks ?? []).includes(hook))?.matcher,
2067
- command: hook?.command,
2068
- })).length;
2069
- 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
+ }
2070
2193
  }
2071
2194
  for (const event of Object.keys(doc.hooks)) {
2072
- 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
+ }
2073
2198
  }
2074
2199
  }
2075
2200
  } catch (error) {
@@ -2080,12 +2205,19 @@ export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'sourc
2080
2205
  const contractsFile = 'plugin/hooks/hook-contracts.json';
2081
2206
  const contracts = JSON.parse(fs.readFileSync(path.join(root, contractsFile), 'utf8'));
2082
2207
  checkedFiles.push(contractsFile);
2083
- const ids = Array.isArray(contracts.contracts) ? contracts.contracts.map((c) => c?.id) : [];
2084
- if (ids.length !== continuityContractIds().length || continuityContractIds().some((id) => !ids.includes(id))) {
2085
- 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(', ')})`);
2086
2212
  }
2087
- if (!Array.isArray(contracts.matcherAllowlist) || contracts.matcherAllowlist.length !== continuityContractIds().length) {
2088
- 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(', ')})`);
2089
2221
  }
2090
2222
  } catch (error) {
2091
2223
  errors.push(`plugin/hooks/hook-contracts.json: ${error.message}`);
@@ -2141,17 +2273,21 @@ export function claudeInstalledHookRetirementStatus({ home = os.homedir(), plugi
2141
2273
  }
2142
2274
  }
2143
2275
  }
2144
- for (const [event, spec] of Object.entries(CONTINUITY_EVENTS)) {
2145
- const count = (doc.hooks[event] ?? []).flatMap((group) => group?.hooks ?? [])
2146
- .filter((hook) => isAllowedContinuityRegistration({
2147
- event,
2148
- matcher: (doc.hooks[event] ?? []).find((group) => (group.hooks ?? []).includes(hook))?.matcher,
2149
- command: hook?.command,
2150
- })).length;
2151
- 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
+ }
2152
2286
  }
2153
2287
  for (const event of Object.keys(doc.hooks)) {
2154
- 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
+ }
2155
2291
  }
2156
2292
  }
2157
2293
  } catch (error) {
@@ -2295,7 +2431,19 @@ async function smokeQuery(cacheDir) {
2295
2431
  const secs = ((Date.now() - started) / 1000).toFixed(1);
2296
2432
  const out = `${r.stdout || ''}`;
2297
2433
  if (r.status !== 0 || !out.trim()) {
2298
- 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}`);
2299
2447
  // SHOW THE ACTUAL ERROR (issue #37 bug 2, Agentist-Elder, 2026-07-21).
2300
2448
  //
2301
2449
  // This captured stderr and then threw it away, so every hard failure — a crash, a missing
@@ -2315,7 +2463,9 @@ async function smokeQuery(cacheDir) {
2315
2463
  for (const line of lines.slice(0, 12)) info(c.dim(` ${line.slice(0, 200)}`));
2316
2464
  if (lines.length > 12) info(c.dim(` … ${lines.length - 12} more line(s)`));
2317
2465
  }
2318
- 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) };
2319
2469
  }
2320
2470
 
2321
2471
  const verifier = await loadCitationVerifier(cacheDir);
@@ -2613,11 +2763,16 @@ async function doctor() {
2613
2763
  const v = verifyInstall(cacheDir);
2614
2764
  const smoke = await smokeQuery(cacheDir);
2615
2765
  const allGreen = v.repos > 0 && v.reader && v.mcp;
2616
- console.log(
2617
- `\n ${allGreen ? c.green('✓ Healthy.') : c.yellow('! Needs attention.')} ${
2618
- allGreen ? 'The brain is installed and reachable.' : 'Re-run the installer to fix the warnings above.'
2619
- }`,
2620
- );
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.')}`);
2621
2776
  // Installed-and-reachable and actually-grounded are different claims. Keep them separate, so a
2622
2777
  // healthy install can never be mistaken for proven grounding.
2623
2778
  if (smoke.grounded === true) {
@@ -2736,8 +2891,11 @@ async function doctor() {
2736
2891
  || codexReadinessFailed
2737
2892
  || !hostConvergence.healthy
2738
2893
  || Boolean(rufloOperational && !rufloOperational.healthy);
2739
- if (failed && !hookResult && !groundingUnprovenPersisted) {
2740
- 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.`);
2741
2899
  }
2742
2900
  return failed ? 1 : 0;
2743
2901
  }
@@ -2805,6 +2963,20 @@ function cmpTag(a, b) {
2805
2963
  return A.pre > B.pre ? 1 : -1;
2806
2964
  }
2807
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
+
2808
2980
  function installedBrainVersion(cacheDir) {
2809
2981
  // Same read the telemetry ping uses: the bundle stamps its Release tag into SOURCE.json.
2810
2982
  // "unknown" is honest for a locally-built or pre-stamping bundle — never guess a tag.
@@ -2909,7 +3081,11 @@ function feedbackHealthLines(cacheDir) {
2909
3081
  return [
2910
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)'}`,
2911
3083
  `toolkit: Ruflo ${env.ruflo ? 'present' : 'not found'} · RuVector ${env.ruvector ? 'present' : 'not found'} · claude CLI ${env.claude ? 'present' : 'not found'}`,
2912
- 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',
2913
3089
  ];
2914
3090
  }
2915
3091
 
@@ -3321,6 +3497,17 @@ function runUpdate() {
3321
3497
  settleRefresh(1, { phase: 'source-enumeration' });
3322
3498
  return;
3323
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
+ }
3324
3511
  info(c.dim("running the bundle's own self-updater (backs up first, re-verifies, never half-applies)…\n"));
3325
3512
  // Relative filename + matching cwd — same launch convention as smokeQuery(); stdio:'inherit'
3326
3513
  // streams the updater's narration live and unedited.
@@ -3667,6 +3854,12 @@ export function machineFootprint() {
3667
3854
  const artifact = nightlyArtifact({ platform: process.platform, env: process.env });
3668
3855
  if (artifact.kind === 'launchd') add('Nightly updater (LaunchAgent)', artifact.path, 'npx ruvnet-brain --disable-nightly');
3669
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');
3670
3863
  }
3671
3864
  if (process.platform === 'darwin') {
3672
3865
  add('Spend watchdog (LaunchAgent)', spendGuardPlistPath(), 'npx ruvnet-brain --disable-spend-guard');
@@ -3847,6 +4040,7 @@ function uninstallAll() {
3847
4040
  // owns (settings.json entries, the MCP registration) and the Claude Code plugin itself are not
3848
4041
  // ours to delete, so they are handed over as commands.
3849
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)',
3850
4044
  'Spend watchdog (LaunchAgent)', 'Spend watchdog script', 'CLAUDE.md block (6 lines, between markers)',
3851
4045
  'Model-router files', 'Status-bar version script', 'Status-bar preference', 'Usage-counts preference',
3852
4046
  // Two gaps closed here: the statusLine KEY is now removable in place (we know exactly what we
@@ -4471,7 +4665,11 @@ export function classifyRufloOperationalHealth({ status = '', memory = '', metri
4471
4665
 
4472
4666
  function probeRufloOperationalHealth() {
4473
4667
  const run = (args) => {
4474
- 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' } });
4475
4673
  return `${result.stdout || ''}\n${result.stderr || ''}`;
4476
4674
  };
4477
4675
  return classifyRufloOperationalHealth({
@@ -5169,21 +5367,33 @@ the installer reports that boot-level declarations changed.
5169
5367
  ? (resolvedRelease.tag_name || resolvedRelease.tag || null)
5170
5368
  : null;
5171
5369
  } catch { latestTag = null; }
5172
- const norm = (v) => (v == null || v === 'unknown' ? null : String(v).replace(/^v/, ''));
5173
- const a = norm(installedTag), b = norm(latestTag);
5174
- // BEHIND => download. SAME => skip. AHEAD => skip, and say so honestly.
5175
- //
5176
- // This was a bare `a !== b`, which treats "newer than the latest release" as staleness. Anyone
5177
- // running a pre-release or dev build — or who simply updated in the window before a release was
5178
- // cut — was told "Brain is out of date" and pushed through a 2 GB download that would DOWNGRADE
5179
- // them. Found 2026-07-22 the moment this repo's own version moved to 3.5.0-dev ahead of the
5180
- // 3.4.22-dev release: the installer immediately declared its own newest brain stale.
5181
- //
5182
- // stack-sync.mjs has modelled AHEAD as legal from the start ("AHEAD is legal and produces NO
5183
- // recommendation — that modelling choice is what makes the alpha-vs-latest downgrade war
5184
- // structurally impossible"). The installer never learned the same lesson. It has now.
5185
- ahead = Boolean(a && b && cmpTag(a, b) > 0);
5186
- 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
+ }
5187
5397
  }
5188
5398
 
5189
5399
  if (alreadyInstalled && !FLAG_FORCE && !staleSkip) {
@@ -5242,7 +5452,12 @@ the installer reports that boot-level declarations changed.
5242
5452
  ok(reason);
5243
5453
  }
5244
5454
  }
5245
- 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
+ });
5246
5461
  const brainProfile = readBrainProfile();
5247
5462
  if (brainProfile !== 'complete') {
5248
5463
  const scoped = applyBrainProfile(cacheDir, brainProfile);