ruvnet-brain 4.4.1 → 4.5.1

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 (102) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +612 -113
  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 +263 -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 +17 -2
  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 +4 -2
  58. package/plugin/scripts/protect-brain-state.sh +4 -1
  59. package/plugin/scripts/session-snapshot-hook.mjs +27 -3
  60. package/plugin/scripts/session-start-budget.mjs +1 -0
  61. package/plugin/scripts/session-start-core.mjs +34 -4
  62. package/plugin/scripts/session-start-health.mjs +7 -1
  63. package/plugin/scripts/session-start-update-plane.mjs +35 -0
  64. package/plugin/scripts/turn-outcome-capture.mjs +12 -1
  65. package/plugin/scripts/unprompted-runtime.mjs +2 -2
  66. package/plugin/skills/brain-console/SKILL.md +3 -3
  67. package/plugin/skills/rnbc/SKILL.md +24 -0
  68. package/plugin/skills/rvbc/SKILL.md +2 -2
  69. package/scripts/approved-runtime.mjs +2 -2
  70. package/scripts/ci/warm-brain-models.mjs +28 -0
  71. package/scripts/codex-hook-trust.mjs +94 -0
  72. package/scripts/console-runtime-identity.mjs +5 -0
  73. package/scripts/corpus-canary.mjs +46 -6
  74. package/scripts/corpus-dispatch-decision.mjs +2 -2
  75. package/scripts/corpus-promotion.mjs +1 -1
  76. package/scripts/hook-qualify-hosts.mjs +15 -3
  77. package/scripts/host-install-matrix.mjs +63 -2
  78. package/scripts/human-approval-phrases.mjs +46 -0
  79. package/scripts/installed-brain-health.mjs +53 -0
  80. package/scripts/move-brain.mjs +310 -0
  81. package/scripts/onboarding-console.mjs +93 -10
  82. package/scripts/oracle/abstain-threshold-sweep.mjs +62 -0
  83. package/scripts/oracle/abstain-trace.mjs +139 -0
  84. package/scripts/oracle/doc2query-generate.mjs +162 -0
  85. package/scripts/oracle/doc2query-reach.mjs +110 -0
  86. package/scripts/oracle/judge-train.mjs +158 -0
  87. package/scripts/oracle/need-set-split.mjs +48 -0
  88. package/scripts/oracle/sona-query-adapter-eval.mjs +139 -0
  89. package/scripts/package-cards.mjs +374 -0
  90. package/scripts/publication-receipt.mjs +37 -9
  91. package/scripts/recommendation-e2e.mjs +110 -0
  92. package/scripts/recommendation-eval.mjs +105 -0
  93. package/scripts/recommendation-floor.mjs +56 -0
  94. package/scripts/recommendation-judge-score.mjs +74 -0
  95. package/scripts/recommendation-latency.mjs +95 -0
  96. package/scripts/recommendation-real-host-score.mjs +76 -0
  97. package/scripts/recommendation-real-host.mjs +137 -0
  98. package/scripts/release-channel-kind.mjs +1 -1
  99. package/scripts/release-environment-policy.mjs +33 -0
  100. package/scripts/single-source-check.mjs +15 -10
  101. package/scripts/sync-commands.mjs +5 -2
  102. 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,10 @@ 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';
75
85
  import { cleanLegacyRufloDebris } from '../plugin/scripts/project-progression-store.mjs';
76
86
  import { resolveProjectStore } from '../plugin/scripts/project-store-resolver.mjs';
77
87
  import { runHostCli, waitForHostCli } from '../scripts/host-cli.mjs';
