ruvnet-brain 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +679 -121
  3. package/console/app.js +178 -81
  4. package/console/index.html +1 -1
  5. package/console/install-architecture.html +1 -0
  6. package/console/scope.css +4 -1
  7. package/console/style.css +13 -0
  8. package/console/tips.html +4 -4
  9. package/kb/brain-profile.mjs +1 -0
  10. package/kb/corpus-release-identity.mjs +1 -1
  11. package/kb/forge-update.mjs +41 -17
  12. package/kb/model-requirements.mjs +4 -1
  13. package/kb/update-storage-transaction.mjs +79 -0
  14. package/kb/zip-extract.mjs +22 -0
  15. package/package.json +1 -1
  16. package/plugin/.claude-plugin/plugin.json +1 -1
  17. package/plugin/.codex-plugin/plugin.json +1 -1
  18. package/plugin/commands/brain-console.md +5 -4
  19. package/plugin/commands/configure.md +5 -4
  20. package/plugin/commands/rnb-brief.md +41 -0
  21. package/plugin/commands/rnb.md +80 -0
  22. package/plugin/commands/rnbc.md +80 -0
  23. package/plugin/commands/rvbc.md +5 -4
  24. package/plugin/commands/rvcb.md +5 -4
  25. package/plugin/commands/whats-new.md +4 -4
  26. package/plugin/mcp/server.mjs +10 -2
  27. package/plugin/scripts/advocacy-route.mjs +59 -23
  28. package/plugin/scripts/anticipate.sh +4 -0
  29. package/plugin/scripts/brain-confirmation.mjs +258 -0
  30. package/plugin/scripts/brain-footprint.mjs +494 -0
  31. package/plugin/scripts/brain-location.mjs +47 -0
  32. package/plugin/scripts/capability-registry.mjs +11 -1
  33. package/plugin/scripts/continuity-brief.mjs +324 -0
  34. package/plugin/scripts/continuity-events.mjs +327 -0
  35. package/plugin/scripts/continuity-journal.mjs +500 -0
  36. package/plugin/scripts/decision-gate.mjs +56 -3
  37. package/plugin/scripts/footprint-io.mjs +186 -0
  38. package/plugin/scripts/ground-before-write.sh +8 -1
  39. package/plugin/scripts/ground-ruvnet.sh +103 -12
  40. package/plugin/scripts/grounding-answer.mjs +2 -1
  41. package/plugin/scripts/grounding-stamp.sh +3 -0
  42. package/plugin/scripts/grounding-substance.mjs +1 -1
  43. package/plugin/scripts/grounding-turn-evidence.mjs +68 -6
  44. package/plugin/scripts/hook-input.mjs +78 -4
  45. package/plugin/scripts/kb-copy-proof.mjs +148 -0
  46. package/plugin/scripts/lesson-bridge.mjs +6 -2
  47. package/plugin/scripts/nightly-controller.mjs +8 -1
  48. package/plugin/scripts/node-sqlite.mjs +41 -0
  49. package/plugin/scripts/package-cards.json +797 -0
  50. package/plugin/scripts/package-cards.rvf +0 -0
  51. package/plugin/scripts/package-cards.rvf.idmap.json +1 -0
  52. package/plugin/scripts/package-cards.rvf.meta.json +1 -0
  53. package/plugin/scripts/package-recommender-client.mjs +138 -0
  54. package/plugin/scripts/package-recommender-flag.mjs +30 -0
  55. package/plugin/scripts/package-recommender.mjs +391 -0
  56. package/plugin/scripts/project-progression-outbox.mjs +26 -8
  57. package/plugin/scripts/project-progression-reader.mjs +14 -2
  58. package/plugin/scripts/project-progression-store.mjs +153 -6
  59. package/plugin/scripts/protect-brain-state.sh +4 -1
  60. package/plugin/scripts/session-snapshot-hook.mjs +225 -41
  61. package/plugin/scripts/session-start-budget.mjs +1 -0
  62. package/plugin/scripts/session-start-core.mjs +34 -4
  63. package/plugin/scripts/session-start-health.mjs +7 -1
  64. package/plugin/scripts/session-start-update-plane.mjs +35 -0
  65. package/plugin/scripts/turn-outcome-capture.mjs +12 -1
  66. package/plugin/scripts/unprompted-runtime.mjs +2 -2
  67. package/plugin/skills/brain-console/SKILL.md +3 -3
  68. package/plugin/skills/rnbc/SKILL.md +24 -0
  69. package/plugin/skills/rvbc/SKILL.md +2 -2
  70. package/scripts/approved-runtime.mjs +2 -2
  71. package/scripts/ci/warm-brain-models.mjs +28 -0
  72. package/scripts/codex-hook-trust.mjs +94 -0
  73. package/scripts/console-instances.mjs +70 -12
  74. package/scripts/console-runtime-identity.mjs +5 -0
  75. package/scripts/corpus-canary.mjs +46 -6
  76. package/scripts/corpus-dispatch-decision.mjs +2 -2
  77. package/scripts/corpus-promotion.mjs +1 -1
  78. package/scripts/full-suite-gate.mjs +9 -2
  79. package/scripts/hook-qualify-hosts.mjs +15 -3
  80. package/scripts/host-install-matrix.mjs +63 -2
  81. package/scripts/human-approval-phrases.mjs +46 -0
  82. package/scripts/installed-brain-health.mjs +53 -0
  83. package/scripts/move-brain.mjs +310 -0
  84. package/scripts/onboarding-console.mjs +93 -10
  85. package/scripts/oracle/abstain-threshold-sweep.mjs +62 -0
  86. package/scripts/oracle/abstain-trace.mjs +139 -0
  87. package/scripts/oracle/doc2query-generate.mjs +162 -0
  88. package/scripts/oracle/doc2query-reach.mjs +110 -0
  89. package/scripts/oracle/judge-train.mjs +158 -0
  90. package/scripts/oracle/need-set-split.mjs +48 -0
  91. package/scripts/oracle/sona-query-adapter-eval.mjs +139 -0
  92. package/scripts/package-cards.mjs +374 -0
  93. package/scripts/publication-receipt.mjs +37 -9
  94. package/scripts/recommendation-e2e.mjs +110 -0
  95. package/scripts/recommendation-eval.mjs +105 -0
  96. package/scripts/recommendation-floor.mjs +56 -0
  97. package/scripts/recommendation-judge-score.mjs +74 -0
  98. package/scripts/recommendation-latency.mjs +95 -0
  99. package/scripts/recommendation-real-host-score.mjs +76 -0
  100. package/scripts/recommendation-real-host.mjs +137 -0
  101. package/scripts/release-channel-kind.mjs +1 -1
  102. package/scripts/release-environment-policy.mjs +33 -0
  103. package/scripts/single-source-check.mjs +15 -10
  104. package/scripts/sync-commands.mjs +5 -2
  105. package/scripts/wired-check.mjs +17 -2
package/bin/install.mjs CHANGED
@@ -23,15 +23,22 @@ import crypto from 'node:crypto';
23
23
  import { applyBrainProfile, readBrainProfile } from '../kb/brain-profile.mjs';
24
24
  import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, physicalPath, recordRefreshAdvisory,
25
25
  recordRefreshPhase, settleRefreshRun, UPDATE_REFRESH_PHASES } from '../kb/refresh-run.mjs';