@@ -107,17 +117,6 @@ const PACKAGE_VERSION = (() => {
107
117
  const REPO = 'stuinfla/ruvnet-brain';
108
118
  const RELEASE_API = `https://api.github.com/repos/${REPO}/releases/latest`;
109
119
  const ASSET_NAME = 'ruvnet-brain.zip';
110
- // Known-good BUNDLE tag, used ONLY by --pin. It is no longer a silent fallback for a failed
111
- // latest-release lookup: that bundle predates ReleaseCoverage and cannot pass validation, so a lookup
112
- // failure now stops with its real cause (resolveRelease / releaseLookupFailure).
113
- //
114
- // This MUST NOT be derived from this package's own version. The installer and the brain bundle are
115
- // two independent version streams (README: "Three independent things version separately here — by
116
- // design"). Reading it from package.json produced a tag that has never existed — installer 1.14.0-dev
117
- // asking for releases/download/v1.14.0-dev/ruvnet-brain.zip, which 404s, while the newest bundle
118
- // Release is v0.5.0-dev. Verified live: v1.14.0-dev → HTTP 404, v0.5.0-dev → HTTP 200. The safety net
119
- // was broken in exactly the situation it exists for. Bump this by hand when a new bundle ships.
120
- const RELEASE_VERSION = 'v2.9.0'; // sync-version-ignore: the BUNDLE Release tag, not this package's version
121
120
  const fallbackUrl = (tag) => `https://github.com/${REPO}/releases/download/${tag}/${ASSET_NAME}`;
122
121
  const APPROX_SIZE = '~736MB';
123
122
 
@@ -132,7 +131,6 @@ const FLAG_HOOKS = argv.includes('--hooks');
132
131
  const FLAG_NO_VERIFY = argv.includes('--no-verify');
133
132
  // Escape hatch for the installer's closing self-check ONLY (it never disables --doctor's verdict).
134
133
  const FLAG_NO_SELFCHECK = argv.includes('--no-selfcheck');
135
- const FLAG_PIN = argv.includes('--pin'); // skip the latest-check, use the bundled default
136
134
  const FLAG_DEMO = argv.includes('--demo'); // guided, real (non-fabricated) walkthrough of the brain in action
137
135
  const FLAG_FEEDBACK = argv.includes('--feedback'); // prefill a GitHub Discussion (version + health, nothing private) and open it
138
136
  // ── freshness flags — invoke/schedule the SELF-UPDATER the bundle already ships (kb/forge-update.mjs) ──
@@ -149,6 +147,12 @@ const FLAG_DISABLE_SPEND_GUARD = argv.includes('--disable-spend-guard'); // the
149
147
  const FLAG_UNINSTALL = argv.includes('--uninstall'); // reverse everything, in one command
150
148
  const FLAG_WHAT_CHANGED = argv.includes('--what-changed'); // show our footprint on this machine
151
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
152
156
  // ── onboarding-experience flags (all optional; every offer is safe to decline) ──
153
157
  const FLAG_YES = argv.includes('--yes') || argv.includes('-y'); // accept every optional offer non-interactively
154
158
  const FLAG_PLAN = argv.includes('--plan') || argv.includes('--dry-run'); // show the interactive checklist, then exit — install NOTHING
@@ -158,12 +162,32 @@ const FLAG_ENHANCE_CLAUDE_MD = argv.includes('--enhance-claude-md'); // add the
158
162
  const FLAG_NO_ENHANCE = argv.includes('--no-enhance'); // skip the CLAUDE.md offer entirely
159
163
  const FLAG_STATUSLINE = argv.includes('--statusline'); // opt in to the status-bar version segment, non-interactively
160
164
  const FLAG_NO_STATUSLINE = argv.includes('--no-statusline'); // decline the status-bar offer without prompting
161
- // --version <tag> forces a specific Release tag (e.g. --version v0.5.0-dev)
162
- const versionIdx = argv.indexOf('--version');
163
- const FORCED_VERSION =
164
- versionIdx !== -1 && argv[versionIdx + 1] && !argv[versionIdx + 1].startsWith('-')
165
- ? argv[versionIdx + 1]
166
- : 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;
167
191
 
168
192
  // ── tiny narrating logger — every step says WHAT and WHY ─────────────────────────────────────────
169
193
  const c = {
@@ -335,7 +359,7 @@ function fetchJson(url, redirects = 0) {
335
359
 
336
360
  // ── step: resolve which Release to download (latest by default; safe fallback) ───────────────────
337
361
  // Default behavior: ask GitHub for the LATEST Release and use its ruvnet-brain.zip asset.
338
- // --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.
339
363
  // Any failure (offline / rate-limited / no releases) THROWS with the HTTP status or network error and
340
364
  // a retry hint; callers that must download stop on it, the staleness check reports "could not check".
341
365
  /**
@@ -370,14 +394,10 @@ async function resolveRelease() {
370
394
  'so a stranger always gets the most current brain — not whatever was hardcoded when this script shipped',
371
395
  );
372
396
 
373
- if (FLAG_PIN) {
374
- info(`--pin set: skipping the latest-check and using the bundled known-good ${c.bold(RELEASE_VERSION)}`);
375
- return { tag: RELEASE_VERSION, url: fallbackUrl(RELEASE_VERSION), source: 'pinned' };
376
- }
377
-
378
- if (FORCED_VERSION) {
379
- info(`--version set: forcing Release ${c.bold(FORCED_VERSION)} (no latest-check)`);
380
- 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 };
381
401
  }
382
402
 
383
403
  // Deterministic integration seam: stale/current behavior must not depend on GitHub API quota.
@@ -476,12 +496,13 @@ async function obtainBundle(release) {
476
496
  return { zipPath: localZip, downloaded: false };
477
497
  }
478
498
 
479
- 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;
480
501
  step(
481
502
  `Downloading the brain (${APPROX_SIZE})`,
482
503
  'the brain embeds source from dozens of RuvNet repos — too big for git, so it ships as a Release',
483
504
  );
484
- info(`version: ${c.bold((release && release.tag) || RELEASE_VERSION)}`);
505
+ info(`version: ${c.bold(release.tag)}`);
485
506
  info(`from: ${downloadUrl}`);
486
507
  // Download into a PRIVATE, per-run temp DIR — never a predictable os.tmpdir()/ruvnet-brain-<pid>.zip
487
508
  // filename (CWE-377: a guessable path invites a pre-created or symlinked file at that location to be
@@ -623,6 +644,18 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
623
644
  );
624
645
 
625
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
+ }
626
659
  const stageDir = fs.mkdtempSync(path.join(path.dirname(cacheDir), `.${path.basename(cacheDir)}.install-stage-`));
627
660
  const localCopy = async () => `local directory copy — ${copyLocalBundleInto(sourceDir, stageDir)} top-level entries`;
628
661
  const nodeExtract = async () => {
@@ -749,6 +782,15 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
749
782
  }
750
783
  }
751
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);
752
794
  const priorPrefix = `${path.basename(cacheDir)}.install-prior-`;
753
795
  const unresolved = fs.readdirSync(parent).filter((name) => name.startsWith(priorPrefix));
754
796
  if (unresolved.length) {
@@ -803,13 +845,33 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
803
845
  hadPrior ? 'the prior brain generation was not moved' : 'no prior brain generation existed'}`,
804
846
  `Candidate retained for inspection at ${stageDir}.`);
805
847
  }
806
- if (hadPrior) warn(`PRESERVED_UNCLASSIFIED: prior generation retained at ${preservedDir}. ` +
807
- 'The updater (kb/forge-update.mjs) releases it only once every byte is proven to survive in the live brain; ' +
808
- '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);
809
873
  ok(`brain unpacked to ${cacheDir}`);
810
- return { status: 'ACTIVATED', priorGeneration: hadPrior
811
- ? { status: 'PRESERVED_UNCLASSIFIED', path: preservedDir, automaticCleanupEligible: false }
812
- : null };
874
+ return { status: 'ACTIVATED', priorGeneration };
813
875
  }
814
876
 
815
877
  /** Stage and validate a bundle for forge-update's private-overlay recovery rail
@@ -1496,7 +1558,7 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1496
1558
  } else if (before.installed && before.version !== installed.version) {
1497
1559
  info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
1498
1560
  }
1499
- info(` commands available${shellBoundary.restartRequired ? ' in new sessions' : ' 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')}`);
1500
1562
  return {
1501
1563
  host: true, wired: true, version: installed.version, manualMarketplace, manualInstall,
1502
1564
  shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
@@ -1516,7 +1578,7 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1516
1578
  ? `Claude installed the plugin with ${installedHookRetirement.registrations.length} retired lifecycle registration(s); refusing to call this host converged.`
1517
1579
  : mismatch
1518
1580
  ? `Claude installed ${installed.version || 'an unknown version'}, not required ${expectedVersion}; refusing to call this host converged.`
1519
- : `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.`);
1520
1582
  info(`${c.green('Your brain still works')}: search_ruvnet is wired and Claude will ground answers with it.`);
1521
1583
  info(`Only the plugin extras (slash commands, the Console, skills, and MCP declaration) are missing.`);
1522
1584
  info(`Run these two yourself to finish:`);
@@ -1853,6 +1915,16 @@ const CODEX_PLUGIN_ID = 'ruvnet-brain@ruvnet-brain';
1853
1915
  const CODEX_MARKETPLACE = 'ruvnet-brain';
1854
1916
  const CODEX_MARKETPLACE_SOURCE = 'stuinfla/ruvnet-brain';
1855
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
+
1856
1928
  function codexMarketplaceTarget() {
1857
1929
  return path.join(
1858
1930
  process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'),
@@ -1984,6 +2056,11 @@ export function wireCodexPlugin({
1984
2056
  path.join(REPO_ROOT, 'plugin'),
1985
2057
  )
1986
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'));
1987
2064
  if (before.installed && before.enabled && versionSatisfies(before.version, expectedVersion)) {
1988
2065
  if (announce) ok(`Codex Brain plugin already installed and enabled (${before.version || 'version unknown'}) — no changes.`);
1989
2066
  return {
@@ -2037,10 +2114,16 @@ export function wireCodexPlugin({
2037
2114
  } else if (before.installed && before.version !== after.version) {
2038
2115
  info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
2039
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
+ }
2040
2122
  }
2041
2123
  return {
2042
2124
  host: true,
2043
2125
  action: before.installed ? 'updated' : 'installed',
2126
+ hooksNeedingReview,
2044
2127
  ...after,
2045
2128
  shellChanged: shellBoundary.changed,
2046
2129
  shellChangedPaths: shellBoundary.paths,
@@ -2143,6 +2226,15 @@ export function classifyCodexLifecycle(plugin, listed = null) {
2143
2226
  const unexpected = hooks.filter((hook) => !continuityHookId(hook?.command, hook?.event));
2144
2227
  if (unexpected.length) return { state: 'unexpected-runtime-hooks', plugin, hooks: unexpected, errors };
2145
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 };
2146
2238
  return { state: 'continuity-registered', plugin, hooks: conforming, errors };
2147
2239
  }
2148
2240
 
@@ -2175,6 +2267,18 @@ export function codexLifecycleGuidance(status) {
2175
2267
  + ' observe Stop or PreCompact, so no capture handler was registered on those.',
2176
2268
  action: null,
2177
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
+ }
2178
2282
  case 'inactive-by-design':
2179
2283
  return {
2180
2284
  healthy: true,
@@ -2448,7 +2552,7 @@ function gatherInstallState(cacheDir) {
2448
2552
  try {
2449
2553
  repos = fs
2450
2554
  .readdirSync(cacheDir)
2451
- .filter((f) => f.endsWith('.rvf')).length;
2555
+ .filter((f) => f.endsWith('.rvf') && !isVolumeMetadata(f)).length; // an exFAT `._x.rvf` shadow is not a store
2452
2556
  } catch {
2453
2557
  /* ignore */
2454
2558
  }
@@ -2509,11 +2613,13 @@ function ensureVerifier(cacheDir) {
2509
2613
  } catch { return 'unavailable'; }
2510
2614
  }
2511
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"). */
2512
2618
  async function loadCitationVerifier(cacheDir) {
2513
2619
  ensureVerifier(cacheDir);
2514
2620
  const p = path.join(cacheDir, 'verify-citation.mjs');
2515
2621
  if (!fs.existsSync(p)) return null;
2516
- 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) }; }
2517
2623
  }
2518
2624
 
2519
2625
  // Proving grounding means proving the answer's CITATION RESOLVES — that the file it points at is a
@@ -2528,14 +2634,53 @@ export function resolveRuntimeModelCache(env = process.env, home = os.homedir())
2528
2634
 
2529
2635
  async function smokeQuery(cacheDir) {
2530
2636
  const ask = path.join(cacheDir, 'forge-ask-all.mjs');
2531
- 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
+ }
2532
2642
  step(
2533
2643
  'Asking the brain a real question',
2534
2644
  'this warms the local model and checks that retrieval returns usable, cited evidence',
2535
2645
  );
2536
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
+ }
2537
2683
  info(`Q: ${c.cyan(`"${Q}"`)}`);
2538
- info(c.dim('(first run downloads a small local model once — this can take a minute)'));
2539
2684
  const started = Date.now();
2540
2685
  let r;
2541
2686
  try {
@@ -2552,19 +2697,18 @@ async function smokeQuery(cacheDir) {
2552
2697
  // path here makes the install smoke warm the model cache the product will actually reopen,
2553
2698
  // instead of a second kb-local cache that can go green while the real door stays cold.
2554
2699
  //
2555
- // RUVNET_BRAIN_QUERY_DEADLINE_MS: this ONE probe is the very first query ever run against a
2556
- // freshly-installed cache — the model is cold and the cross-encoder "rerank" phase has to pay
2557
- // load cost that every later, warm query never pays again. Measured on macOS GitHub Actions
2558
- // runners 2026-09-27 (public-verification runs 36324328134 job 108636740357, 28.5-28.7s; and
2559
- // 36325803503 job 108638381147, 27.1-27.2s): this exact probe consistently needs ~27-29s on
2560
- // that platform, against the general 20s deadline (kb/query-deadline.mjs
2561
- // DEFAULT_QUERY_DEADLINE_MS) that is correct for every normal, warm query. 45s keeps this
2562
- // bounded (never unbounded — the module's core guarantee) while giving this one cold-start
2563
- // probe real margin, without touching the default that protects normal queries everywhere
2564
- // 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.
2565
2709
  env: {
2566
2710
  ...process.env,
2567
- KB_MODEL_CACHE: resolveRuntimeModelCache(),
2711
+ KB_MODEL_CACHE: modelCache,
2568
2712
  RUVNET_BRAIN_QUERY_DEADLINE_MS: process.env.RUVNET_BRAIN_QUERY_DEADLINE_MS ?? '45000',
2569
2713
  },
2570
2714
  });