26
- import { pruneLifecycleEvidence } from '../kb/lifecycle-evidence-retention.mjs';
27
- import { recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
26
+ import { assessLifecycleEvidence, pruneLifecycleEvidence } from '../kb/lifecycle-evidence-retention.mjs';
27
+ import { checkDiskSpace, recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
28
+ import { footprintRoots, inventoryFootprint, sweepFootprint } from '../plugin/scripts/brain-footprint.mjs';
29
+ import { kbCopyProof } from '../plugin/scripts/kb-copy-proof.mjs';
30
+ import { isVolumeMetadata } from '../plugin/scripts/footprint-io.mjs';
31
+ import { confirm, doctorVerdict, formatBytes, formatConfirmation, signatureEvidenceFromReceipts, signatureRecordValid, writeSignatureRecord } from '../plugin/scripts/brain-confirmation.mjs';
28
32
  import {
29
33
  requiredEmbedderModels,
30
34
  missingEmbedderModels,
31
35
  } from '../kb/model-requirements.mjs';
32
36
  import { applyManagedCatalogUpdate } from '../scripts/model-router-catalog.mjs';
33
37
  import { cmpVersion } from '../scripts/stack-sync.mjs';
34
- import { inspectInstalledBrain, classifySmokeEvidence, DOCTOR_SMOKE_QUERY, doctorSmokeArgs } from '../scripts/installed-brain-health.mjs';
38
+ import {
39
+ inspectInstalledBrain, classifySmokeEvidence, classifySmokeFailure, classifyWarmupFailure, coldModels, DOCTOR_SMOKE_QUERY, doctorSmokeArgs,
40
+ MODEL_WARMUP_SCRIPT, MODEL_WARMUP_TIMEOUT_MS,
41
+ } from '../scripts/installed-brain-health.mjs';
35
42
  import { validateCoverageDirectory } from '../plugin/scripts/coverage-integrity.mjs';
36
43
  import {
37
44
  continuityContractIds,
@@ -71,7 +78,12 @@ import {
71
78
  CONSOLE_RUNTIME_SURFACE, CONSOLE_RUNTIME_IDENTITY_FILE, consoleRuntimeDigest,
72
79
  } from '../scripts/console-runtime-identity.mjs';
73
80
  import { shellDiff as pluginShellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
81
+ import { codexTrustChanges, CODEX_TRUST_ACTION } from '../scripts/codex-hook-trust.mjs';
74
82
  import { readConsoleReceipts, replaceStaleConsoles } from '../scripts/console-instances.mjs';
83
+ import { moveBrain, MoveRefused } from '../scripts/move-brain.mjs';
84
+ import { brainLocation } from '../plugin/scripts/brain-location.mjs';
85
+ import { cleanLegacyRufloDebris } from '../plugin/scripts/project-progression-store.mjs';
86
+ import { resolveProjectStore } from '../plugin/scripts/project-store-resolver.mjs';
75
87
  import { runHostCli, waitForHostCli } from '../scripts/host-cli.mjs';
76
88
  import {
77
89
  writeInstalledRuntimeIdentity, recordCorpusTransportIdentity, isCorpusReleaseTag, rejectedReleasePath,
@@ -105,17 +117,6 @@ const PACKAGE_VERSION = (() => {
105
117
  const REPO = 'stuinfla/ruvnet-brain';
106
118
  const RELEASE_API = `https://api.github.com/repos/${REPO}/releases/latest`;
107
119
  const ASSET_NAME = 'ruvnet-brain.zip';
108
- // Known-good BUNDLE tag, used ONLY by --pin. It is no longer a silent fallback for a failed
109
- // latest-release lookup: that bundle predates ReleaseCoverage and cannot pass validation, so a lookup
110
- // failure now stops with its real cause (resolveRelease / releaseLookupFailure).
111
- //
112
- // This MUST NOT be derived from this package's own version. The installer and the brain bundle are
113
- // two independent version streams (README: "Three independent things version separately here — by
114
- // design"). Reading it from package.json produced a tag that has never existed — installer 1.14.0-dev
115
- // asking for releases/download/v1.14.0-dev/ruvnet-brain.zip, which 404s, while the newest bundle
116
- // Release is v0.5.0-dev. Verified live: v1.14.0-dev → HTTP 404, v0.5.0-dev → HTTP 200. The safety net
117
- // was broken in exactly the situation it exists for. Bump this by hand when a new bundle ships.
118
- const RELEASE_VERSION = 'v2.9.0'; // sync-version-ignore: the BUNDLE Release tag, not this package's version
119
120
  const fallbackUrl = (tag) => `https://github.com/${REPO}/releases/download/${tag}/${ASSET_NAME}`;
120
121
  const APPROX_SIZE = '~736MB';
121
122
 
@@ -130,7 +131,6 @@ const FLAG_HOOKS = argv.includes('--hooks');
130
131
  const FLAG_NO_VERIFY = argv.includes('--no-verify');
131
132
  // Escape hatch for the installer's closing self-check ONLY (it never disables --doctor's verdict).
132
133
  const FLAG_NO_SELFCHECK = argv.includes('--no-selfcheck');
133
- const FLAG_PIN = argv.includes('--pin'); // skip the latest-check, use the bundled default
134
134
  const FLAG_DEMO = argv.includes('--demo'); // guided, real (non-fabricated) walkthrough of the brain in action
135
135
  const FLAG_FEEDBACK = argv.includes('--feedback'); // prefill a GitHub Discussion (version + health, nothing private) and open it
136
136
  // ── freshness flags — invoke/schedule the SELF-UPDATER the bundle already ships (kb/forge-update.mjs) ──
@@ -147,6 +147,12 @@ const FLAG_DISABLE_SPEND_GUARD = argv.includes('--disable-spend-guard'); // the
147
147
  const FLAG_UNINSTALL = argv.includes('--uninstall'); // reverse everything, in one command
148
148
  const FLAG_WHAT_CHANGED = argv.includes('--what-changed'); // show our footprint on this machine
149
149
  const FLAG_WHATS_NEW = argv.includes('--whats-new'); // show curated major-release highlights
150
+ // --move-brain <dir> moves the whole Brain to another disk, leaving ~/.cache/ruvnet-brain as a link to it;
151
+ // --move-brain --back brings it home (scripts/move-brain.mjs).
152
+ const FLAG_MOVE_BRAIN = argv.includes('--move-brain');
153
+ const MOVE_BRAIN_TO = (() => { const i = argv.indexOf('--move-brain'); const v = i === -1 ? null : argv[i + 1]; return v && !v.startsWith('-') ? v : null; })();
154
+ const FLAG_CLEAN = argv.includes('--clean'); // enforce the footprint guarantee now (ADR-0098), then confirm
155
+ const FLAG_JSON = argv.includes('--json'); // with --doctor / --clean: machine-readable confirmation only
150
156
  // ── onboarding-experience flags (all optional; every offer is safe to decline) ──
151
157
  const FLAG_YES = argv.includes('--yes') || argv.includes('-y'); // accept every optional offer non-interactively
152
158
  const FLAG_PLAN = argv.includes('--plan') || argv.includes('--dry-run'); // show the interactive checklist, then exit — install NOTHING
@@ -156,12 +162,32 @@ const FLAG_ENHANCE_CLAUDE_MD = argv.includes('--enhance-claude-md'); // add the
156
162
  const FLAG_NO_ENHANCE = argv.includes('--no-enhance'); // skip the CLAUDE.md offer entirely
157
163
  const FLAG_STATUSLINE = argv.includes('--statusline'); // opt in to the status-bar version segment, non-interactively
158
164
  const FLAG_NO_STATUSLINE = argv.includes('--no-statusline'); // decline the status-bar offer without prompting
159
- // --version <tag> forces a specific Release tag (e.g. --version v0.5.0-dev)
160
- const versionIdx = argv.indexOf('--version');
161
- const FORCED_VERSION =
162
- versionIdx !== -1 && argv[versionIdx + 1] && !argv[versionIdx + 1].startsWith('-')
163
- ? argv[versionIdx + 1]
164
- : null;
165
+ // --version <tag> forces a specific Release tag (e.g. --version v0.5.0-dev); --pin <tag> is the same.
166
+ /**
167
+ * Which release the operator named, if any. Pure, for testing.
168
+ * --pin used to mean "install the bundled known-good v2.9.0" — a bundle that predates COVERAGE.json,
169
+ * so every --pin install failed validation two steps later. A pin now names its version, or stops.
170
+ * @returns {{ tag: string, source: 'pinned'|'forced' } | { error: string, hint: string } | null}
171
+ */
172
+ export function namedReleaseFromArgs(args) {
173
+ const valueAfter = (flag) => {
174
+ const i = args.indexOf(flag);
175
+ if (i === -1) return undefined;
176
+ const next = args[i + 1];
177
+ return next && !next.startsWith('-') ? next : null;
178
+ };
179
+ const pin = valueAfter('--pin');
180
+ const version = valueAfter('--version');
181
+ if (pin === null) {
182
+ return { error: '--pin needs the release to install, e.g. --pin vX.Y.Z', hint: `It no longer falls back to a built-in release (that bundle could not pass validation). Pick one from https://github.com/${REPO}/releases, or omit --pin to install the latest.` };
183
+ }
184
+ if (pin && version && pin !== version) return { error: `--pin ${pin} and --version ${version} disagree`, hint: 'Name one release.' };
185
+ if (pin) return { tag: pin, source: 'pinned' };
186
+ if (version) return { tag: version, source: 'forced' };
187
+ return null;
188
+ }
189
+ const NAMED_RELEASE = namedReleaseFromArgs(argv);
190
+ const FORCED_VERSION = NAMED_RELEASE?.tag || null;
165
191
 
166
192
  // ── tiny narrating logger — every step says WHAT and WHY ─────────────────────────────────────────
167
193
  const c = {
@@ -333,7 +359,7 @@ function fetchJson(url, redirects = 0) {
333
359
 
334
360
  // ── step: resolve which Release to download (latest by default; safe fallback) ───────────────────
335
361
  // Default behavior: ask GitHub for the LATEST Release and use its ruvnet-brain.zip asset.
336
- // --version <tag> forces a tag; --pin skips the network check and uses the bundled known-good tag.
362
+ // --version <tag> / --pin <tag> install exactly that tag; there is no built-in fallback release.
337
363
  // Any failure (offline / rate-limited / no releases) THROWS with the HTTP status or network error and
338
364
  // a retry hint; callers that must download stop on it, the staleness check reports "could not check".
339
365
  /**
@@ -368,14 +394,10 @@ async function resolveRelease() {
368
394
  'so a stranger always gets the most current brain — not whatever was hardcoded when this script shipped',
369
395
  );
370
396
 
371
- if (FLAG_PIN) {
372
- info(`--pin set: skipping the latest-check and using the bundled known-good ${c.bold(RELEASE_VERSION)}`);
373
- return { tag: RELEASE_VERSION, url: fallbackUrl(RELEASE_VERSION), source: 'pinned' };
374
- }
375
-
376
- if (FORCED_VERSION) {
377
- info(`--version set: forcing Release ${c.bold(FORCED_VERSION)} (no latest-check)`);
378
- return { tag: FORCED_VERSION, url: fallbackUrl(FORCED_VERSION), source: 'forced' };
397
+ if (NAMED_RELEASE?.error) throw Object.assign(new Error(NAMED_RELEASE.error), { hint: NAMED_RELEASE.hint });
398
+ if (NAMED_RELEASE) {
399
+ info(`${NAMED_RELEASE.source === 'pinned' ? '--pin' : '--version'} set: installing Release ${c.bold(NAMED_RELEASE.tag)} (no latest-check)`);
400
+ return { tag: NAMED_RELEASE.tag, url: fallbackUrl(NAMED_RELEASE.tag), source: NAMED_RELEASE.source };
379
401
  }
380
402
 
381
403
  // Deterministic integration seam: stale/current behavior must not depend on GitHub API quota.
@@ -474,12 +496,13 @@ async function obtainBundle(release) {
474
496
  return { zipPath: localZip, downloaded: false };
475
497
  }
476
498
 
477
- const downloadUrl = (release && release.url) || fallbackUrl(RELEASE_VERSION);
499
+ if (!release || !release.url) die('no release was resolved to download.', 'Re-run to look up the latest release, or name one with --version <tag>.');
500
+ const downloadUrl = release.url;
478
501
  step(
479
502
  `Downloading the brain (${APPROX_SIZE})`,
480
503
  'the brain embeds source from dozens of RuvNet repos — too big for git, so it ships as a Release',
481
504
  );
482
- info(`version: ${c.bold((release && release.tag) || RELEASE_VERSION)}`);
505
+ info(`version: ${c.bold(release.tag)}`);
483
506
  info(`from: ${downloadUrl}`);
484
507
  // Download into a PRIVATE, per-run temp DIR — never a predictable os.tmpdir()/ruvnet-brain-<pid>.zip
485
508
  // filename (CWE-377: a guessable path invites a pre-created or symlinked file at that location to be
@@ -621,6 +644,18 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
621
644
  );
622
645
 
623
646
  fs.mkdirSync(path.dirname(cacheDir), { recursive: true });
647
+ // DISK-SPACE PREFLIGHT before staging: the whole unpacked bundle lands beside the brain (the prior
648
+ // generation is renamed, never copied). Refuse cleanly with the exact shortfall rather than ENOSPC
649
+ // half-way through an extraction.
650
+ if (zipPath && !sourceDir) {
651
+ let space;
652
+ try {
653
+ const { zipDeclaredBytes } = await import(new URL('../kb/zip-extract.mjs', import.meta.url).href);
654
+ space = checkDiskSpace([{ dir: path.dirname(cacheDir), bytes: zipDeclaredBytes(zipPath), purpose: 'unpacked brain' }],
655
+ { what: 'install the brain' });
656
+ } catch { space = { ok: true }; } // unmeasurable: extraction's own limits still apply
657
+ if (!space.ok) die(space.message.split('\n')[0], 'Nothing was installed and nothing was changed.');
658
+ }
624
659
  const stageDir = fs.mkdtempSync(path.join(path.dirname(cacheDir), `.${path.basename(cacheDir)}.install-stage-`));
625
660
  const localCopy = async () => `local directory copy — ${copyLocalBundleInto(sourceDir, stageDir)} top-level entries`;
626
661
  const nodeExtract = async () => {
@@ -747,6 +782,15 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
747
782
  }
748
783
  }
749
784
  const parent = path.dirname(cacheDir);
785
+ // THE ACTIVATION IS IN PROGRESS from here until the prior generation is proven and released (review S6).
786
+ // A plain install holds no refresh lock, so this marker (with our pid) is what tells the detached
787
+ // SessionStart sweep (plugin/scripts/brain-footprint.mjs) to touch NOTHING beside the KB meanwhile — it
788
+ // used to be able to delete kb.install-prior-* between the two renames below, stranding the rollback.
789
+ // A die() leaves it naming a dead pid, which the sweep treats as finished.
790
+ const activationMarker = path.join(parent, `.${path.basename(cacheDir)}.install-activation.lock`);
791
+ const clearActivationMarker = () => { try { fs.rmSync(activationMarker, { force: true }); } catch { /* a dead pid reads as finished */ } };
792
+ fs.writeFileSync(activationMarker, `${JSON.stringify({ pid: process.pid, at: Date.now() })}\n`, { mode: 0o600 });
793
+ process.once('exit', clearActivationMarker);
750
794
  const priorPrefix = `${path.basename(cacheDir)}.install-prior-`;
751
795
  const unresolved = fs.readdirSync(parent).filter((name) => name.startsWith(priorPrefix));
752
796
  if (unresolved.length) {
@@ -801,13 +845,33 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
801
845
  hadPrior ? 'the prior brain generation was not moved' : 'no prior brain generation existed'}`,
802
846
  `Candidate retained for inspection at ${stageDir}.`);
803
847
  }
804
- if (hadPrior) warn(`PRESERVED_UNCLASSIFIED: prior generation retained at ${preservedDir}. ` +
805
- 'The updater (kb/forge-update.mjs) releases it only once every byte is proven to survive in the live brain; ' +
806
- 'until then it stays, and repeated installs can grow disk usage.');
848
+ // THE CREATOR NO LONGER LEAVES A SECOND KB BEHIND (ADR-0098). The new generation just passed landed
849
+ // coverage validation, so the prior one is released the moment kbCopyProof shows nothing in it is
850
+ // unique: every private-store file byte-identical in the live brain, every store the live brain lacks
851
+ // of public provenance. A copy holding anything else is KEPT and its files are named. Measured
852
+ // 2026-10-01: two install-preserved copies (~2.4 GB) had outlived every proof that could release them.
853
+ let priorGeneration = null;
854
+ if (hadPrior) {
855
+ const proof = kbCopyProof({ copyDir: preservedDir, liveDir: cacheDir });
856
+ if (proof.disposable) {
857
+ try {
858
+ fs.rmSync(preservedDir, { recursive: true, force: true });
859
+ ok(`released the prior generation (${proof.reason})`);
860
+ priorGeneration = { status: 'RELEASED', path: preservedDir, reason: proof.reason };
861
+ } catch (error) {
862
+ warn(`prior generation could not be removed (${error.message}); the next update or npx ruvnet-brain --clean retries`);
863
+ priorGeneration = { status: 'PRESERVED_UNCLASSIFIED', path: preservedDir, automaticCleanupEligible: true };
864
+ }
865
+ } else {
866
+ warn(`KEPT the prior generation at ${preservedDir}: ${proof.reason}`);
867
+ for (const { file, why } of proof.unique.slice(0, 10)) info(c.dim(` ${file} — ${why}`));
868
+ priorGeneration = { status: 'PRESERVED_UNIQUE', path: preservedDir, unique: proof.unique, automaticCleanupEligible: false };
869
+ }
870
+ }
871
+ clearActivationMarker();
872
+ process.removeListener('exit', clearActivationMarker);
807
873
  ok(`brain unpacked to ${cacheDir}`);
808
- return { status: 'ACTIVATED', priorGeneration: hadPrior
809
- ? { status: 'PRESERVED_UNCLASSIFIED', path: preservedDir, automaticCleanupEligible: false }
810
- : null };
874
+ return { status: 'ACTIVATED', priorGeneration };
811
875
  }
812
876
 
813
877
  /** Stage and validate a bundle for forge-update's private-overlay recovery rail
@@ -1297,6 +1361,30 @@ export function withLiveConsoleState(recorded, { receiptDir, alive, probe } = {}
1297
1361
  return { ...recorded, consoleRuntime: live };
1298
1362
  }
1299
1363
 
1364
+ /**
1365
+ * 4.3.40's ruflo leftovers inside this project's `.swarm` (cleanLegacyRufloDebris). --update removes
1366
+ * them; --doctor only reports (dryRun). Either way a REFUSED artifact (unexpected contents, a symlink) is
1367
+ * printed, never dropped silently. Not a project (or no .swarm): nothing to say.
1368
+ */
1369
+ export function reportLegacyRufloDebris({ projectDir = process.cwd(), dryRun = false } = {}) {
1370
+ let storeDir;
1371
+ try { storeDir = path.dirname(resolveProjectStore({ projectDir }).canonicalAgentDbPath); } catch { return null; }
1372
+ if (!fs.existsSync(storeDir)) return null;
1373
+ const result = cleanLegacyRufloDebris(storeDir, { dryRun });
1374
+ for (const entry of result.removed) {
1375
+ // Dry run applies the same checks (allowlist, mirror proof, in-use window); only the final
1376
+ // unchanged-since-proof comparison can still keep it at --update time.
1377
+ if (dryRun) info(`legacy ruflo debris from 4.3.40 in ${entry}: passes every check; --update removes it unless it is written to before then`);
1378
+ else ok(`removed legacy ruflo debris from 4.3.40: ${entry}`);
1379
+ }
1380
+ for (const { path: entry, reason, kept } of result.refused) {
1381
+ // A KEPT nested AgentDB holds rows (or may still be written): never suggest deleting it by hand.
1382
+ if (kept) warn(`left 4.3.40's nested ruflo store in place — ${entry}: ${reason}. It is checked again on every update.`);
1383
+ else warn(`left legacy ruflo debris in place — ${entry}: ${reason}. Inspect it; remove it yourself if it is ruflo's.`);
1384
+ }
1385
+ return result;
1386
+ }
1387
+
1300
1388
  export function consoleRestartState(identity, {
1301
1389
  receiptDir = path.join(process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'console-instances'),
1302
1390
  alive, probe,
@@ -1463,17 +1551,22 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1463
1551
  const installedHookRetirement = claudeInstalledHookRetirementStatus({ plugin: installed });
1464
1552
  if (installed.installed && versionSatisfies(installed.version, expectedVersion) && installedHookRetirement.ok) {
1465
1553
  ok(`plugin installed at user scope (global, alongside Ruflo / RuVector) — exact version ${installed.version}`);
1466
- if (shellBoundary.restartRequired) {
1467
- warn(`boot-level plugin declarations changed; restart Claude Code once to load them (${shellBoundary.paths.join(', ') || shellBoundary.reason}).`);
1554
+ if (shellBoundary.restartRequired && shellBoundary.known) {
1555
+ info(` boot-level plugin declarations changed (${shellBoundary.paths.join(', ')}): new Claude Code sessions load them; already-open windows keep the old hook definitions until they are reopened.`);
1556
+ } else if (shellBoundary.restartRequired) {
1557
+ warn(`could not verify the plugin's boot-level declarations (${shellBoundary.reason}); restart Claude Code once to be sure they are loaded.`);
1468
1558
  } else if (before.installed && before.version !== installed.version) {
1469
1559
  info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
1470
1560
  }
1471
- info(` commands available${shellBoundary.restartRequired ? ' after a restart' : ' immediately'}: ${c.bold('/rvbc')}, ${c.bold('/ruvnet-brain:configure')}`);
1561
+ info(` commands available${shellBoundary.restartRequired ? ' in new sessions' : ' immediately'}: ${c.bold('/rnbc')} (also /rnb, /rvbc), ${c.bold('/ruvnet-brain:configure')}`);
1472
1562
  return {
1473
1563
  host: true, wired: true, version: installed.version, manualMarketplace, manualInstall,
1474
1564
  shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
1475
1565
  restartRequired: shellBoundary.restartRequired,
1476
- ...(shellBoundary.restartRequired ? { sessionSafety: 'restart-required', sessionSafetyReason: shellBoundary.reason } : {}),
1566
+ // 'open-sessions': the new declarations are installed and PROVEN changed — only sessions already
1567
+ // open booted the old ones. 'unproven': the boot surface could not be compared at all.
1568
+ ...(shellBoundary.restartRequired ? { sessionSafety: 'restart-required', sessionSafetyReason: shellBoundary.reason,
1569
+ restartScope: shellBoundary.known ? 'open-sessions' : 'unproven' } : {}),
1477
1570
  };
1478
1571
  }
1479
1572
 
@@ -1485,7 +1578,7 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1485
1578
  ? `Claude installed the plugin with ${installedHookRetirement.registrations.length} retired lifecycle registration(s); refusing to call this host converged.`
1486
1579
  : mismatch
1487
1580
  ? `Claude installed ${installed.version || 'an unknown version'}, not required ${expectedVersion}; refusing to call this host converged.`
1488
- : `the plugin did NOT land — so slash commands like ${c.bold('/rvbc')} will not exist yet.`);
1581
+ : `the plugin did NOT land — so slash commands like ${c.bold('/rnbc')} will not exist yet.`);
1489
1582
  info(`${c.green('Your brain still works')}: search_ruvnet is wired and Claude will ground answers with it.`);
1490
1583
  info(`Only the plugin extras (slash commands, the Console, skills, and MCP declaration) are missing.`);
1491
1584
  info(`Run these two yourself to finish:`);
@@ -1822,6 +1915,16 @@ const CODEX_PLUGIN_ID = 'ruvnet-brain@ruvnet-brain';
1822
1915
  const CODEX_MARKETPLACE = 'ruvnet-brain';
1823
1916
  const CODEX_MARKETPLACE_SOURCE = 'stuinfla/ruvnet-brain';
1824
1917
 
1918
+ /**
1919
+ * The Brain hooks Codex will hold back after replacing `installedRoot`'s plugin with `candidateRoot`'s:
1920
+ * [{ key, status: 'modified'|'untrusted' }]. No installed root = a fresh install = every hook untrusted.
1921
+ * An unreadable file is treated as absent (fail toward telling the user to review, never toward silence).
1922
+ */
1923
+ export function codexHooksNeedingReview(installedRoot, candidateRoot) {
1924
+ const read = (root) => { try { return root ? JSON.parse(fs.readFileSync(path.join(root, 'hooks', 'codex-hooks.json'), 'utf8')) : {}; } catch { return {}; } };
1925
+ return codexTrustChanges(read(installedRoot), read(candidateRoot));
1926
+ }
1927
+
1825
1928
  function codexMarketplaceTarget() {
1826
1929
  return path.join(
1827
1930
  process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'),
@@ -1953,6 +2056,11 @@ export function wireCodexPlugin({
1953
2056
  path.join(REPO_ROOT, 'plugin'),
1954
2057
  )
1955
2058
  : { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
2059
+ // Which Brain hooks will Codex hold back after this update until the user re-reviews them? Codex keys
2060
+ // trust to a hash of each hook's definition (scripts/codex-hook-trust.mjs), and codex-hooks.json is not
2061
+ // a boot-surface path, so the shell boundary above cannot see it. Computed from the two files, offline.
2062
+ const hooksNeedingReview = codexHooksNeedingReview(before.installed
2063
+ ? codexInstalledPluginRoot({ codexHome, status: before }) : null, path.join(REPO_ROOT, 'plugin'));
1956
2064
  if (before.installed && before.enabled && versionSatisfies(before.version, expectedVersion)) {
1957
2065
  if (announce) ok(`Codex Brain plugin already installed and enabled (${before.version || 'version unknown'}) — no changes.`);
1958
2066
  return {
@@ -2002,14 +2110,20 @@ export function wireCodexPlugin({
2002
2110
  if (announce) {
2003
2111
  ok(`Codex Brain plugin installed and enabled (${after.version || 'version unknown'}).`);
2004
2112
  if (shellBoundary.restartRequired) {
2005
- warn(`boot-level plugin declarations changed; restart Codex once to load them (${shellBoundary.paths.join(', ') || shellBoundary.reason}).`);
2113
+ warn(`boot-level plugin declarations changed; restart Codex, then review them in /hooks (${shellBoundary.paths.join(', ') || shellBoundary.reason}).`);
2006
2114
  } else if (before.installed && before.version !== after.version) {
2007
2115
  info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
2008
2116
  }
2117
+ if (hooksNeedingReview.length) {
2118
+ warn(`Codex will NOT run ${hooksNeedingReview.length} Brain hook${hooksNeedingReview.length === 1 ? '' : 's'} until you review`
2119
+ + ` ${hooksNeedingReview.length === 1 ? 'it' : 'them'} (${hooksNeedingReview.map((h) => `${h.key.split(':').slice(-3).join(':')} ${h.status}`).join(', ')}).`);
2120
+ info(` ${CODEX_TRUST_ACTION}`);
2121
+ }
2009
2122
  }
2010
2123
  return {
2011
2124
  host: true,
2012
2125
  action: before.installed ? 'updated' : 'installed',
2126
+ hooksNeedingReview,
2013
2127
  ...after,
2014
2128
  shellChanged: shellBoundary.changed,
2015
2129
  shellChangedPaths: shellBoundary.paths,
@@ -2017,6 +2131,10 @@ export function wireCodexPlugin({
2017
2131
  ...(shellBoundary.restartRequired ? {
2018
2132
  sessionSafety: 'restart-required',
2019
2133
  sessionSafetyReason: shellBoundary.reason,
2134
+ // Never 'open-sessions' for Codex: changed hook definitions show as PENDING until reviewed in
2135
+ // /hooks (doctor fails closed on pending trust; the SessionStart notice says to trust them), and no
2136
+ // measurement shows a fresh Codex session running them without that step. Unproven = restart.
2137
+ restartScope: 'unproven',
2020
2138
  } : {}),
2021
2139
  };
2022
2140
  }
@@ -2108,6 +2226,15 @@ export function classifyCodexLifecycle(plugin, listed = null) {
2108
2226
  const unexpected = hooks.filter((hook) => !continuityHookId(hook?.command, hook?.event));
2109
2227
  if (unexpected.length) return { state: 'unexpected-runtime-hooks', plugin, hooks: unexpected, errors };
2110
2228
  if (conforming.length === 0) return { state: 'inactive-by-design', plugin, hooks, errors };
2229
+ // REGISTERED IS NOT RUNNING (4.5, review-4.3.39 #7). Codex runs a plugin hook only when its stored
2230
+ // trusted_hash equals the hash of its CURRENT definition (scripts/codex-hook-trust.mjs cites the source);
2231
+ // `untrusted` and `modified` hooks are listed but skipped. A release that edits a command's text leaves
2232
+ // that hook `modified` on every machine that trusted the old one — measured on the owner's machine
2233
+ // 2026-10-01: SessionEnd `modified` after 4.4.0, so it had silently stopped. A user-disabled hook is the
2234
+ // user's choice and is not reported as pending.
2235
+ const pending = conforming.filter((hook) => hook?.enabled !== false
2236
+ && (hook?.trustStatus === 'untrusted' || hook?.trustStatus === 'modified'));
2237
+ if (pending.length) return { state: 'pending-trust', plugin, hooks: conforming, pending, errors };
2111
2238
  return { state: 'continuity-registered', plugin, hooks: conforming, errors };
2112
2239
  }
2113
2240
 
@@ -2140,6 +2267,18 @@ export function codexLifecycleGuidance(status) {
2140
2267
  + ' observe Stop or PreCompact, so no capture handler was registered on those.',
2141
2268
  action: null,
2142
2269
  };
2270
+ case 'pending-trust': {
2271
+ const pending = Array.isArray(status?.pending) ? status.pending : [];
2272
+ const named = pending.map((hook) => `${hook?.event ?? hook?.eventName ?? '?'} (${hook?.trustStatus})`).join(', ');
2273
+ return {
2274
+ healthy: false,
2275
+ intentional: false,
2276
+ summary: `Codex is NOT running ${pending.length} Brain hook${pending.length === 1 ? '' : 's'} until you review ${pending.length === 1 ? 'it' : 'them'}: ${named}.`,
2277
+ detail: 'Codex runs a hook only while its definition matches the one you trusted; an update that changed'
2278
+ + ' a hook leaves it "modified", a new hook "untrusted", and Codex skips both without an error.',
2279
+ action: CODEX_TRUST_ACTION,
2280
+ };
2281
+ }
2143
2282
  case 'inactive-by-design':
2144
2283
  return {
2145
2284
  healthy: true,
@@ -2413,7 +2552,7 @@ function gatherInstallState(cacheDir) {
2413
2552
  try {
2414
2553
  repos = fs
2415
2554
  .readdirSync(cacheDir)
2416
- .filter((f) => f.endsWith('.rvf')).length;
2555
+ .filter((f) => f.endsWith('.rvf') && !isVolumeMetadata(f)).length; // an exFAT `._x.rvf` shadow is not a store
2417
2556
  } catch {
2418
2557
  /* ignore */
2419
2558
  }
@@ -2474,11 +2613,13 @@ function ensureVerifier(cacheDir) {
2474
2613
  } catch { return 'unavailable'; }
2475
2614
  }
2476
2615
 
2616
+ /** The installed verifier: the module, or { broken } when the file is there but will not load (an install
2617
+ * defect, ✗), or null when no verifier exists at all (neither installed nor shippable — "cannot settle"). */
2477
2618
  async function loadCitationVerifier(cacheDir) {
2478
2619
  ensureVerifier(cacheDir);
2479
2620
  const p = path.join(cacheDir, 'verify-citation.mjs');
2480
2621
  if (!fs.existsSync(p)) return null;
2481
- try { return await import(pathToFileURL(p).href); } catch { return null; }
2622
+ try { return await import(pathToFileURL(p).href); } catch (error) { return { broken: String(error?.message || error).slice(0, 200) }; }
2482
2623
  }
2483
2624
 
2484
2625
  // Proving grounding means proving the answer's CITATION RESOLVES — that the file it points at is a
@@ -2493,14 +2634,53 @@ export function resolveRuntimeModelCache(env = process.env, home = os.homedir())
2493
2634
 
2494
2635
  async function smokeQuery(cacheDir) {
2495
2636
  const ask = path.join(cacheDir, 'forge-ask-all.mjs');
2496
- if (!fs.existsSync(ask)) return { ran: false };
2637
+ // A missing reader is an INSTALL DEFECT, named as one (✗ with its fix), never a silent skip.
2638
+ if (!fs.existsSync(ask)) {
2639
+ warn('the reader forge-ask-all.mjs is not in the installed brain — the live question cannot be asked');
2640
+ return { ran: false, grounded: false, reason: 'reader-missing: forge-ask-all.mjs is not in the installed brain' };
2641
+ }
2497
2642
  step(
2498
2643
  'Asking the brain a real question',
2499
2644
  'this warms the local model and checks that retrieval returns usable, cited evidence',
2500
2645
  );
2501
2646
  const Q = DOCTOR_SMOKE_QUERY;
2647
+ const modelCache = resolveRuntimeModelCache();
2648
+ // WARMING IS NOT ANSWERING. A cold cache means the first question also downloads and loads the
2649
+ // models, so a timeout could not tell "still fetching" from "broken". Fetch them first, as their
2650
+ // own step with their own bound and their own verdict; the question's limit then measures an
2651
+ // answer. A warm cache (every later doctor run) skips this step entirely.
2652
+ const cold = coldModels(cacheDir, modelCache);
2653
+ if (cold.length) {
2654
+ // The warm-up imports the reader's own modules. Missing ones are an incomplete install (✗, reinstall),
2655
+ // not a "could not prepare the models" crash with an ERR_MODULE_NOT_FOUND stack.
2656
+ const missing = ['forge-ask.mjs', 'forge-rerank.mjs'].filter((f) => !fs.existsSync(path.join(cacheDir, f)));
2657
+ if (missing.length) {
2658
+ warn(`the reader is incomplete — ${missing.join(', ')} missing from the installed brain`);
2659
+ return { ran: true, grounded: false, reason: `reader-incomplete: ${missing.join(', ')} missing from the installed brain` };
2660
+ }
2661
+ info(c.dim(`warming ${cold.length} local model(s) once: ${cold.join(', ')}`));
2662
+ const warmStarted = Date.now();
2663
+ const w = spawnSync(process.execPath, ['--input-type=module', '-e', MODEL_WARMUP_SCRIPT], {
2664
+ cwd: cacheDir, encoding: 'utf8', timeout: MODEL_WARMUP_TIMEOUT_MS,
2665
+ env: { ...process.env, KB_MODEL_CACHE: modelCache },
2666
+ });
2667
+ const warmSecs = ((Date.now() - warmStarted) / 1000).toFixed(1);
2668
+ if (w.status === 0) {
2669
+ ok(`local models ready in ${warmSecs}s (downloaded and loaded once; later questions skip this)`);
2670
+ } else if (w.status === 3) {
2671
+ info(c.dim('this bundle predates the separate warm-up step — the question below includes it'));
2672
+ } else {
2673
+ // ONE classification (installed-brain-health.mjs classifyWarmupFailure): spawnSync's own timeout is
2674
+ // advisory (a slow machine); a native crash (a signal with no ETIMEDOUT) is a broken runtime, ✗.
2675
+ const v = classifyWarmupFailure({ error: w.error, signal: w.signal, status: w.status, secs: warmSecs, limitSecs: MODEL_WARMUP_TIMEOUT_MS / 1000 });
2676
+ warn(`could not prepare the local models — the warm-up ${v.cause}`);
2677
+ const err = `${w.stderr || ''}`.trim();
2678
+ if (err) for (const line of err.split('\n').slice(0, 8)) info(c.dim(` ${line.slice(0, 200)}`));
2679
+ return { ran: true, grounded: false, reason: `model-warmup-${v.kind}: ${v.cause}`, stderr: err.slice(0, 4000),
2680
+ ...(v.advisory ? { slow: true, warmupTimeout: true } : {}) };
2681
+ }
2682
+ }
2502
2683
  info(`Q: ${c.cyan(`"${Q}"`)}`);
2503
- info(c.dim('(first run downloads a small local model once — this can take a minute)'));
2504
2684
  const started = Date.now();
2505
2685
  let r;
2506
2686
  try {
@@ -2517,19 +2697,18 @@ async function smokeQuery(cacheDir) {
2517
2697
  // path here makes the install smoke warm the model cache the product will actually reopen,
2518
2698
  // instead of a second kb-local cache that can go green while the real door stays cold.
2519
2699
  //
2520
- // RUVNET_BRAIN_QUERY_DEADLINE_MS: this ONE probe is the very first query ever run against a
2521
- // freshly-installed cache — the model is cold and the cross-encoder "rerank" phase has to pay
2522
- // load cost that every later, warm query never pays again. Measured on macOS GitHub Actions
2523
- // runners 2026-09-27 (public-verification runs 36324328134 job 108636740357, 28.5-28.7s; and
2524
- // 36325803503 job 108638381147, 27.1-27.2s): this exact probe consistently needs ~27-29s on
2525
- // that platform, against the general 20s deadline (kb/query-deadline.mjs
2526
- // DEFAULT_QUERY_DEADLINE_MS) that is correct for every normal, warm query. 45s keeps this
2527
- // bounded (never unbounded — the module's core guarantee) while giving this one cold-start
2528
- // probe real margin, without touching the default that protects normal queries everywhere
2529
- // else. Only applied if the caller hasn't already set an explicit override.
2700
+ // RUVNET_BRAIN_QUERY_DEADLINE_MS: 45s for this one probe, against the general 20s deadline
2701
+ // (kb/query-deadline.mjs DEFAULT_QUERY_DEADLINE_MS) that protects every normal query. Still
2702
+ // bounded — the module's core guarantee. The 27-29s once quoted here (public-verification runs
2703
+ // 36324328134, 36325803503) were THREE doctors started at once on the 3-vCPU/7GB macOS runner,
2704
+ // not one cold probe. Measured 2026-10-01 on that runner (ci-probe run 36882813925): model
2705
+ // download+load 6-7s (now its own step above), this question alone 6.6-10.4s with a warm cache
2706
+ // and 14-15s with the fetch inside it, three at once 24-34s; the real --doctor, alone, verified
2707
+ // in 11.8s (4.4.1) and 15.4s (4.4.0). 45s is ~3x the measured single-doctor cost. Only applied
2708
+ // if the caller hasn't already set an explicit override.
2530
2709
  env: {
2531
2710
  ...process.env,
2532
- KB_MODEL_CACHE: resolveRuntimeModelCache(),
2711
+ KB_MODEL_CACHE: modelCache,
2533
2712
  RUVNET_BRAIN_QUERY_DEADLINE_MS: process.env.RUVNET_BRAIN_QUERY_DEADLINE_MS ?? '45000',
2534
2713
  },
2535
2714
  });
@@ -2547,11 +2726,10 @@ async function smokeQuery(cacheDir) {
2547
2726
  // crash, a timeout, and a missing module all read as "nothing is wrong, it will warm up".
2548
2727
  // Observed on this machine: a smoke query that produced no answer in 240s was reported as a
2549
2728
  // first-run download. spawnSync already tells us which it was; say that instead.
2550
- const cause = r.error ? `could not launch the reader: ${r.error.message}`
2551
- : r.signal === 'SIGTERM' ? `timed out after ${secs}s (240s limit) with no answer`
2552
- : r.signal ? `the reader was killed by ${r.signal} after ${secs}s`
2553
- : r.status !== 0 ? `the reader exited ${r.status} after ${secs}s`
2554
- : `the reader exited 0 after ${secs}s but printed nothing`;
2729
+ const limitMs = Number(process.env.RUVNET_BRAIN_QUERY_DEADLINE_MS ?? 45000);
2730
+ const failure = classifySmokeFailure({ error: r.error, signal: r.signal, status: r.status,
2731
+ stderr: r.stderr, secs, limitSecs: Number.isFinite(limitMs) ? limitMs / 1000 : 45 });
2732
+ const { cause } = failure;
2555
2733
  warn(`no answer came back — ${cause}`);
2556
2734
  // SHOW THE ACTUAL ERROR (issue #37 bug 2, Agentist-Elder, 2026-07-21).
2557
2735
  //
@@ -2574,17 +2752,29 @@ async function smokeQuery(cacheDir) {
2574
2752
  }
2575
2753
  // The reason travels with the verdict so the doctor's "Grounding NOT proven (<reason>)" line
2576
2754
  // names the real cause too, instead of the generic token.
2577
- return { ran: true, grounded: false, reason: `no-answer: ${cause}`, secs, stderr: err.slice(0, 4000) };
2755
+ return { ran: true, grounded: false, reason: `no-answer: ${cause}`, secs, stderr: err.slice(0, 4000),
2756
+ slow: failure.kind === 'slow' };
2578
2757
  }
2579
2758
 
2580
2759
  const verifier = await loadCitationVerifier(cacheDir);
2760
+ if (verifier?.broken) {
2761
+ warn(`the installed citation verifier does not load (${verifier.broken}) — grounding cannot be proven until it is reinstalled`);
2762
+ return { ran: true, grounded: false, reason: `reader-broken: verify-citation.mjs does not load (${verifier.broken})` };
2763
+ }
2581
2764
  if (!verifier) {
2582
2765
  info(`the brain answered in ${secs}s, but this bundle predates the citation verifier —`);
2583
2766
  info(c.dim(' re-run `npx ruvnet-brain` to refresh it, and grounding will be PROVEN, not assumed'));
2584
2767
  return { ran: true, grounded: null, reason: 'verifier-missing' };
2585
2768
  }
2586
2769
 
2587
- const v = await verifier.verifyGrounding(out, cacheDir);
2770
+ // A verifier that throws when CALLED is as broken as one that will not load: a named ✗, never a crash that
2771
+ // leaves --doctor --json with nothing to parse (re-review a6 SF4).
2772
+ let v;
2773
+ try { v = await verifier.verifyGrounding(out, cacheDir); } catch (error) {
2774
+ const why = String(error?.message || error).slice(0, 200);
2775
+ warn(`the installed citation verifier failed while checking the answer (${why}) — reinstall to repair it`);
2776
+ return { ran: true, grounded: false, reason: `reader-broken: verify-citation.mjs threw (${why})` };
2777
+ }
2588
2778
  const evidence = classifySmokeEvidence(v, out);
2589
2779
  if (v.grounded && !evidence.usable) {
2590
2780
  warn(`the citation resolves, but the question was not answered with sufficient evidence (${evidence.reason})`);
@@ -2764,15 +2954,47 @@ function meterSummaryLine() {
2764
2954
  // Honest prose that no machine can read is not a check. It could not gate a CI job, a nightly probe,
2765
2955
  // or a `&&` in someone's shell. It now RETURNS a verdict and main() exits with it, so "needs
2766
2956
  // attention" and "success" can never again be the same thing to a script.
2767
- async function doctor() {
2957
+ // `--doctor --json` runs THIS SAME doctor (review S5: it used to print only the confirmation object, so the
2958
+ // text doctor and the JSON could give one machine two verdicts). The narration goes to stderr; stdout carries
2959
+ // only the ONE verdict object (brain-confirmation.mjs doctorVerdict), and the exit code is its exitCode.
2960
+ async function doctor({ json = false } = {}) {
2961
+ if (!json) return doctorRun({ json });
2962
+ const log = console.log;
2963
+ console.log = (...args) => console.error(...args);
2964
+ try { return await doctorRun({ json }); } finally { console.log = log; }
2965
+ }
2966
+ async function doctorRun({ json }) {
2768
2967
  printBanner('doctor');
2769
2968
  console.log(c.dim('Checking every part of the install and reporting green/red.\n'));
2770
2969
  const cacheDir = process.env.RUVNET_BRAIN_KB || path.join(os.homedir(), '.cache', 'ruvnet-brain', 'kb');
2771
2970
  info(`brain dir: ${c.bold(cacheDir)}`);
2971
+ {
2972
+ const where = brainLocation();
2973
+ if (where.state === 'linked') ok(`the Brain lives on another disk: ${where.real} (${where.path} links to it; that disk is mounted)`);
2974
+ }
2772
2975
  const present = fs.existsSync(path.join(cacheDir, 'forge-mcp-all.mjs'));
2773
2976
  if (!present) {
2774
- warn('brain not found here — run the installer first: npx ruvnet-brain');
2775
- return 1; // "not installed" is a FAILING doctor, not a neutral one
2977
+ // "not installed" is a FAILING doctor, not a neutral one — and the same verdict in both outputs. An
2978
+ // interrupted --move-brain may have left the ONLY copy at <home>.old-<pid>: its Move line names the `mv`
2979
+ // back (a fresh install over it would build a second, public-only Brain).
2980
+ let moveLines = [];
2981
+ try { // names only (measure:false): no tree walk on a machine that has no Brain here
2982
+ moveLines = confirm({ footprint: inventoryFootprint({ now: footprintNow(), measure: false }), installedVersion: PACKAGE_VERSION, now: footprintNow() })
2983
+ .lines.filter((l) => l.id === 'move-leftover');
2984
+ } catch { /* the install line stands */ }
2985
+ // A Brain an interrupted move set aside is RESTORED, never reinstalled over (re-review a6 SHOULD-FIX 1).
2986
+ const restore = moveLines.find((l) => l.state === 'fail');
2987
+ if (restore) warn(`the Brain is not at its path, but an interrupted move left it here — restore it, do NOT reinstall: ${restore.fix}`);
2988
+ else warn('brain not found here — run the installer first: npx ruvnet-brain');
2989
+ const verdict = doctorVerdict({ schemaVersion: 1, kind: 'ruvnet-brain-confirmation', lines: moveLines },
2990
+ [{ id: 'install', label: 'Install', state: 'fail', detail: `brain not found at ${cacheDir}`,
2991
+ fix: moveLines.find((l) => l.state === 'fail')?.fix || 'npx ruvnet-brain' }]);
2992
+ if (json) process.stdout.write(`${JSON.stringify(verdict, null, 2)}\n`);
2993
+ else {
2994
+ if (moveLines.length) console.log(`\n${formatConfirmation(verdict, { color: c, summary: false })}`);
2995
+ console.log(`\n ${c.red('✗ FAILING')} — ${verdict.failing.join(', ')}: run the fix named ${moveLines.length ? 'on each ✗ line' : 'above'}.`);
2996
+ }
2997
+ return verdict.exitCode;
2776
2998
  }
2777
2999
  const convergencePath = path.join(process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'host-convergence.json');
2778
3000
  let hostConvergence = { healthy: true, state: 'not-recorded' };
@@ -2781,7 +3003,10 @@ async function doctor() {
2781
3003
  const recorded = withLiveConsoleState(JSON.parse(fs.readFileSync(convergencePath, 'utf8')),
2782
3004
  { receiptDir: path.join(path.dirname(convergencePath), 'console-instances') });
2783
3005
  hostConvergence = classifyHostConvergence(recorded);
2784
- if (hostConvergence.healthy) ok(`host convergence receipt: ${hostConvergence.state}`);
3006
+ if (hostConvergence.healthy) {
3007
+ ok(`host convergence receipt: ${hostConvergence.state}`);
3008
+ if (hostConvergence.notice) info(hostConvergence.notice);
3009
+ }
2785
3010
  else {
2786
3011
  warn(`host convergence incomplete: ${hostConvergence.state}`);
2787
3012
  info(`Retry the same generation: ${c.bold('npx ruvnet-brain --update')}${hostConvergence.action ? `; ${hostConvergence.action}` : ''}`);
@@ -2791,6 +3016,7 @@ async function doctor() {
2791
3016
  warn(`host convergence receipt is invalid: ${error.message}`);
2792
3017
  }
2793
3018
  }
3019
+ try { reportLegacyRufloDebris({ dryRun: true }); } catch (error) { warn(`legacy ruflo debris check failed: ${error.message}`); }
2794
3020
  const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(cacheDir);
2795
3021
  const nightlyHealth = schedulerStatus({ platform: process.platform, env: process.env,
2796
3022
  brainHome, kbDir: cacheDir });
@@ -2835,9 +3061,17 @@ async function doctor() {
2835
3061
  cleanupStrayRuvectorDb(); // issue #39: sweep a leftover empty scaffold from before this fix, if one's here
2836
3062
  const env = detectEnvironment();
2837
3063
  let rufloOperational = null;
2838
- if (env.ruflo) {
2839
- rufloOperational = probeRufloOperationalHealth();
2840
- if (rufloOperational.healthy) {
3064
+ const rufloAt = locateRuflo();
3065
+ if (env.ruflo && !rufloAt.cli) {
3066
+ // Wired into ~/.claude/settings.json (npx) but no CLI to ask: the probe used to spawn a missing `ruflo`,
3067
+ // read the empty output as "operational", and print a ✓ it had not measured.
3068
+ rufloOperational = { configuredOnly: true };
3069
+ info('Ruflo is configured in ~/.claude/settings.json but no ruflo CLI is on PATH — not probed');
3070
+ } else if (env.ruflo) {
3071
+ rufloOperational = probeRufloOperationalHealth({ cli: rufloAt.cli });
3072
+ if (rufloOperational.notInitialized) {
3073
+ info('Ruflo CLI present; this directory has not run `ruflo init`, so there is no project learning to judge here (not a failure)');
3074
+ } else if (rufloOperational.healthy) {
2841
3075
  ok(rufloOperational.directMode
2842
3076
  ? 'Ruflo direct mode ready — daemon/swarm is stopped by design; AgentDB remains CLI-backed'
2843
3077
  : 'Ruflo operational — runtime, memory, and learning signals agree');
@@ -2905,7 +3139,12 @@ async function doctor() {
2905
3139
  console.log(` really exists in your local KB. Checked in ${smoke.secs}s, no cloud, no API key.`);
2906
3140
  } else if (smoke.grounded === false) {
2907
3141
  console.log(` ${c.yellow('! Grounding NOT proven')} (${smoke.reason}). The install is present but the brain did not`);
2908
- console.log(' answer from a verifiable source. Re-run npx ruvnet-brain to repair the KB.');
3142
+ // A timeout mid-answer is not a damaged KB, and reinstalling a working brain does not make the
3143
+ // machine faster — so that case gets its own, true advice. Still NOT proven, still failing.
3144
+ console.log(smoke.slow
3145
+ ? ' answer inside the limit. The reader was working, not broken — a reinstall will not help. Run\n'
3146
+ + ' npx ruvnet-brain --doctor again when the machine is less busy.'
3147
+ : ' answer from a verifiable source. Re-run npx ruvnet-brain to repair the KB.');
2909
3148
  } else if (smoke.grounded === null) {
2910
3149
  console.log(` ${c.yellow('! Grounding not verifiable')} on this bundle — it predates the citation verifier.`);
2911
3150
  console.log(' Re-run npx ruvnet-brain to refresh, then --doctor will prove it.');
@@ -2954,6 +3193,27 @@ async function doctor() {
2954
3193
  c.dim('\n Heads-up: a window that was ALREADY open when you installed needs a restart to pick it up;\n newly-opened windows are fine.\n'),
2955
3194
  );
2956
3195
 
3196
+ // ── AGENTDB RECORDING (ADR-100 §4) — positive confirmation, or the loud reason it is not ────────
3197
+ // For the project --doctor is run from. Read-only: the outbox and the store are inspected, nothing
3198
+ // is written. A project without `.swarm` has not adopted the store and gets no line. It is a LINE OF THE
3199
+ // ONE VERDICT (re-review S2: it used to be narration only, so --json never saw it): advisory '!' when
3200
+ // stuck — recording is the project's opt-in memory, not the Brain's health — ✓ when proven, ○ otherwise.
3201
+ let agentdbLine = null;
3202
+ try {
3203
+ const { ContinuityJournal, recordingLine } = await import('../plugin/scripts/continuity-journal.mjs');
3204
+ const { resolveProjectStore } = await import('../plugin/scripts/project-store-resolver.mjs');
3205
+ const journal = new ContinuityJournal({ projectRoot: resolveProjectStore({ projectDir: process.cwd() }).projectRoot });
3206
+ if (fs.existsSync(journal.swarm)) {
3207
+ const status = journal.status();
3208
+ const detail = recordingLine(status).replace(/^AgentDB: /, '');
3209
+ agentdbLine = { id: 'agentdb', label: 'AgentDB', detail,
3210
+ state: status.stuck ? 'warn' : status.lastCommitAt && !status.notApplicable ? 'ok' : 'unknown',
3211
+ fix: status.stuck ? (status.problem === 'stuck-pending' ? 'ruflo doctor --fix (then the next session drains the outbox)' : 'node <plugin>/scripts/continuity-brief.mjs --clear') : null };
3212
+ }
3213
+ } catch (error) {
3214
+ agentdbLine = { id: 'agentdb', label: 'AgentDB', state: 'unknown', detail: `recording status unavailable: ${error.message}`, fix: null };
3215
+ }
3216
+
2957
3217
  // ── THE MECHANICAL VERDICT ────────────────────────────────────────────────────────────────────
2958
3218
  // `--hooks` is retained as a compatibility alias for a read-only zero-registration proof. It must
2959
3219
  // never execute dormant hook bodies.
@@ -2980,12 +3240,14 @@ async function doctor() {
2980
3240
  // "unproven" verdict DOES gate the exit code. Its successful live citation proof above must clear
2981
3241
  // an older failure before this read; otherwise one invocation can print both PROVEN and UNPROVEN.
2982
3242
  let groundingUnprovenPersisted = false;
3243
+ let persistedGrounding = null;
2983
3244
  try {
2984
3245
  const mod = await import(new URL('../scripts/selfcheck.mjs', import.meta.url).href);
2985
3246
  if (smoke.grounded === true) {
2986
- mod.writeInstallState({ grounding: 'proven', reason: null, clearedBy: 'doctor-live-proof' });
3247
+ mod.writeInstallState({ grounding: 'proven', reason: null, clearedBy: 'doctor-live-proof', coverageSha256: liveCoverageSha256(cacheDir) });
2987
3248
  }
2988
- groundingUnprovenPersisted = mod.groundingUnproven(mod.readInstallState());
3249
+ persistedGrounding = mod.readInstallState();
3250
+ groundingUnprovenPersisted = mod.groundingUnproven(persistedGrounding);
2989
3251
  if (groundingUnprovenPersisted) {
2990
3252
  console.log(` ${c.yellow('! Grounding UNPROVEN')} (recorded at ${c.bold(mod.installStatePath())}).`);
2991
3253
  console.log(` This is what makes ${c.bold('--doctor')} fail here even though nothing above crashed — re-run`);
@@ -3007,24 +3269,44 @@ async function doctor() {
3007
3269
  const codexTrustBypassed = process.env.RUVNET_CODEX_HOOK_TRUST_MODE === 'bypass';
3008
3270
  const codexWiringFailed = Boolean(cx.host && !cx.wired);
3009
3271
  const codexReadinessFailed = Boolean(codexMcp?.blocking);
3010
- const failed = (hookResult ? hookResult.exitCode !== 0 : !allGreen)
3011
- || !installedIdentity.healthy
3012
- || smoke.grounded !== true
3013
- || groundingUnprovenPersisted
3014
- || (codexLifecycleFailed && !codexTrustBypassed)
3015
- || codexWiringFailed
3016
- || nightlyHealth.state === 'degraded'
3017
- || (nightlyHealth.state === 'on' && !['ok', 'running'].includes(nightlyHealth.runHealth?.state))
3018
- || codexReadinessFailed
3019
- || !hostConvergence.healthy
3020
- || Boolean(rufloOperational && !rufloOperational.healthy);
3021
- // THE ONE VERDICT. Always printed, always consistent with the exit code, never alongside another.
3022
- if (failed) {
3023
- console.log(`\n ${c.red('✗ FAILING')} — the warnings above are real. Re-run ${c.bold('npx ruvnet-brain')} to repair.`);
3272
+ // ADR-0098 positive confirmation (read-only here; `--clean` enforces) PLUS every doctor check, as lines of
3273
+ // ONE block, judged by ONE function (doctorVerdict): ✗ anywhere fails; ! (currency: KB age, a host plugin
3274
+ // behind, npm newer) advises and never fails, so a correctly installed older build still verifies.
3275
+ const confirmation = await printConfirmation({ print: false });
3276
+ const check = (id, label, failedNow, detail, fix, { advisory = false } = {}) => ({ id, label,
3277
+ state: failedNow ? (advisory ? 'warn' : 'fail') : 'ok', detail, fix: failedNow ? fix : null });
3278
+ const nightlyFailed = nightlyHealth.state === 'degraded'
3279
+ || (nightlyHealth.state === 'on' && !['ok', 'running'].includes(nightlyHealth.runHealth?.state));
3280
+ const codexFailed = codexWiringFailed || codexReadinessFailed || (codexLifecycleFailed && !codexTrustBypassed);
3281
+ const checks = [
3282
+ // With --hooks the install reading was never part of the verdict (release install verification); keep that.
3283
+ check('install', 'Install', !allGreen, `${v.repos} store(s), reader ${v.reader ? 'present' : 'MISSING'}, search server ${v.mcp ? 'present' : 'MISSING'}`,
3284
+ 'npx ruvnet-brain', { advisory: Boolean(hookResult) }),
3285
+ ...(hookResult ? [check('hooks', 'Hooks', hookResult.exitCode !== 0, 'automatic Brain hook continuity policy', 'npx ruvnet-brain')] : []),
3286
+ check('identity', 'Identity', !installedIdentity.healthy, installedIdentity.healthy ? 'search engine, validator and archive manifest agree'
3287
+ : installedIdentity.issues.join('; '), 'npx ruvnet-brain@latest --update'),
3288
+ groundingCheckLine({ smoke, persisted: persistedGrounding, coverageSha256: liveCoverageSha256(cacheDir) }),
3289
+ ...(cx.host ? [check('codex', 'Codex', codexFailed, codexFailed ? (codexWiringFailed ? 'host detected but NOT wired'
3290
+ : codexReadinessFailed ? 'MCP readiness blocked' : 'lifecycle hooks unhealthy') : 'wired', 'npx ruvnet-brain')] : []),
3291
+ check('nightly', 'Nightly', nightlyFailed, `${nightlyHealth.state}${nightlyHealth.runHealth?.state ? `, last run ${nightlyHealth.runHealth.state}` : ''}`,
3292
+ nightlyHealth.state === 'degraded' ? 'npx ruvnet-brain --enable-nightly' : 'npx ruvnet-brain --update'),
3293
+ check('host-convergence', 'Hosts sync', !hostConvergence.healthy, hostConvergence.state, 'npx ruvnet-brain --update'),
3294
+ ...(rufloOperational ? [rufloCheckLine(rufloOperational)] : []),
3295
+ ...(agentdbLine ? [agentdbLine] : []),
3296
+ ];
3297
+ // THE ONE VERDICT. Text, --json and the exit code are all read from this object; nothing else decides.
3298
+ const verdict = doctorVerdict(confirmation, checks);
3299
+ if (json) {
3300
+ process.stdout.write(`${JSON.stringify(verdict, null, 2)}\n`);
3024
3301
  } else {
3025
- console.log(`\n ${c.green('✓ Healthy.')} The brain is installed, reachable, and its checks pass.`);
3302
+ console.log(`\n${formatConfirmation(verdict, { color: c, summary: false })}`);
3303
+ if (!verdict.ok) {
3304
+ console.log(`\n ${c.red('✗ FAILING')} — ${verdict.failing.join(', ')}: run the fix named on each ✗ line.`);
3305
+ } else {
3306
+ console.log(`\n ${c.green('✓ Healthy.')} The brain is installed, reachable, and its checks pass${verdict.advisories.length ? ` (${verdict.advisories.length} advisory ! line(s): ${verdict.advisories.join(', ')})` : ''}.`);
3307
+ }
3026
3308
  }
3027
- return failed ? 1 : 0;
3309
+ return verdict.exitCode;
3028
3310
  }
3029
3311
 
3030
3312
  // ── the post-install self-check, wired for both --doctor --hooks and the installer's last step ────
@@ -3206,7 +3488,7 @@ function feedbackHealthLines(cacheDir) {
3206
3488
  const env = detectEnvironment();
3207
3489
  const allGreen = s.repos > 0 && s.reader && s.mcp;
3208
3490
  return [
3209
- `${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)'}`,
3491
+ `${s.repos} repo stores on disk · reader ${s.reader ? 'ok' : 'MISSING'} · search_ruvnet ${s.mcp ? 'ok' : 'MISSING'} · plugin ${s.plugin ? 'ok' : 'NOT INSTALLED (no /rnbc)'}`,
3210
3492
  `toolkit: Ruflo ${env.ruflo ? 'present' : 'not found'} · RuVector ${env.ruvector ? 'present' : 'not found'} · claude CLI ${env.claude ? 'present' : 'not found'}`,
3211
3493
  // NOT called a "verdict": this reads only repos/reader/mcp, while --doctor's verdict also weighs
3212
3494
  // grounding, Codex wiring, nightly health, host convergence and the hook policy. Two lines both
@@ -3306,6 +3588,11 @@ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = n
3306
3588
  if (/^unresolved rollback state exists/.test(String(result?.reason || ''))) {
3307
3589
  return { verdict: 'refused-retained-copies', fallback: false, exitCode: status || 1 };
3308
3590
  }
3591
+ // Exit 6 / "not enough free disk space": the updater measured before unpacking and touched nothing. A
3592
+ // fresh-install fallback needs at least as much room, so it would only fail later and messier.
3593
+ if (status === 6 || /^not enough free disk space/.test(String(result?.reason || ''))) {
3594
+ return { verdict: 'refused-disk-space', fallback: false, exitCode: status || 6 };
3595
+ }
3309
3596
  // Exit 2 is "manifest unreachable, nothing touched". The fallback exists for a DEAD manifest URL (an old
3310
3597
  // bundle polling a path that 404s); a rate limit, a 5xx or no network is transient, and a full fresh
3311
3598
  // reinstall over it re-downloads the brain and preserves another full copy each time. Retry later instead.
@@ -3464,6 +3751,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3464
3751
  codexReceipt.restartRequired = true;
3465
3752
  codexReceipt.sessionSafety = results.codex.sessionSafety || null;
3466
3753
  codexReceipt.sessionSafetyReason = results.codex.sessionSafetyReason || null;
3754
+ codexReceipt.restartScope = results.codex.restartScope || 'unproven';
3467
3755
  }
3468
3756
  const claudeReceipt = {
3469
3757
  state: results.claude.host ? 'ready' : 'absent',
@@ -3473,6 +3761,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3473
3761
  claudeReceipt.restartRequired = true;
3474
3762
  claudeReceipt.sessionSafety = results.claude.sessionSafety || null;
3475
3763
  claudeReceipt.sessionSafetyReason = results.claude.sessionSafetyReason || null;
3764
+ claudeReceipt.restartScope = results.claude.restartScope || 'unproven';
3476
3765
  }
3477
3766
  if (okApplied) {
3478
3767
  // ISSUE #153 — a running host may freeze an old plugin root. Reclaim only generations whose
@@ -3563,9 +3852,16 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
3563
3852
  return { healthy: false, state: 'version-mismatch', action: `required version ${expectedVersion}` };
3564
3853
  }
3565
3854
  const hostStates = Object.values(receipt.hosts || {});
3566
- const badHost = hostStates.find((host) => !['ready', 'disabled', 'absent'].includes(host?.state)
3855
+ // A host that is ready at the expected version and whose ONLY gap is PROVEN changed boot-level
3856
+ // declarations is converged: new sessions load the new hooks; only windows already open booted the
3857
+ // old ones (owner's Mac, 4.3.40 -> 4.4.0: a fresh `claude -p` loaded 4.4.0 and ran its hooks). That is
3858
+ // reported, not failed. An UNPROVEN boot surface (it could not be compared) still requires a restart.
3859
+ const openSessionsOnly = (host) => host?.state === 'ready' && versionSatisfies(host.version, expectedVersion)
3860
+ && host.restartRequired === true && host.restartScope === 'open-sessions';
3861
+ const openSessions = Object.entries(receipt.hosts || {}).filter(([, host]) => openSessionsOnly(host)).map(([name]) => name);
3862
+ const badHost = hostStates.find((host) => !openSessionsOnly(host) && (!['ready', 'disabled', 'absent'].includes(host?.state)
3567
3863
  || (host.state === 'ready' && !versionSatisfies(host.version, expectedVersion))
3568
- || (host.state === 'ready' && host.restartRequired === true));
3864
+ || (host.state === 'ready' && host.restartRequired === true)));
3569
3865
  if (badHost?.restartRequired === true) {
3570
3866
  return { healthy: false, state: 'host-restart-required', action: badHost.sessionSafetyReason || 'restart the host, then re-run --doctor' };
3571
3867
  }
@@ -3575,11 +3871,28 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
3575
3871
  ? `the installer could not replace the running Console (${receipt.consoleRuntime.replacementFailures.join('; ')}); ` : '';
3576
3872
  return { healthy: false, state: receipt.consoleRuntime?.state || 'console-unproven', action: `${why}restart Console, then re-run --doctor` };
3577
3873
  }
3874
+ if (openSessions.length) return { healthy: true, state: 'channels-converged', openSessions, notice: openSessionsNotice(openSessions, receipt.desiredVersion) };
3578
3875
  return { healthy: true, state: 'channels-converged' };
3579
3876
  }
3580
3877
 
3878
+ const HOST_LABELS = { claude: 'Claude Code', codex: 'Codex' };
3879
+ /** The one accurate line for converged hosts whose already-open windows booted the old declarations. */
3880
+ export function openSessionsNotice(hosts, version) {
3881
+ const names = hosts.map((host) => HOST_LABELS[host] || host).join('/');
3882
+ return `new ${names} sessions use ${version}; already-open windows keep the old hook definitions until they are reopened`;
3883
+ }
3884
+
3885
+ /** A moved brain whose disk is unplugged (plugin/scripts/brain-location.mjs state 'unmounted') is never
3886
+ * reinstalled, updated or cleaned beside the dead link (ADR-098): stop with that module's one line. */
3887
+ function refuseUnmountedBrain() {
3888
+ const roots = footprintRoots();
3889
+ if (!roots.dangling) return;
3890
+ die(roots.location.message, 'Nothing was installed, updated or removed.');
3891
+ }
3892
+
3581
3893
  async function runUpdate() {
3582
3894
  printBanner('update');
3895
+ refuseUnmountedBrain();
3583
3896
  const kbDir = resolvedKbDir();
3584
3897
  const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(kbDir);
3585
3898
  if (FLAG_HOST_SYNC_ONLY) {
@@ -3590,6 +3903,7 @@ async function runUpdate() {
3590
3903
  process.exitCode = 1;
3591
3904
  return;
3592
3905
  }
3906
+ if (convergence.convergence?.notice) info(convergence.convergence.notice);
3593
3907
  try {
3594
3908
  const managed = applyManagedCatalogUpdate({
3595
3909
  routerDir: path.join(os.homedir(), '.claude', 'model-router'),
@@ -3694,6 +4008,12 @@ async function runUpdate() {
3694
4008
  } catch (error) {
3695
4009
  warn(`could not place the trusted coverage validator (${error.message}); the updater will report what it finds`);
3696
4010
  }
4011
+ // ADR-0098: release every KB copy that is PROVEN disposable before the updater looks. Its preflight
4012
+ // refuses ("unresolved rollback state exists") while a full copy it cannot account for sits beside the
4013
+ // live KB, and its own redundancy proof can never account for an older generation — measured
4014
+ // 2026-10-01: three copies (3.6 GB) kept every later update from running at all. This runs under the
4015
+ // refresh lock this process already holds; private-unique copies stay and are named.
4016
+ enforceFootprint({ holdingRefreshLock: true, quiet: true });
3697
4017
  info(c.dim("running the bundle's own self-updater (backs up first, re-verifies, never half-applies)…\n"));
3698
4018
  // Relative filename + matching cwd — same launch convention as smokeQuery(); stdio:'inherit'
3699
4019
  // streams the updater's narration live and unedited.
@@ -3794,6 +4114,33 @@ async function runUpdate() {
3794
4114
  return;
3795
4115
  }
3796
4116
  if (cleanupPending) updateStatus = 0;
4117
+ // The updater refuses unsigned or mis-signed bundles (exit 3/4), so an applied result names bytes whose
4118
+ // signature verified. Record it bound to the live COVERAGE.json (ADR-0098 positive confirmation).
4119
+ if (updaterResult?.terminalVerdict === 'applied' && updaterResult.bundleSha256) {
4120
+ try {
4121
+ const corpusTag = JSON.parse(fs.readFileSync(path.join(kbDir, 'SOURCE.json'), 'utf8')).corpusReleaseTag || null;
4122
+ writeSignatureRecord({ brainHome: footprintRoots().brainHome, kbDir, bundleSha256: updaterResult.bundleSha256,
4123
+ releaseTag: corpusTag, source: 'update', now: footprintNow() });
4124
+ } catch (error) { warn(`signature verification could not be recorded (${error.message})`); }
4125
+ } else if (updateStatus === 0) {
4126
+ // Nothing was applied (already current). The doctor names `--update` as the fix for a missing record, so
4127
+ // this run must be able to write it — but only from proof: this machine's own receipt of an update that
4128
+ // APPLIED a signature-verified bundle whose coverage digest equals the live COVERAGE.json (review S5).
4129
+ const roots = footprintRoots();
4130
+ try {
4131
+ if (!signatureRecordValid({ brainHome: roots.brainHome, kbDir })) {
4132
+ const evidence = signatureEvidenceFromReceipts({ brainHome: roots.brainHome, kbDir });
4133
+ if (evidence) {
4134
+ writeSignatureRecord({ brainHome: roots.brainHome, kbDir, bundleSha256: evidence.bundleSha256,
4135
+ source: `update-receipt:${evidence.runId}`, now: footprintNow() });
4136
+ ok('signature verification recorded for the live knowledge (from this machine\'s verified update receipt)');
4137
+ } else {
4138
+ warn('no signature verification is on record for the live knowledge, and no verified update of these exact bytes is either;');
4139
+ info(`it is recorded by the next update that applies a signed release, or now by a verified reinstall: ${c.bold('npx ruvnet-brain@latest --force')}`);
4140
+ }
4141
+ }
4142
+ } catch (error) { warn(`signature verification could not be recorded (${error.message})`); }
4143
+ }
3797
4144
  let phaseEvidence = updaterResult?.phaseEvidence || null;
3798
4145
  if (!phaseEvidence) {
3799
4146
  const installed = validateCoverageDirectory(kbDir, { expectedVersion: PACKAGE_VERSION });
@@ -3826,9 +4173,11 @@ async function runUpdate() {
3826
4173
  if (!convergence.ok) {
3827
4174
  warn(`host synchronization is incomplete — runtime stays on the prior verified generation${convergence.error ? ` (${convergence.error})` : ''}`);
3828
4175
  updateStatus = 1;
3829
- }
4176
+ } else if (convergence.convergence?.notice) info(convergence.convergence.notice);
4177
+ try { reportLegacyRufloDebris(); } catch (error) { warn(`legacy ruflo debris cleanup failed: ${error.message}`); }
3830
4178
  recordRefreshPhase(refreshReceipt, 'host-convergence', convergence.ok && convergence.convergence?.healthy === true ? 'PASS' : 'FAIL', {
3831
4179
  state: convergence.convergence?.state || null, error: convergence.error || null,
4180
+ ...(convergence.convergence?.openSessions ? { openSessions: convergence.convergence.openSessions } : {}),
3832
4181
  execution: { kind: 'executed', runId: refreshReceipt.runId },
3833
4182
  });
3834
4183
  }
@@ -3879,12 +4228,24 @@ async function runUpdate() {
3879
4228
  execution: { kind: 'executed', runId: refreshReceipt.runId },
3880
4229
  required: cleanupFailed || updateStatus === 0,
3881
4230
  });
4231
+ // ADR-0098: after the swap, enforce the footprint (the updater just released its own rollback; this
4232
+ // releases everything else that must not exist) and record the result on the receipt as an advisory —
4233
+ // a footprint problem is reported, it never fails an otherwise-good update.
4234
+ const footprint = enforceFootprint({ holdingRefreshLock: true });
4235
+ try {
4236
+ const after = footprint?.after;
4237
+ recordRefreshAdvisory(refreshReceipt, 'footprint', after && after.kbCopies === 1 && !after.cruft.length && after.withinBudget ? 'PASS' : 'FAIL', {
4238
+ kbCopies: after?.kbCopies ?? null, cruft: after?.cruft.length ?? null, totalBytes: after?.totalBytes ?? null,
4239
+ budgetBytes: after?.budgetBytes ?? null, removed: footprint?.removed.length ?? 0, freedBytes: footprint?.freedBytes ?? 0,
4240
+ });
4241
+ } catch { /* the advisory never blocks settlement */ }
3882
4242
  settleRefresh(cleanupPending ? 12 : (retentionFailed ? 1 : updateStatus), {
3883
4243
  phase: cleanupFailed ? 'cleanup' : (updateStatus === 0 ? 'complete' : 'failed'),
3884
4244
  terminalVerdict: cleanupPending ? 'cleanup-pending' : retentionFailed ? 'recovery-required'
3885
4245
  : (outcome.verdict === 'noop' ? 'noop' : 'applied'),
3886
4246
  storageDelta: updaterResult?.storageDelta || null, lifecycleRetention: retention });
3887
4247
  process.removeListener('exit', exitGuard);
4248
+ await printConfirmation({ footprint: footprint?.after || null });
3888
4249
  }
3889
4250
 
3890
4251
  function enableNightly() {
@@ -4062,6 +4423,63 @@ const upgradeNoticeStatePath = () =>
4062
4423
  const brainOffSentinelPath = () =>
4063
4424
  path.join(process.env.RUVNET_BRAIN_STATE_DIR || path.join(os.homedir(), '.config', 'ruvnet-brain'), 'brain-off');
4064
4425
 
4426
+ // ── ADR-0098: THE FOOTPRINT GUARANTEE — one KB, current, in use, nothing building up ─────────────
4427
+ // The classifier and sweep live in plugin/scripts/brain-footprint.mjs (shared with SessionStart's
4428
+ // detached sweep); this file supplies the two collectors only it may run: the lease-aware plugin
4429
+ // generation collector and the lifecycle-evidence pruner. Test seams (RUVNET_BRAIN_TEST=1 only):
4430
+ // RUVNET_BRAIN_TEST_NPM_LATEST pins the registry answer, RUVNET_BRAIN_TEST_NOW the clock.
4431
+ const footprintNow = () => (process.env.RUVNET_BRAIN_TEST === '1' && Date.parse(process.env.RUVNET_BRAIN_TEST_NOW || ''))
4432
+ || Date.now();
4433
+ async function npmLatestVersion() {
4434
+ if (process.env.RUVNET_BRAIN_TEST === '1' && process.env.RUVNET_BRAIN_TEST_NPM_LATEST) {
4435
+ return { version: process.env.RUVNET_BRAIN_TEST_NPM_LATEST, checkedAt: footprintNow(), source: 'test registry seam' };
4436
+ }
4437
+ try {
4438
+ const response = await fetch('https://registry.npmjs.org/ruvnet-brain/latest', { signal: AbortSignal.timeout(3_000) });
4439
+ const metadata = response.ok ? await response.json() : null;
4440
+ return typeof metadata?.version === 'string' ? { version: metadata.version, checkedAt: Date.now(), source: 'npm registry, live' } : null;
4441
+ } catch { return null; }
4442
+ }
4443
+ function footprintEvidence() {
4444
+ const { brainHome, kbDir } = footprintRoots();
4445
+ try { return assessLifecycleEvidence({ brainHome, kbDir }); } catch { return null; }
4446
+ }
4447
+
4448
+ /** Enforce the footprint now: remove what must not exist (proof-gated), rotate over-cap logs, collect
4449
+ * lease-free plugin generations. Narrates every removal and every refusal; never throws. */
4450
+ export function enforceFootprint({ holdingRefreshLock = false, pruneEvidence = false, quiet = false } = {}) {
4451
+ let result;
4452
+ try {
4453
+ result = sweepFootprint({ apply: true, holdingRefreshLock, now: footprintNow(), evidence: footprintEvidence(),
4454
+ collectPluginGenerations: ({ registryPath, apply }) => prunePluginGenerations({ registryPath, apply }),
4455
+ pruneEvidence: pruneEvidence ? (args) => pruneLifecycleEvidence(args) : null });
4456
+ } catch (error) {
4457
+ warn(`footprint sweep could not run (${error.message}); nothing was removed`);
4458
+ return null;
4459
+ }
4460
+ if (result.removed.length) ok(`footprint: removed ${result.removed.length} item(s) that must not exist, freed ${formatBytes(result.freedBytes)}`);
4461
+ for (const r of quiet ? [] : result.removed) info(c.dim(` removed ${r.path.replace(os.homedir(), '~')} — ${r.reason}`));
4462
+ if (result.rotated.length) ok(`footprint: rotated ${result.rotated.length} log(s) past their size cap`);
4463
+ if (result.plugins?.removed?.length) ok(`footprint: collected plugin generation(s) ${result.plugins.removed.join(', ')}`);
4464
+ for (const k of result.kept.filter((item) => /^KEPT/.test(item.reason) || item.unique)) {
4465
+ warn(`footprint: ${k.path.replace(os.homedir(), '~')} ${k.reason}`);
4466
+ for (const { file, why } of (k.unique || []).slice(0, 5)) info(c.dim(` ${file} — ${why}`));
4467
+ }
4468
+ return result;
4469
+ }
4470
+
4471
+ /** Print (or emit as JSON) the positive-confirmation block for the machine as it is right now. */
4472
+ async function printConfirmation({ footprint = null, json = false, print = true } = {}) {
4473
+ const now = footprintNow();
4474
+ // Read-only: the inventory only — no copy is proven (no GB-sized hashing) and nothing is written (re-review S4).
4475
+ const fp = footprint || inventoryFootprint({ now, evidence: footprintEvidence() });
4476
+ const result = confirm({ footprint: fp, npmLatest: await npmLatestVersion(), installedVersion: PACKAGE_VERSION, now });
4477
+ if (!print) return result;
4478
+ if (json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
4479
+ else console.log(`\n${formatConfirmation(result, { color: c })}`);
4480
+ return result;
4481
+ }
4482
+
4065
4483
  /**
4066
4484
  * Everything this installer can leave on a machine, DERIVED from disk — never asserted.
4067
4485
  *
@@ -4851,20 +5269,31 @@ function detectEnvironment() {
4851
5269
  platform: process.platform,
4852
5270
  arch: process.arch,
4853
5271
  claude: have('claude'),
4854
- ruflo: have('ruflo') || have('claude-flow'),
5272
+ ruflo: (() => { const r = locateRuflo(); return Boolean(r.cli || r.configured); })(),
4855
5273
  ruvector: have('ruvector') || hasUserScopeMcpServer('ruvector'),
4856
5274
  };
4857
- // Also honor a toolkit that's wired into the Claude config even if the CLI isn't on PATH (npx users).
4858
- try {
4859
- const settings = path.join(os.homedir(), '.claude', 'settings.json');
4860
- if (fs.existsSync(settings)) {
4861
- const s = fs.readFileSync(settings, 'utf8');
4862
- if (/claude-flow|\bruflo\b/i.test(s)) env.ruflo = true;
4863
- }
4864
- } catch { /* ignore — detection is best-effort */ }
4865
5275
  return env;
4866
5276
  }
4867
5277
 
5278
+ /**
5279
+ * THE Ruflo locator — the only way the installer and --doctor decide whether Ruflo is here. Every route it
5280
+ * uses is a parameter, so a caller (or a test) controls all of them: the CLI is `ruflo` (or the legacy
5281
+ * `claude-flow`) found by the shell on `env.PATH`, and `configured` means `~/.claude/settings.json`
5282
+ * mentions it (npx users with no CLI on PATH). Nothing else is consulted — not node's own directory, not the
5283
+ * npm prefix — unless it is on that PATH. (CI run 36919727923: `npm i -g ruflo` puts ruflo beside node, so a
5284
+ * test PATH that kept node's directory still "had" ruflo.)
5285
+ */
5286
+ export function locateRuflo({ env = process.env, home = os.homedir() } = {}) {
5287
+ const which = (cmd) => {
5288
+ const r = IS_WIN ? spawnSync('where', [cmd], { env, encoding: 'utf8' })
5289
+ : spawnSync('sh', ['-c', `command -v -- ${cmd}`], { env, encoding: 'utf8' });
5290
+ return !r.error && r.status === 0 ? (String(r.stdout || '').trim().split(/\r?\n/)[0] || null) : null;
5291
+ };
5292
+ let configured = false;
5293
+ try { configured = /claude-flow|\bruflo\b/i.test(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8')); } catch { /* none */ }
5294
+ return { cli: which('ruflo') || which('claude-flow'), configured };
5295
+ }
5296
+
4868
5297
  export function classifyRufloOperationalHealth({ status = '', memory = '', metrics = '' } = {}) {
4869
5298
  const stopped = /\b(?:RuFlo V3 \[STOPPED\]|Swarm not running|MCP Server\s*\\n.*Not running)/i.test(status);
4870
5299
  const statusSaysNoMemory = /Backend\s*[|:]?\s*none|Entries\s*[|:]?\s*0\b/i.test(status);
@@ -4889,22 +5318,94 @@ export function classifyRufloOperationalHealth({ status = '', memory = '', metri
4889
5318
  };
4890
5319
  }
4891
5320
 
4892
- function probeRufloOperationalHealth() {
4893
- const run = (args) => {
4894
- // Every `ruflo` invocation auto-starts a project background daemon unless this is set
4895
- // (verified live: ~/.npm-global/lib/node_modules/ruflo/node_modules/@claude-flow/cli/dist/src/
4896
- // services/daemon-autostart.js:85) — a read-only health probe must not leave one running.
4897
- const result = spawnSync('ruflo', args, { cwd: process.cwd(), encoding: 'utf8', timeout: 10_000,
4898
- env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
4899
- return `${result.stdout || ''}\n${result.stderr || ''}`;
4900
- };
5321
+ const RUFLO_NOT_INITIALIZED = /not initialized in this directory/i;
5322
+ const rufloProbeRun = (args, cli = 'ruflo', timeoutMs = 10_000) => {
5323
+ // Every `ruflo` invocation auto-starts a project background daemon unless this is set
5324
+ // (verified live: ~/.npm-global/lib/node_modules/ruflo/node_modules/@claude-flow/cli/dist/src/
5325
+ // services/daemon-autostart.js:85) — a read-only health probe must not leave one running.
5326
+ const result = spawnSync(cli, args, { cwd: process.cwd(), encoding: 'utf8', timeout: timeoutMs, shell: IS_WIN && /\.(?:cmd|bat)$/i.test(cli),
5327
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
5328
+ // A spawn failure or a timeout is marked, so it can never pass for an (empty, "healthy") answer.
5329
+ return `${result.stdout || ''}\n${result.stderr || ''}${result.error ? `\n[ruflo-probe-error] ${result.error.code || result.error.message}` : ''}`;
5330
+ };
5331
+
5332
+ /**
5333
+ * The Ruflo probe, READ-ONLY and therefore the same on every run. Measured on ruflo 3.49.0: in a directory
5334
+ * that has not run `ruflo init`, `ruflo status` only reports "not initialized", but `ruflo status memory`
5335
+ * WRITES .swarm/, .claude-flow/ and ruvector.db into it and `hooks metrics` writes .claude-flow/. So the old
5336
+ * probe initialized the user's directory itself: the first --doctor read "not initialized" as degraded
5337
+ * learning (✗), every later one saw "[STOPPED]" (direct mode, ✓) — text and --json disagreed on CI Linux,
5338
+ * run 36915686695. An uninitialized directory is now "not applicable" and the writing commands never run.
5339
+ */
5340
+ export function probeRufloOperationalHealth({ cli = 'ruflo', timeoutMs = 10_000, run = (args) => rufloProbeRun(args, cli, timeoutMs) } = {}) {
5341
+ const status = run(['status']);
5342
+ // No answer (could not start, timed out, printed nothing) is NOT health, and nothing more is asked (re-review NIT).
5343
+ if (/\[ruflo-probe-error\]/.test(status) || !status.trim()) {
5344
+ return { healthy: false, unanswered: true, directMode: false, stopped: false, zeroLearning: false, memoryContradiction: false, memoryEntries: 0 };
5345
+ }
5346
+ if (RUFLO_NOT_INITIALIZED.test(status)) {
5347
+ return { healthy: true, notInitialized: true, directMode: false, stopped: false, zeroLearning: false, memoryContradiction: false, memoryEntries: 0 };
5348
+ }
4901
5349
  return classifyRufloOperationalHealth({
4902
- status: run(['status']),
5350
+ status,
4903
5351
  memory: run(['status', 'memory']),
4904
5352
  metrics: run(['hooks', 'metrics', '--v3-dashboard']),
4905
5353
  });
4906
5354
  }
4907
5355
 
5356
+ /** sha256 of the live COVERAGE.json: the identity a persisted grounding verdict is bound to. */
5357
+ export function liveCoverageSha256(kbDir) {
5358
+ try { return crypto.createHash('sha256').update(fs.readFileSync(path.join(kbDir, 'COVERAGE.json'))).digest('hex'); } catch { return null; }
5359
+ }
5360
+
5361
+ /**
5362
+ * THE Grounding line of the doctor's one verdict (ADR-058 §D8), from the live question and the persisted
5363
+ * verdict only (owner ruling: a WARM-UP timeout is advisory; the timed QUESTION timing out is ✗):
5364
+ * live proven ✓ (and the persisted verdict was just rewritten, bound to these bytes)
5365
+ * persisted UNPROVEN, live not proven ✗ — the persisted verdict gates
5366
+ * reader missing / incomplete / broken ✗ — an install defect, fix: reinstall (a verifier that throws on
5367
+ * import is "broken", not "cannot settle")
5368
+ * warm-up crashed, answer wrong or late ✗
5369
+ * model warm-up ran out of time ! — a slow machine, advisory, fix: run it again
5370
+ * NO verifier at all (none installed, none shippable): the live question cannot settle it, so a persisted
5371
+ * 'proven' verdict FOR THESE BYTES (coverage digest) passes, named as not re-proven live; anything else ✗.
5372
+ * Only this narrow case can pass without a live proof.
5373
+ */
5374
+ export function groundingCheckLine({ smoke = {}, persisted = null, coverageSha256 = null } = {}) {
5375
+ const line = (state, detail, fix = null) => ({ id: 'grounding', label: 'Grounding', state, detail, fix: state === 'ok' ? null : fix });
5376
+ // Coarse by design (tests/unit/install-state.test.mjs): any recorded state that is not literally 'proven' is unproven.
5377
+ const recorded = persisted && typeof persisted === 'object' ? persisted : null;
5378
+ if (smoke.grounded === true) return line('ok', `proven (${smoke.receipt?.path || 'cited passage'})`);
5379
+ if (recorded && recorded.grounding !== 'proven') return line('fail', `recorded UNPROVEN${recorded.reason ? ` (${recorded.reason})` : ''}`, 'npx ruvnet-brain');
5380
+ if (smoke.grounded === null) {
5381
+ const forTheseBytes = recorded?.grounding === 'proven' && Boolean(coverageSha256) && recorded.coverageSha256 === coverageSha256;
5382
+ return forTheseBytes
5383
+ ? line('ok', `not re-proven live (${smoke.reason || 'no verifier'}); last proven for these bytes${recorded.clearedBy ? ` by ${recorded.clearedBy}` : ''}`)
5384
+ : line('fail', `not verifiable live (${smoke.reason || 'no verifier'}) and never proven for these bytes`, 'npx ruvnet-brain');
5385
+ }
5386
+ if (smoke.warmupTimeout) return line('warn', `not proven in time (${smoke.reason || 'slow'})`, 'npx ruvnet-brain --doctor (again, when the machine is less busy)');
5387
+ // The reader's own deadline ("slow on this machine, not broken") is ✗, but its fix is to run it again: a
5388
+ // reinstall does not make a machine faster (and the narration says so).
5389
+ return line('fail', `not proven (${smoke.reason || (smoke.ran === false ? 'the live question did not run' : 'unknown')})`,
5390
+ smoke.slow ? 'npx ruvnet-brain --doctor (again, when the machine is less busy)' : 'npx ruvnet-brain');
5391
+ }
5392
+
5393
+ /** THE Ruflo line of the doctor's one verdict: derived only from the probe result, for text and --json alike. */
5394
+ export function rufloCheckLine(health) {
5395
+ if (health.unanswered) {
5396
+ return { id: 'ruflo', label: 'Ruflo', state: 'fail', detail: '`ruflo status` did not answer (could not start or timed out)', fix: 'ruflo doctor --fix' };
5397
+ }
5398
+ if (health.configuredOnly) {
5399
+ return { id: 'ruflo', label: 'Ruflo', state: 'unknown', detail: 'configured in ~/.claude/settings.json; no ruflo CLI on PATH (not probed)', fix: null };
5400
+ }
5401
+ if (health.notInitialized) {
5402
+ return { id: 'ruflo', label: 'Ruflo', state: 'unknown', detail: 'CLI present; not initialized in this directory (no project learning to judge)', fix: null };
5403
+ }
5404
+ return health.healthy
5405
+ ? { id: 'ruflo', label: 'Ruflo', state: 'ok', detail: health.directMode ? 'direct mode (daemon stopped by design)' : 'operational', fix: null }
5406
+ : { id: 'ruflo', label: 'Ruflo', state: 'fail', detail: 'operational learning DEGRADED', fix: 'ruflo doctor --fix' };
5407
+ }
5408
+
4908
5409
  // ── issue #39: the ruvector MCP server's own native VectorDb defaults ITS storage to
4909
5410
  // "./ruvector.db" relative to whatever cwd it happens to be launched in — and Claude Code
4910
5411
  // always launches an MCP server with cwd = the current project, so every consumer project got
@@ -5446,7 +5947,11 @@ nothing; re-run later, or pick a release yourself with --version <tag>.
5446
5947
  Usage:
5447
5948
  npx ruvnet-brain Install the brain + Claude Code plugin (recommended, npm)
5448
5949
  npx github:stuinfla/ruvnet-brain Same, but from the bleeding-edge GitHub commit
5449
- npx ruvnet-brain --doctor Health-check an existing install (green/red per part).
5950
+ npx ruvnet-brain --doctor Health-check an existing install (green/red per part), ending with the
5951
+ positive confirmation: latest software, ONE current signed KB in use,
5952
+ footprint within budget, no cruft. --doctor --json prints only that, as JSON.
5953
+ npx ruvnet-brain --clean Remove everything that must not exist (old KB copies — never one holding
5954
+ private files the live brain lacks — old installers, over-cap logs), then confirm.
5450
5955
  EXITS NON-ZERO when the install is genuinely broken, so it can gate a
5451
5956
  script: npx ruvnet-brain --doctor && ./deploy.sh
5452
5957
  npx ruvnet-brain --doctor --hooks
@@ -5477,7 +5982,9 @@ Usage:
5477
5982
  opt-in prompt appears once at install; answer lives in a plain file:
5478
5983
  ~/.cache/ruvnet-brain/.telemetry-consent)
5479
5984
  node bin/install.mjs --version <tag> Install a specific Release tag (e.g. --version v0.5.0-dev)
5480
- node bin/install.mjs --pin Skip the latest-check; use the bundled known-good version
5985
+ node bin/install.mjs --pin <tag> Same as --version <tag>: install exactly that release
5986
+ node bin/install.mjs --move-brain <dir> Move the whole Brain to <dir> (e.g. an external disk); ~/.cache/ruvnet-brain links to it
5987
+ node bin/install.mjs --move-brain --back Bring the Brain back to ~/.cache/ruvnet-brain
5481
5988
  node bin/install.mjs --local Install from a repo clone's assembled dist/ruvnet-brain/
5482
5989
  node bin/install.mjs --force Re-fetch and reinstall even if already present
5483
5990
  node bin/install.mjs --no-verify Skip the post-install verify + warm-up smoke test
@@ -5516,10 +6023,42 @@ the installer reports that boot-level declarations changed.
5516
6023
  && canonical(process.argv[1]) === canonical(fileURLToPath(import.meta.url));
5517
6024
  if (!invokedDirectly) return;
5518
6025
  if (FLAG_HELP) return showHelp();
6026
+ // The Brain moved to another disk (a symlink at ~/.cache/ruvnet-brain) and that disk is not here:
6027
+ // say so in one line and stop. Never re-create a fresh brain in ~/.cache over the dangling link, and
6028
+ // never update one that is not there. --doctor reports it as its verdict.
6029
+ {
6030
+ const where = brainLocation();
6031
+ if (where.state === 'unmounted' && !FLAG_MOVE_BRAIN) {
6032
+ if (FLAG_DOCTOR) { printBanner('doctor'); warn(where.message); console.log(`\n ${c.red('✗ BRAIN DISK NOT MOUNTED')} — nothing was checked or changed; this is not a health verdict.`); process.exitCode = 1; return; }
6033
+ die(where.message, 'Plug the disk in (or mount it), then run the same command again.');
6034
+ }
6035
+ }
6036
+ if (FLAG_MOVE_BRAIN) {
6037
+ printBanner('move the brain');
6038
+ try {
6039
+ const moved = moveBrain({ to: MOVE_BRAIN_TO, back: argv.includes('--back'), log: info });
6040
+ ok(`the Brain is at ${moved.to}${moved.to === moved.link ? '' : ` (${moved.link} links to it)`} — ${(moved.bytes / 1024 ** 3).toFixed(2)} GB moved, verified byte for byte`);
6041
+ } catch (error) {
6042
+ if (error instanceof MoveRefused) die(error.message);
6043
+ throw error;
6044
+ }
6045
+ return;
6046
+ }
5519
6047
  // `process.exitCode`, not `return` — doctor()'s verdict is the whole point of running it in a
5520
6048
  // script. A bare `return await doctor()` discarded the number, which is how "! Needs attention"
5521
6049
  // and `echo $?` → 0 coexisted for so long.
5522
- if (FLAG_DOCTOR) { process.exitCode = await doctor(); return; }
6050
+ // `--doctor --json`: the positive-confirmation object ONLY (no narration), exit 0 iff every line that
6051
+ // can be proven here is green — for scripts, CI, and agents (ADR-0098).
6052
+ if (FLAG_DOCTOR) { process.exitCode = await doctor({ json: FLAG_JSON }); return; }
6053
+ // `--clean`: enforce the footprint guarantee now, then confirm. Exit 0 only when exactly one KB copy
6054
+ // remains and nothing that must not exist is left (a private-unique copy kept for safety is a 1).
6055
+ if (FLAG_CLEAN) {
6056
+ if (!FLAG_JSON) printBanner('clean');
6057
+ const swept = enforceFootprint({ pruneEvidence: true, quiet: FLAG_JSON });
6058
+ const result = await printConfirmation({ footprint: swept?.after || null, json: FLAG_JSON });
6059
+ process.exitCode = swept && result.footprint.kbCopies === 1 && result.footprint.cruft.length === 0 ? 0 : 1;
6060
+ return;
6061
+ }
5523
6062
  if (FLAG_DEMO) return runDemo();
5524
6063
  if (FLAG_FEEDBACK) return runFeedback();
5525
6064
  if (FLAG_UPDATE) return runUpdate();
@@ -5557,6 +6096,7 @@ the installer reports that boot-level declarations changed.
5557
6096
  }
5558
6097
 
5559
6098
  await printPlanAndConfirm();
6099
+ refuseUnmountedBrain();
5560
6100
 
5561
6101
  const { cacheDir, isCustom } = resolveCacheDir();
5562
6102
 
@@ -5656,13 +6196,17 @@ the installer reports that boot-level declarations changed.
5656
6196
  // bundle cannot also swap the key it is checked against).
5657
6197
  //
5658
6198
  // SIGNING_REQUIRED was `false` transitionally, for releases that predated signing. That is over:
5659
- // every release from v2.0.0 on is signed, including the pinned offline fallback (RELEASE_VERSION).
6199
+ // every release from v2.0.0 on is signed.
5660
6200
  // Leaving it false left a real downgrade path — strip or 404 the small .sig file and the missing-
5661
6201
  // signature branch printed a warning and extracted 800MB+ of executable .mjs anyway. No alarm
5662
6202
  // fired, because no signature was ever obtained. Now a missing signature fails closed like an
5663
6203
  // invalid one, and --no-verify remains the single explicit, user-chosen override.
5664
6204
  const SIGNING_REQUIRED = true;
5665
- if (downloaded && !FLAG_NO_VERIFY) {
6205
+ let signedBundleSha256 = null; // set only when THIS run verified the bundle's Ed25519 signature
6206
+ // A local bundle that carries its signature beside it is verified (and recorded) too; without one it
6207
+ // installs as before, unverified and recorded as such (the doctor says "signature NOT verified").
6208
+ const localSigned = !downloaded && Boolean(zipPath) && fs.existsSync(`${zipPath}.sig`);
6209
+ if ((downloaded || localSigned) && !FLAG_NO_VERIFY) {
5666
6210
  const sigPath = `${zipPath}.sig`;
5667
6211
  const hasSig = fs.existsSync(sigPath);
5668
6212
  if (!hasSig) {
@@ -5678,6 +6222,7 @@ the installer reports that boot-level declarations changed.
5678
6222
  `Refusing to extract an unverified bundle. Re-run to fetch a fresh copy; if it persists, the\nrelease may be tampered — report it. (Override at your own risk with ${c.bold('--no-verify')}.)`);
5679
6223
  }
5680
6224
  ok(reason);
6225
+ signedBundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(zipPath)).digest('hex');
5681
6226
  }
5682
6227
  }
5683
6228
  // The tag is only carried when it came from a genuine `latest` resolution — a pinned/offline
@@ -5686,6 +6231,12 @@ the installer reports that boot-level declarations changed.
5686
6231
  await unzipInto(zipPath, cacheDir, sourceDir, {
5687
6232
  releaseTag: release && release.source === 'latest' ? (release.tag_name || release.tag || null) : null,
5688
6233
  });
6234
+ if (signedBundleSha256) {
6235
+ try {
6236
+ writeSignatureRecord({ brainHome: footprintRoots().brainHome, kbDir: cacheDir, bundleSha256: signedBundleSha256,
6237
+ releaseTag: release?.tag_name || release?.tag || null, source: 'install', now: footprintNow() });
6238
+ } catch (error) { warn(`signature verification could not be recorded (${error.message})`); }
6239
+ }
5689
6240
  const brainProfile = readBrainProfile();
5690
6241
  if (brainProfile !== 'complete') {
5691
6242
  const scoped = applyBrainProfile(cacheDir, brainProfile);
@@ -5703,7 +6254,7 @@ the installer reports that boot-level declarations changed.
5703
6254
  } catch (error) {
5704
6255
  die(
5705
6256
  `the Brain Console runtime could not be installed (${error.message})`,
5706
- `The knowledge base is present, but /rvbc would be broken. Re-run the installer from a complete package.`,
6257
+ `The knowledge base is present, but /rnbc would be broken. Re-run the installer from a complete package.`,
5707
6258
  );
5708
6259
  }
5709
6260
  retireManagedHookRegistrations();
@@ -5766,6 +6317,11 @@ the installer reports that boot-level declarations changed.
5766
6317
  // out what we did — which is precisely the position the 2026-07-20 corporate-machine reporter was
5767
6318
  // left in. Derived from disk, so it can only ever describe what is actually there.
5768
6319
  try { printFootprint(); } catch { /* a summary must never break a finished install */ }
6320
+ // ADR-0098: enforce the footprint the install just produced, then prove it in one block.
6321
+ try {
6322
+ const swept = enforceFootprint({ pruneEvidence: true, quiet: true });
6323
+ await printConfirmation({ footprint: swept?.after || null });
6324
+ } catch (error) { warn(`positive confirmation could not run (${error.message})`); }
5769
6325
 
5770
6326
  // ── SCOPE + UPGRADE, on the path a real user actually takes ─────────────────────────────────
5771
6327
  //
@@ -5845,6 +6401,8 @@ the installer reports that boot-level declarations changed.
5845
6401
  mod.writeInstallState({
5846
6402
  grounding,
5847
6403
  reason: !smoke ? 'verify-skipped' : (smoke.grounded === true ? null : (smoke.reason || (smoke.ran ? 'not-grounded' : 'no-answer'))),
6404
+ // The verdict is about THESE bytes: a later corpus no longer inherits it (re-review S1).
6405
+ coverageSha256: liveCoverageSha256(cacheDir),
5848
6406
  });
5849
6407
  if (grounding !== 'proven' && process.env.RUVNET_STRICT_INSTALL === '1') {
5850
6408
  die(