@@ -2582,11 +2726,10 @@ async function smokeQuery(cacheDir) {
2582
2726
  // crash, a timeout, and a missing module all read as "nothing is wrong, it will warm up".
2583
2727
  // Observed on this machine: a smoke query that produced no answer in 240s was reported as a
2584
2728
  // first-run download. spawnSync already tells us which it was; say that instead.
2585
- const cause = r.error ? `could not launch the reader: ${r.error.message}`
2586
- : r.signal === 'SIGTERM' ? `timed out after ${secs}s (240s limit) with no answer`
2587
- : r.signal ? `the reader was killed by ${r.signal} after ${secs}s`
2588
- : r.status !== 0 ? `the reader exited ${r.status} after ${secs}s`
2589
- : `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;
2590
2733
  warn(`no answer came back — ${cause}`);
2591
2734
  // SHOW THE ACTUAL ERROR (issue #37 bug 2, Agentist-Elder, 2026-07-21).
2592
2735
  //
@@ -2609,17 +2752,29 @@ async function smokeQuery(cacheDir) {
2609
2752
  }
2610
2753
  // The reason travels with the verdict so the doctor's "Grounding NOT proven (<reason>)" line
2611
2754
  // names the real cause too, instead of the generic token.
2612
- 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' };
2613
2757
  }
2614
2758
 
2615
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
+ }
2616
2764
  if (!verifier) {
2617
2765
  info(`the brain answered in ${secs}s, but this bundle predates the citation verifier —`);
2618
2766
  info(c.dim(' re-run `npx ruvnet-brain` to refresh it, and grounding will be PROVEN, not assumed'));
2619
2767
  return { ran: true, grounded: null, reason: 'verifier-missing' };
2620
2768
  }
2621
2769
 
2622
- 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
+ }
2623
2778
  const evidence = classifySmokeEvidence(v, out);
2624
2779
  if (v.grounded && !evidence.usable) {
2625
2780
  warn(`the citation resolves, but the question was not answered with sufficient evidence (${evidence.reason})`);
@@ -2799,15 +2954,47 @@ function meterSummaryLine() {
2799
2954
  // Honest prose that no machine can read is not a check. It could not gate a CI job, a nightly probe,
2800
2955
  // or a `&&` in someone's shell. It now RETURNS a verdict and main() exits with it, so "needs
2801
2956
  // attention" and "success" can never again be the same thing to a script.
2802
- 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 }) {
2803
2967
  printBanner('doctor');
2804
2968
  console.log(c.dim('Checking every part of the install and reporting green/red.\n'));
2805
2969
  const cacheDir = process.env.RUVNET_BRAIN_KB || path.join(os.homedir(), '.cache', 'ruvnet-brain', 'kb');
2806
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
+ }
2807
2975
  const present = fs.existsSync(path.join(cacheDir, 'forge-mcp-all.mjs'));
2808
2976
  if (!present) {
2809
- warn('brain not found here — run the installer first: npx ruvnet-brain');
2810
- 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;
2811
2998
  }
2812
2999
  const convergencePath = path.join(process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'host-convergence.json');
2813
3000
  let hostConvergence = { healthy: true, state: 'not-recorded' };
@@ -2874,9 +3061,17 @@ async function doctor() {
2874
3061
  cleanupStrayRuvectorDb(); // issue #39: sweep a leftover empty scaffold from before this fix, if one's here
2875
3062
  const env = detectEnvironment();
2876
3063
  let rufloOperational = null;
2877
- if (env.ruflo) {
2878
- rufloOperational = probeRufloOperationalHealth();
2879
- 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) {
2880
3075
  ok(rufloOperational.directMode
2881
3076
  ? 'Ruflo direct mode ready — daemon/swarm is stopped by design; AgentDB remains CLI-backed'
2882
3077
  : 'Ruflo operational — runtime, memory, and learning signals agree');
@@ -2944,7 +3139,12 @@ async function doctor() {
2944
3139
  console.log(` really exists in your local KB. Checked in ${smoke.secs}s, no cloud, no API key.`);
2945
3140
  } else if (smoke.grounded === false) {
2946
3141
  console.log(` ${c.yellow('! Grounding NOT proven')} (${smoke.reason}). The install is present but the brain did not`);
2947
- 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.');
2948
3148
  } else if (smoke.grounded === null) {
2949
3149
  console.log(` ${c.yellow('! Grounding not verifiable')} on this bundle — it predates the citation verifier.`);
2950
3150
  console.log(' Re-run npx ruvnet-brain to refresh, then --doctor will prove it.');
@@ -2993,6 +3193,27 @@ async function doctor() {
2993
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'),
2994
3194
  );
2995
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
+
2996
3217
  // ── THE MECHANICAL VERDICT ────────────────────────────────────────────────────────────────────
2997
3218
  // `--hooks` is retained as a compatibility alias for a read-only zero-registration proof. It must
2998
3219
  // never execute dormant hook bodies.
@@ -3019,12 +3240,14 @@ async function doctor() {
3019
3240
  // "unproven" verdict DOES gate the exit code. Its successful live citation proof above must clear
3020
3241
  // an older failure before this read; otherwise one invocation can print both PROVEN and UNPROVEN.
3021
3242
  let groundingUnprovenPersisted = false;
3243
+ let persistedGrounding = null;
3022
3244
  try {
3023
3245
  const mod = await import(new URL('../scripts/selfcheck.mjs', import.meta.url).href);
3024
3246
  if (smoke.grounded === true) {
3025
- mod.writeInstallState({ grounding: 'proven', reason: null, clearedBy: 'doctor-live-proof' });
3247
+ mod.writeInstallState({ grounding: 'proven', reason: null, clearedBy: 'doctor-live-proof', coverageSha256: liveCoverageSha256(cacheDir) });
3026
3248
  }
3027
- groundingUnprovenPersisted = mod.groundingUnproven(mod.readInstallState());
3249
+ persistedGrounding = mod.readInstallState();
3250
+ groundingUnprovenPersisted = mod.groundingUnproven(persistedGrounding);
3028
3251
  if (groundingUnprovenPersisted) {
3029
3252
  console.log(` ${c.yellow('! Grounding UNPROVEN')} (recorded at ${c.bold(mod.installStatePath())}).`);
3030
3253
  console.log(` This is what makes ${c.bold('--doctor')} fail here even though nothing above crashed — re-run`);
@@ -3046,24 +3269,44 @@ async function doctor() {
3046
3269
  const codexTrustBypassed = process.env.RUVNET_CODEX_HOOK_TRUST_MODE === 'bypass';
3047
3270
  const codexWiringFailed = Boolean(cx.host && !cx.wired);
3048
3271
  const codexReadinessFailed = Boolean(codexMcp?.blocking);
3049
- const failed = (hookResult ? hookResult.exitCode !== 0 : !allGreen)
3050
- || !installedIdentity.healthy
3051
- || smoke.grounded !== true
3052
- || groundingUnprovenPersisted
3053
- || (codexLifecycleFailed && !codexTrustBypassed)
3054
- || codexWiringFailed
3055
- || nightlyHealth.state === 'degraded'
3056
- || (nightlyHealth.state === 'on' && !['ok', 'running'].includes(nightlyHealth.runHealth?.state))
3057
- || codexReadinessFailed
3058
- || !hostConvergence.healthy
3059
- || Boolean(rufloOperational && !rufloOperational.healthy);
3060
- // THE ONE VERDICT. Always printed, always consistent with the exit code, never alongside another.
3061
- if (failed) {
3062
- 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`);
3063
3301
  } else {
3064
- 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
+ }
3065
3308
  }
3066
- return failed ? 1 : 0;
3309
+ return verdict.exitCode;
3067
3310
  }
3068
3311
 
3069
3312
  // ── the post-install self-check, wired for both --doctor --hooks and the installer's last step ────
@@ -3245,7 +3488,7 @@ function feedbackHealthLines(cacheDir) {
3245
3488
  const env = detectEnvironment();
3246
3489
  const allGreen = s.repos > 0 && s.reader && s.mcp;
3247
3490
  return [
3248
- `${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)'}`,
3249
3492
  `toolkit: Ruflo ${env.ruflo ? 'present' : 'not found'} · RuVector ${env.ruvector ? 'present' : 'not found'} · claude CLI ${env.claude ? 'present' : 'not found'}`,
3250
3493
  // NOT called a "verdict": this reads only repos/reader/mcp, while --doctor's verdict also weighs
3251
3494
  // grounding, Codex wiring, nightly health, host convergence and the hook policy. Two lines both
@@ -3345,6 +3588,11 @@ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = n
3345
3588
  if (/^unresolved rollback state exists/.test(String(result?.reason || ''))) {
3346
3589
  return { verdict: 'refused-retained-copies', fallback: false, exitCode: status || 1 };
3347
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
+ }
3348
3596
  // Exit 2 is "manifest unreachable, nothing touched". The fallback exists for a DEAD manifest URL (an old
3349
3597
  // bundle polling a path that 404s); a rate limit, a 5xx or no network is transient, and a full fresh
3350
3598
  // reinstall over it re-downloads the brain and preserves another full copy each time. Retry later instead.
@@ -3634,8 +3882,17 @@ export function openSessionsNotice(hosts, version) {
3634
3882
  return `new ${names} sessions use ${version}; already-open windows keep the old hook definitions until they are reopened`;
3635
3883
  }
3636
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
+
3637
3893
  async function runUpdate() {
3638
3894
  printBanner('update');
3895
+ refuseUnmountedBrain();
3639
3896
  const kbDir = resolvedKbDir();
3640
3897
  const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(kbDir);
3641
3898
  if (FLAG_HOST_SYNC_ONLY) {
@@ -3751,6 +4008,12 @@ async function runUpdate() {
3751
4008
  } catch (error) {
3752
4009
  warn(`could not place the trusted coverage validator (${error.message}); the updater will report what it finds`);
3753
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 });
3754
4017
  info(c.dim("running the bundle's own self-updater (backs up first, re-verifies, never half-applies)…\n"));
3755
4018
  // Relative filename + matching cwd — same launch convention as smokeQuery(); stdio:'inherit'
3756
4019
  // streams the updater's narration live and unedited.
@@ -3851,6 +4114,33 @@ async function runUpdate() {
3851
4114
  return;
3852
4115
  }
3853
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
+ }
3854
4144
  let phaseEvidence = updaterResult?.phaseEvidence || null;
3855
4145
  if (!phaseEvidence) {
3856
4146
  const installed = validateCoverageDirectory(kbDir, { expectedVersion: PACKAGE_VERSION });
@@ -3938,12 +4228,24 @@ async function runUpdate() {
3938
4228
  execution: { kind: 'executed', runId: refreshReceipt.runId },
3939
4229
  required: cleanupFailed || updateStatus === 0,
3940
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 */ }
3941
4242
  settleRefresh(cleanupPending ? 12 : (retentionFailed ? 1 : updateStatus), {
3942
4243
  phase: cleanupFailed ? 'cleanup' : (updateStatus === 0 ? 'complete' : 'failed'),
3943
4244
  terminalVerdict: cleanupPending ? 'cleanup-pending' : retentionFailed ? 'recovery-required'
3944
4245
  : (outcome.verdict === 'noop' ? 'noop' : 'applied'),
3945
4246
  storageDelta: updaterResult?.storageDelta || null, lifecycleRetention: retention });
3946
4247
  process.removeListener('exit', exitGuard);
4248
+ await printConfirmation({ footprint: footprint?.after || null });
3947
4249
  }
3948
4250
 
3949
4251
  function enableNightly() {
@@ -4121,6 +4423,63 @@ const upgradeNoticeStatePath = () =>
4121
4423
  const brainOffSentinelPath = () =>
4122
4424
  path.join(process.env.RUVNET_BRAIN_STATE_DIR || path.join(os.homedir(), '.config', 'ruvnet-brain'), 'brain-off');
4123
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
+
4124
4483
  /**
4125
4484
  * Everything this installer can leave on a machine, DERIVED from disk — never asserted.
4126
4485
  *
@@ -4910,20 +5269,31 @@ function detectEnvironment() {
4910
5269
  platform: process.platform,
4911
5270
  arch: process.arch,
4912
5271
  claude: have('claude'),
4913
- ruflo: have('ruflo') || have('claude-flow'),
5272
+ ruflo: (() => { const r = locateRuflo(); return Boolean(r.cli || r.configured); })(),
4914
5273
  ruvector: have('ruvector') || hasUserScopeMcpServer('ruvector'),
4915
5274
  };
4916
- // Also honor a toolkit that's wired into the Claude config even if the CLI isn't on PATH (npx users).
4917
- try {
4918
- const settings = path.join(os.homedir(), '.claude', 'settings.json');
4919
- if (fs.existsSync(settings)) {
4920
- const s = fs.readFileSync(settings, 'utf8');
4921
- if (/claude-flow|\bruflo\b/i.test(s)) env.ruflo = true;
4922
- }
4923
- } catch { /* ignore — detection is best-effort */ }
4924
5275
  return env;
4925
5276
  }
4926
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
+
4927
5297
  export function classifyRufloOperationalHealth({ status = '', memory = '', metrics = '' } = {}) {
4928
5298
  const stopped = /\b(?:RuFlo V3 \[STOPPED\]|Swarm not running|MCP Server\s*\\n.*Not running)/i.test(status);
4929
5299
  const statusSaysNoMemory = /Backend\s*[|:]?\s*none|Entries\s*[|:]?\s*0\b/i.test(status);
@@ -4948,22 +5318,94 @@ export function classifyRufloOperationalHealth({ status = '', memory = '', metri
4948
5318
  };
4949
5319
  }
4950
5320
 
4951
- function probeRufloOperationalHealth() {
4952
- const run = (args) => {
4953
- // Every `ruflo` invocation auto-starts a project background daemon unless this is set
4954
- // (verified live: ~/.npm-global/lib/node_modules/ruflo/node_modules/@claude-flow/cli/dist/src/
4955
- // services/daemon-autostart.js:85) — a read-only health probe must not leave one running.
4956
- const result = spawnSync('ruflo', args, { cwd: process.cwd(), encoding: 'utf8', timeout: 10_000,
4957
- env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
4958
- return `${result.stdout || ''}\n${result.stderr || ''}`;
4959
- };
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
+ }
4960
5349
  return classifyRufloOperationalHealth({
4961
- status: run(['status']),
5350
+ status,
4962
5351
  memory: run(['status', 'memory']),
4963
5352
  metrics: run(['hooks', 'metrics', '--v3-dashboard']),
4964
5353
  });
4965
5354
  }
4966
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
+
4967
5409
  // ── issue #39: the ruvector MCP server's own native VectorDb defaults ITS storage to
4968
5410
  // "./ruvector.db" relative to whatever cwd it happens to be launched in — and Claude Code
4969
5411
  // always launches an MCP server with cwd = the current project, so every consumer project got
@@ -5505,7 +5947,11 @@ nothing; re-run later, or pick a release yourself with --version <tag>.
5505
5947
  Usage:
5506
5948
  npx ruvnet-brain Install the brain + Claude Code plugin (recommended, npm)
5507
5949
  npx github:stuinfla/ruvnet-brain Same, but from the bleeding-edge GitHub commit
5508
- 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.
5509
5955
  EXITS NON-ZERO when the install is genuinely broken, so it can gate a
5510
5956
  script: npx ruvnet-brain --doctor && ./deploy.sh
5511
5957
  npx ruvnet-brain --doctor --hooks
@@ -5536,7 +5982,9 @@ Usage:
5536
5982
  opt-in prompt appears once at install; answer lives in a plain file:
5537
5983
  ~/.cache/ruvnet-brain/.telemetry-consent)
5538
5984
  node bin/install.mjs --version <tag> Install a specific Release tag (e.g. --version v0.5.0-dev)
5539
- 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
5540
5988
  node bin/install.mjs --local Install from a repo clone's assembled dist/ruvnet-brain/
5541
5989
  node bin/install.mjs --force Re-fetch and reinstall even if already present
5542
5990
  node bin/install.mjs --no-verify Skip the post-install verify + warm-up smoke test
@@ -5575,10 +6023,42 @@ the installer reports that boot-level declarations changed.
5575
6023
  && canonical(process.argv[1]) === canonical(fileURLToPath(import.meta.url));
5576
6024
  if (!invokedDirectly) return;
5577
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
+ }
5578
6047
  // `process.exitCode`, not `return` — doctor()'s verdict is the whole point of running it in a
5579
6048
  // script. A bare `return await doctor()` discarded the number, which is how "! Needs attention"
5580
6049
  // and `echo $?` → 0 coexisted for so long.
5581
- 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
+ }
5582
6062
  if (FLAG_DEMO) return runDemo();
5583
6063
  if (FLAG_FEEDBACK) return runFeedback();
5584
6064
  if (FLAG_UPDATE) return runUpdate();
@@ -5616,6 +6096,7 @@ the installer reports that boot-level declarations changed.
5616
6096
  }
5617
6097
 
5618
6098
  await printPlanAndConfirm();
6099
+ refuseUnmountedBrain();
5619
6100
 
5620
6101
  const { cacheDir, isCustom } = resolveCacheDir();
5621
6102
 
@@ -5715,13 +6196,17 @@ the installer reports that boot-level declarations changed.
5715
6196
  // bundle cannot also swap the key it is checked against).
5716
6197
  //
5717
6198
  // SIGNING_REQUIRED was `false` transitionally, for releases that predated signing. That is over:
5718
- // 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.
5719
6200
  // Leaving it false left a real downgrade path — strip or 404 the small .sig file and the missing-
5720
6201
  // signature branch printed a warning and extracted 800MB+ of executable .mjs anyway. No alarm
5721
6202
  // fired, because no signature was ever obtained. Now a missing signature fails closed like an
5722
6203
  // invalid one, and --no-verify remains the single explicit, user-chosen override.
5723
6204
  const SIGNING_REQUIRED = true;
5724
- 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) {
5725
6210
  const sigPath = `${zipPath}.sig`;
5726
6211
  const hasSig = fs.existsSync(sigPath);
5727
6212
  if (!hasSig) {
@@ -5737,6 +6222,7 @@ the installer reports that boot-level declarations changed.
5737
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')}.)`);
5738
6223
  }
5739
6224
  ok(reason);
6225
+ signedBundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(zipPath)).digest('hex');
5740
6226
  }
5741
6227
  }
5742
6228
  // The tag is only carried when it came from a genuine `latest` resolution — a pinned/offline
@@ -5745,6 +6231,12 @@ the installer reports that boot-level declarations changed.
5745
6231
  await unzipInto(zipPath, cacheDir, sourceDir, {
5746
6232
  releaseTag: release && release.source === 'latest' ? (release.tag_name || release.tag || null) : null,
5747
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
+ }
5748
6240
  const brainProfile = readBrainProfile();
5749
6241
  if (brainProfile !== 'complete') {
5750
6242
  const scoped = applyBrainProfile(cacheDir, brainProfile);
@@ -5762,7 +6254,7 @@ the installer reports that boot-level declarations changed.
5762
6254
  } catch (error) {
5763
6255
  die(
5764
6256
  `the Brain Console runtime could not be installed (${error.message})`,
5765
- `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.`,
5766
6258
  );
5767
6259
  }
5768
6260
  retireManagedHookRegistrations();
@@ -5825,6 +6317,11 @@ the installer reports that boot-level declarations changed.
5825
6317
  // out what we did — which is precisely the position the 2026-07-20 corporate-machine reporter was
5826
6318
  // left in. Derived from disk, so it can only ever describe what is actually there.
5827
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})`); }
5828
6325
 
5829
6326
  // ── SCOPE + UPGRADE, on the path a real user actually takes ─────────────────────────────────
5830
6327
  //
@@ -5904,6 +6401,8 @@ the installer reports that boot-level declarations changed.
5904
6401
  mod.writeInstallState({
5905
6402
  grounding,
5906
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),
5907
6406
  });
5908
6407
  if (grounding !== 'proven' && process.env.RUVNET_STRICT_INSTALL === '1') {
5909
6408
  die(