ruvnet-brain 3.9.85-dev → 3.9.129-dev

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.
package/bin/install.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // npx ruvnet-brain # published on npm — shortest, recommended
5
5
  // npx github:stuinfla/ruvnet-brain # always the latest commit, even ahead of the npm release
6
- // node bin/install.mjs --local # from a repo clone that already has dist/ruvnet-brain.zip
6
+ // node bin/install.mjs --local # from a repo clone with assembled dist/ruvnet-brain/
7
7
  //
8
8
  // Goal: a newcomer runs ONE command and ends up with (a) the brain on disk and (b) the Claude Code
9
9
  // plugin wired at user scope — narrating "what I'm doing and why" at every step (the product's ethos).
@@ -15,10 +15,15 @@ import https from 'node:https';
15
15
  import fs from 'node:fs';
16
16
  import os from 'node:os';
17
17
  import path from 'node:path';
18
- import { spawnSync } from 'node:child_process';
18
+ import { spawn, spawnSync } from 'node:child_process';
19
19
  import { fileURLToPath, pathToFileURL } from 'node:url';
20
20
  import readline from 'node:readline';
21
21
  import crypto from 'node:crypto';
22
+ import { applyBrainProfile, readBrainProfile } from '../kb/brain-profile.mjs';
23
+ import {
24
+ requiredEmbedderModels,
25
+ missingEmbedderModels,
26
+ } from '../kb/model-requirements.mjs';
22
27
 
23
28
  // SEC-0010 #6 — the Ed25519 PUBLIC key is EMBEDDED here (not a separate file) so the installer's
24
29
  // trust root travels with the installer code itself: an attacker who swaps the downloaded bundle
@@ -40,6 +45,10 @@ function verifyBundle(bundlePath, sigPath) {
40
45
 
41
46
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
42
47
  const REPO_ROOT = path.resolve(__dirname, '..');
48
+ const PACKAGE_VERSION = (() => {
49
+ try { return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')).version; }
50
+ catch { return null; }
51
+ })();
43
52
 
44
53
  const REPO = 'stuinfla/ruvnet-brain';
45
54
  const RELEASE_API = `https://api.github.com/repos/${REPO}/releases/latest`;
@@ -62,7 +71,14 @@ const FLAG_LOCAL = argv.includes('--local');
62
71
  const FLAG_FORCE = argv.includes('--force');
63
72
  const FLAG_HELP = argv.includes('--help') || argv.includes('-h');
64
73
  const FLAG_DOCTOR = argv.includes('--doctor');
74
+ // `--doctor --hooks`: the post-install hook battery (ADR-053 §2 / ADR-055 build item 2). Fires every
75
+ // registration in the INSTALLED hooks.json through the real shim under four stdin regimes with an
76
+ // external process-group watchdog. Separate flag because it spawns real hooks — the plain --doctor
77
+ // stays a pure read.
78
+ const FLAG_HOOKS = argv.includes('--hooks');
65
79
  const FLAG_NO_VERIFY = argv.includes('--no-verify');
80
+ // Escape hatch for the installer's closing self-check ONLY (it never disables --doctor's verdict).
81
+ const FLAG_NO_SELFCHECK = argv.includes('--no-selfcheck');
66
82
  const FLAG_PIN = argv.includes('--pin'); // skip the latest-check, use the bundled default
67
83
  const FLAG_DEMO = argv.includes('--demo'); // guided, real (non-fabricated) walkthrough of the brain in action
68
84
  const FLAG_FEEDBACK = argv.includes('--feedback'); // prefill a GitHub Discussion (version + health, nothing private) and open it
@@ -272,6 +288,14 @@ async function resolveRelease() {
272
288
  return { tag: FORCED_VERSION, url: fallbackUrl(FORCED_VERSION), source: 'forced' };
273
289
  }
274
290
 
291
+ // Deterministic integration seam: stale/current behavior must not depend on GitHub API quota.
292
+ // It is inert unless the installer's existing mutation-safe test mode is explicitly enabled.
293
+ if (process.env.RUVNET_BRAIN_TEST === '1' && process.env.RUVNET_BRAIN_TEST_LATEST_TAG) {
294
+ const tag = process.env.RUVNET_BRAIN_TEST_LATEST_TAG;
295
+ info(`test mode: latest Release is ${c.bold(tag)}`);
296
+ return { tag, url: fallbackUrl(tag), source: 'latest' };
297
+ }
298
+
275
299
  try {
276
300
  info(`checking ${RELEASE_API} …`);
277
301
  const rel = await fetchJson(RELEASE_API);
@@ -308,16 +332,24 @@ function resolveCacheDir() {
308
332
  // ── step: obtain the bundle (local or download) ──────────────────────────────────────────────────
309
333
  async function obtainBundle(release) {
310
334
  const localZip = path.join(REPO_ROOT, 'dist', 'ruvnet-brain.zip');
335
+ const localDir = path.join(REPO_ROOT, 'dist', 'ruvnet-brain');
311
336
  const haveLocal = fs.existsSync(localZip);
337
+ const haveLocalDir = fs.existsSync(path.join(localDir, 'forge-mcp-all.mjs'));
312
338
 
313
- if (FLAG_LOCAL && !haveLocal) {
339
+ if (FLAG_LOCAL && !haveLocalDir) {
314
340
  die(
315
- `--local was passed but ${localZip} does not exist.`,
341
+ `--local was passed but ${localDir} is not an assembled brain bundle.`,
316
342
  `Build it first with: ${c.bold('node scripts/build-bundle.mjs')} (then re-run with --local),\nor drop --local to download the published brain instead.`,
317
343
  );
318
344
  }
319
345
 
320
- if (haveLocal || FLAG_LOCAL) {
346
+ if (FLAG_LOCAL) {
347
+ step('Using the local brain bundle', 'you are running from the repo, so no download is needed');
348
+ info(`source: ${localDir}`);
349
+ return { sourceDir: localDir, downloaded: false };
350
+ }
351
+
352
+ if (haveLocal) {
321
353
  step('Using the local brain bundle', 'you are running from the repo, so no download is needed');
322
354
  info(`source: ${localZip}`);
323
355
  return { zipPath: localZip, downloaded: false };
@@ -326,7 +358,7 @@ async function obtainBundle(release) {
326
358
  const downloadUrl = (release && release.url) || fallbackUrl(RELEASE_VERSION);
327
359
  step(
328
360
  `Downloading the brain (${APPROX_SIZE})`,
329
- 'the brain is the embedded source of 20+ RuvNet repos — too big for git, so it ships as a Release',
361
+ 'the brain embeds source from dozens of RuvNet repos — too big for git, so it ships as a Release',
330
362
  );
331
363
  info(`version: ${c.bold((release && release.tag) || RELEASE_VERSION)}`);
332
364
  info(`from: ${downloadUrl}`);
@@ -365,50 +397,99 @@ async function obtainBundle(release) {
365
397
  }
366
398
 
367
399
  // ── step: unzip into the cache dir (flattening the top-level ruvnet-brain/ folder) ───────────────
368
- function unzipInto(zipPath, cacheDir) {
400
+ //
401
+ // EXTRACTION IS NO LONGER GATED ON AN EXTERNAL BINARY (stranger-matrix, windows cells).
402
+ // The old code shelled to `unzip`, with PowerShell's Expand-Archive only as a fallback when `unzip`
403
+ // was ABSENT. That has two measured failure modes on a stranger's Windows machine:
404
+ // · windows-powershell: no `unzip` exists at all — the fallback carried the whole install.
405
+ // · windows-gitbash: `unzip` IS on PATH (MSYS2 build), so the fallback never engaged, and the
406
+ // call died — `unzip -q -o D:\a\_temp\...\ruvnet-brain.zip -d D:\a\...`
407
+ // exited 1. Windows spawns go through `shell: true` here (mandatory, for
408
+ // .cmd shims), so native backslash paths reach a POSIX-ish tool that treats
409
+ // `\` as an escape. A path handed to a shell must be correct for THAT shell.
410
+ // The fix is to stop involving a shell: kb/zip-extract.mjs reads the archive in-process with
411
+ // node:zlib. Measured on macOS against a real `zip -r -y` archive, its output is byte-for-byte
412
+ // identical to `unzip -q -o` (same tree, sizes, 0755/0644 modes, symlinks preserved).
413
+ //
414
+ // ORDER IS DELIBERATE, and the non-Windows default is UNCHANGED:
415
+ // non-win32 -> `unzip` first (byte-identical to every previous release), node:zlib as fallback
416
+ // so a slim container without unzip now installs instead of dying.
417
+ // win32 -> node:zlib first (no PATH lookup, no shell, no quoting), PowerShell second.
418
+ // Every attempt is recorded and, if all of them fail, ALL are printed with the exact command and
419
+ // exit code. The old message's best property — it named the precise failing command — is kept.
420
+ export function copyLocalBundleInto(sourceDir, cacheDir) {
421
+ let copied = 0;
422
+ for (const entry of fs.readdirSync(sourceDir)) {
423
+ fs.cpSync(path.join(sourceDir, entry), path.join(cacheDir, entry), {
424
+ recursive: true,
425
+ force: true,
426
+ preserveTimestamps: true,
427
+ });
428
+ copied++;
429
+ }
430
+ return copied;
431
+ }
432
+
433
+ async function unzipInto(zipPath, cacheDir, sourceDir = null) {
369
434
  step(
370
435
  'Unpacking the brain into place',
371
436
  'so the plugin finds forge-mcp-all.mjs and the vector stores right where it looks',
372
437
  );
373
438
 
374
- const hasUnzip = have('unzip');
375
- // Windows fallback: PowerShell's Expand-Archive is available on all modern Windows systems.
376
- const psExe = !hasUnzip ? (['pwsh', 'powershell'].find(have) || null) : null;
439
+ const localCopy = async () => `local directory copy — ${copyLocalBundleInto(sourceDir, cacheDir)} top-level entries`;
440
+ const nodeExtract = async () => {
441
+ const { extractZip } = await import(new URL('../kb/zip-extract.mjs', import.meta.url).href);
442
+ const r = await extractZip(zipPath, cacheDir);
443
+ return `node:zlib — ${r.files} files, ${(r.bytes / 1e6).toFixed(1)}MB${r.crcChecked ? ', CRC verified' : ''}`;
444
+ };
445
+ const unzipExtract = async () => {
446
+ if (!have('unzip')) throw new Error('`unzip` is not on PATH');
447
+ run('unzip', ['-q', '-o', zipPath, '-d', cacheDir]);
448
+ return 'unzip';
449
+ };
450
+ const psExtract = async () => {
451
+ // PowerShell's Expand-Archive handles .zip natively with -Force for overwrite.
452
+ // shell:false here (pwsh/powershell are real .exe files, not .cmd shims) — routing this
453
+ // through cmd.exe would re-tokenize the already-quoted -Command string and break it.
454
+ // -ExecutionPolicy Bypass: Expand-Archive ships as a script module (.psm1); on a locked-down
455
+ // machine (Restricted/AllSigned policy — common in sandboxes) importing it fails with
456
+ // "running scripts is disabled on this system" even though the exe itself runs fine. Bypass
457
+ // only affects this one child process, not any persistent machine setting.
458
+ const psExe = ['pwsh', 'powershell'].find(have);
459
+ if (!psExe) throw new Error('neither `pwsh` nor `powershell` is on PATH');
460
+ run(psExe, [
461
+ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command',
462
+ `Expand-Archive -LiteralPath "${zipPath}" -DestinationPath "${cacheDir}" -Force`,
463
+ ], { shell: false });
464
+ return `${psExe} Expand-Archive`;
465
+ };
466
+
467
+ const strategies = sourceDir
468
+ ? [['local assembled directory', localCopy]]
469
+ : (IS_WIN
470
+ ? [['node:zlib (built-in)', nodeExtract], ['PowerShell Expand-Archive', psExtract]]
471
+ : [['unzip', unzipExtract], ['node:zlib (built-in)', nodeExtract]]);
377
472
 
378
- if (!hasUnzip && !psExe) {
473
+ // The zip extracts to a top-level `ruvnet-brain/` folder. Extract into the cache dir, then lift
474
+ // its CONTENTS up one level so that cacheDir/forge-mcp-all.mjs exists (idempotent: overwrites).
475
+ const failures = [];
476
+ let extractedBy = null;
477
+ for (const [label, attempt] of strategies) {
478
+ try { extractedBy = await attempt(); break; }
479
+ catch (e) { failures.push(` • ${label}: ${(e && e.message) || e}`); }
480
+ }
481
+ if (!extractedBy) {
379
482
  die(
380
- `no zip extraction tool is available on this machine.`,
483
+ `extraction failed — every available method was tried and each one is reported below.\n${failures.join('\n')}`,
381
484
  [
382
- `Install one and re-run:`,
383
- ` • macOS: \`unzip\` is already built in — check your PATH`,
384
- ` • Debian/Ubuntu: ${c.bold('sudo apt-get install -y unzip')}`,
385
- ` • Fedora/RHEL: ${c.bold('sudo dnf install -y unzip')}`,
386
- ` • Windows: open a PowerShell window and re-run (Expand-Archive is built in)`,
485
+ `The archive may be incomplete or corrupt — re-run to download a fresh copy.`,
486
+ `If it keeps failing, one of these gives the same job to a tool you control:`,
487
+ ` • macOS/Linux: ${c.bold(`unzip -o "${zipPath}" -d "${cacheDir}"`)}`,
488
+ ` • Windows: ${c.bold(`Expand-Archive -LiteralPath "${zipPath}" -DestinationPath "${cacheDir}" -Force`)}`,
387
489
  ].join('\n'),
388
490
  );
389
491
  }
390
-
391
- // The zip extracts to a top-level `ruvnet-brain/` folder. Extract into the cache dir, then lift
392
- // its CONTENTS up one level so that cacheDir/forge-mcp-all.mjs exists (idempotent: -o overwrites).
393
- try {
394
- if (hasUnzip) {
395
- run('unzip', ['-q', '-o', zipPath, '-d', cacheDir]);
396
- } else {
397
- // Windows: PowerShell's Expand-Archive handles .zip natively with -Force for overwrite.
398
- // shell:false here (pwsh/powershell are real .exe files, not .cmd shims) — routing this
399
- // through cmd.exe would re-tokenize the already-quoted -Command string and break it.
400
- // -ExecutionPolicy Bypass: Expand-Archive ships as a script module (.psm1); on a locked-down
401
- // machine (Restricted/AllSigned policy — common in sandboxes) importing it fails with
402
- // "running scripts is disabled on this system" even though the exe itself runs fine. Bypass
403
- // only affects this one child process, not any persistent machine setting.
404
- run(psExe, [
405
- '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command',
406
- `Expand-Archive -LiteralPath "${zipPath}" -DestinationPath "${cacheDir}" -Force`,
407
- ], { shell: false });
408
- }
409
- } catch (e) {
410
- die(`extraction failed (${e.message}).`, `The archive may be incomplete — re-run to download a fresh copy.`);
411
- }
492
+ if (failures.length) warn(`extracted via ${extractedBy} after ${failures.length} method(s) failed:\n${failures.join('\n')}`);
412
493
 
413
494
  const nested = path.join(cacheDir, 'ruvnet-brain');
414
495
  if (fs.existsSync(path.join(nested, 'forge-mcp-all.mjs'))) {
@@ -421,6 +502,16 @@ function unzipInto(zipPath, cacheDir) {
421
502
  fs.rmdirSync(nested);
422
503
  }
423
504
 
505
+ // An update is a replacement, not an overlay. Before this prune existed, installing a public
506
+ // bundle over a KB that once contained private stores left every omitted `.rvf` and passages file
507
+ // behind. discoverRepos() then served those stale stores as if they were part of the new bundle.
508
+ // Only repo-artifact families are touched; reader deps, local logs, preferences, and unrelated
509
+ // files remain byte-for-byte.
510
+ const pruned = pruneUnlistedStores(cacheDir);
511
+ if (pruned.length) {
512
+ warn(`pruned ${pruned.length} stale repo artifact file(s) omitted by this bundle: ${[...new Set(pruned.map((p) => p.repo))].join(', ')}`);
513
+ }
514
+
424
515
  if (!fs.existsSync(path.join(cacheDir, 'forge-mcp-all.mjs'))) {
425
516
  die(
426
517
  `the brain unpacked but forge-mcp-all.mjs is missing from ${cacheDir}.`,
@@ -430,6 +521,40 @@ function unzipInto(zipPath, cacheDir) {
430
521
  ok(`brain unpacked to ${cacheDir}`);
431
522
  }
432
523
 
524
+ export function pruneUnlistedStores(cacheDir) {
525
+ try {
526
+ const manifest = JSON.parse(fs.readFileSync(path.join(cacheDir, 'manifest.json'), 'utf8'));
527
+ const allowed = new Set((manifest.builtRepos || []).map((r) => String(r?.name || '')).filter(Boolean));
528
+ if (manifest.conceptsStore?.store) allowed.add(String(manifest.conceptsStore.store).replace(/\.big\.rvf$|\.rvf$/, ''));
529
+ // ruv-gists is a separately assembled provenance store, not a registry builtRepo.
530
+ if (fs.existsSync(path.join(cacheDir, 'ruv-gists.rvf')) || fs.existsSync(path.join(cacheDir, 'ruv-gists.big.rvf'))) {
531
+ allowed.add('ruv-gists');
532
+ }
533
+ if (!allowed.size) return [];
534
+
535
+ const presentRepos = new Set();
536
+ for (const entry of fs.readdirSync(cacheDir)) {
537
+ const m = /^(.*?)(?:\.big)?\.rvf$/.exec(entry);
538
+ if (m?.[1] && /^[A-Za-z0-9._-]+$/.test(m[1])) presentRepos.add(m[1]);
539
+ const primer = /^(.*)-primer\.md$/.exec(entry);
540
+ if (primer?.[1] && /^[A-Za-z0-9._-]+$/.test(primer[1])) presentRepos.add(primer[1]);
541
+ }
542
+ const stale = [...presentRepos].filter((repo) => !allowed.has(repo));
543
+ const removed = [];
544
+ for (const repo of stale) {
545
+ for (const entry of fs.readdirSync(cacheDir)) {
546
+ if (entry !== repo && entry !== `${repo}-primer.md` && !entry.startsWith(`${repo}.`)) continue;
547
+ fs.rmSync(path.join(cacheDir, entry), { recursive: true, force: true });
548
+ removed.push({ repo, entry });
549
+ }
550
+ }
551
+ return removed;
552
+ } catch {
553
+ // A missing/corrupt manifest is already a verification failure. Never guess what to delete.
554
+ return [];
555
+ }
556
+ }
557
+
433
558
  // ── step: install the reader deps ────────────────────────────────────────────────────────────────
434
559
  function installReader(cacheDir) {
435
560
  step(
@@ -557,14 +682,16 @@ function wirePlugin() {
557
682
  // it and touch nothing. Their hand-written config outranks our convenience.
558
683
  //
559
684
  // The registered path must also OUTLIVE the install. The npx checkout is ephemeral (the same reason
560
- // the spend watchdog and the router tools get copied under ~/.claude), and plugin/mcp/server.mjs is
561
- // self-contained — node builtins only — so it is copied to a persistent home and THAT absolute path
562
- // is what gets registered. Registering the npx dir would rot the moment the temp dir vanished.
685
+ // the spend watchdog and the router tools get copied under ~/.claude). The MCP shell and its one
686
+ // local structured-interface module use node builtins only; both are copied to a persistent home and
687
+ // THAT absolute server path is registered. Registering the npx dir would rot when the temp dir vanished.
563
688
  const CODEX_BLOCK_START = '# --- ruvnet-brain (managed block, installer-rewritten) ---';
564
689
  const CODEX_BLOCK_END = '# --- end ruvnet-brain ---';
565
690
  const codexHomeDir = () => path.join(os.homedir(), '.codex');
566
691
  const codexConfigPath = () => path.join(codexHomeDir(), 'config.toml');
567
692
  const codexServerDir = () => path.join(os.homedir(), '.claude', 'ruvnet-brain', 'mcp');
693
+ const codexHookWrapperPath = (codexDir = codexHomeDir()) =>
694
+ path.join(path.dirname(codexDir), '.cache', 'ruvnet-brain', 'codex-hook.mjs');
568
695
 
569
696
  // The exact bytes we own. Kept in one place so the writer and the doctor probe can never disagree.
570
697
  function codexManagedBlock(serverPath) {
@@ -645,6 +772,8 @@ export function wireCodexHost({
645
772
  configPath = path.join(codexDir, 'config.toml'),
646
773
  serverDir = codexServerDir(),
647
774
  source = path.join(__dirname, '..', 'plugin', 'mcp', 'server.mjs'),
775
+ hookWrapperSource = path.join(__dirname, '..', 'plugin', 'scripts', 'codex-hook-wrapper.mjs'),
776
+ hookWrapperPath = codexHookWrapperPath(codexDir),
648
777
  announce = true,
649
778
  } = {}) {
650
779
  let host = false;
@@ -659,12 +788,25 @@ export function wireCodexHost({
659
788
  if (announce) warn('MCP server missing from this bundle — Codex left untouched (non-fatal)');
660
789
  return { host: true, action: 'no-source' };
661
790
  }
791
+ const managedCliSource = path.join(path.dirname(source), 'managed-cli-interface.mjs');
792
+ if (!fs.existsSync(managedCliSource)) {
793
+ if (announce) warn('MCP structured-interface module missing from this bundle — Codex left untouched (non-fatal)');
794
+ return { host: true, action: 'no-source' };
795
+ }
662
796
  const serverPath = path.join(serverDir, 'server.mjs');
797
+ const managedCliPath = path.join(serverDir, 'managed-cli-interface.mjs');
663
798
  fs.mkdirSync(serverDir, { recursive: true });
664
799
  // Write-beside-then-rename, both here and for the config below (issue #43): an interrupted plain
665
800
  // copy leaves a TORN server.mjs at the exact path a prior install's config already points at, so
666
801
  // Codex spawns half a file. rename() over the target is atomic; a failure leaves the old bytes.
802
+ // Copy the dependency first. If the later server swap fails, the previously registered server
803
+ // remains byte-intact and continues to import a backward-compatible module at the same path.
804
+ atomicReplace(managedCliPath, (tmp) => fs.copyFileSync(managedCliSource, tmp));
667
805
  atomicReplace(serverPath, (tmp) => fs.copyFileSync(source, tmp));
806
+ if (fs.existsSync(hookWrapperSource)) {
807
+ fs.mkdirSync(path.dirname(hookWrapperPath), { recursive: true });
808
+ atomicReplace(hookWrapperPath, (tmp) => fs.copyFileSync(hookWrapperSource, tmp));
809
+ }
668
810
 
669
811
  let before = '';
670
812
  try { before = fs.readFileSync(configPath, 'utf8'); } catch { /* first run — no config yet */ }
@@ -674,7 +816,7 @@ export function wireCodexHost({
674
816
  ok('Codex already declares ruvnet-brain in your own config — left exactly as you wrote it');
675
817
  info(` to hand it to us instead, delete that ${c.bold('[mcp_servers.ruvnet-brain]')} block and re-run this installer`);
676
818
  }
677
- return { host: true, action, serverPath };
819
+ return { host: true, action, serverPath, managedCliPath, hookWrapperPath };
678
820
  }
679
821
  if (text !== before) {
680
822
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
@@ -685,7 +827,253 @@ export function wireCodexHost({
685
827
  info(` server: ${serverPath} ${c.dim('(persistent copy — the npx dir vanishes)')}`);
686
828
  info(` ${c.dim('only our marked block is written; every other section is byte-preserved')}`);
687
829
  }
688
- return { host: true, action, serverPath, changed: text !== before };
830
+ return { host: true, action, serverPath, managedCliPath, hookWrapperPath, changed: text !== before };
831
+ }
832
+
833
+ const CODEX_PLUGIN_ID = 'ruvnet-brain@ruvnet-brain';
834
+ const CODEX_MARKETPLACE = 'ruvnet-brain';
835
+ const CODEX_MARKETPLACE_SOURCE = 'stuinfla/ruvnet-brain';
836
+
837
+ // Codex 0.145 returns `{ marketplaces: [...] }`; early preview builds returned the array itself.
838
+ // Keep both shapes so an installer update never turns a harmless host-version difference into a
839
+ // repeated "marketplace already exists" failure.
840
+ export function codexMarketplaceRows(value) {
841
+ if (Array.isArray(value?.marketplaces)) return value.marketplaces;
842
+ return Array.isArray(value) ? value : [];
843
+ }
844
+
845
+ function runCodexJson(args, {
846
+ codexBin = process.env.CODEX_BIN || 'codex',
847
+ codexHome = codexHomeDir(),
848
+ cwd = process.cwd(),
849
+ } = {}) {
850
+ const r = spawnSync(codexBin, args, {
851
+ cwd,
852
+ env: { ...process.env, CODEX_HOME: codexHome },
853
+ encoding: 'utf8',
854
+ timeout: 30_000,
855
+ maxBuffer: 20 * 1024 * 1024,
856
+ });
857
+ if (r.error || r.status !== 0) {
858
+ const detail = String(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim();
859
+ return { ok: false, error: detail };
860
+ }
861
+ try { return { ok: true, value: JSON.parse(r.stdout || 'null') }; }
862
+ catch { return { ok: false, error: `Codex returned non-JSON output for ${args.join(' ')}` }; }
863
+ }
864
+
865
+ export function codexPluginStatus(options = {}) {
866
+ const { runJson = runCodexJson, ...commandOptions } = options;
867
+ const listed = runJson(['plugin', 'list', '--json'], commandOptions);
868
+ if (!listed.ok) return { available: false, installed: false, enabled: false, error: listed.error };
869
+ const rows = Array.isArray(listed.value?.installed) ? listed.value.installed : [];
870
+ const row = rows.find((candidate) => candidate?.pluginId === CODEX_PLUGIN_ID);
871
+ return {
872
+ available: true,
873
+ installed: Boolean(row?.installed),
874
+ enabled: Boolean(row?.enabled),
875
+ version: row?.version || null,
876
+ row: row || null,
877
+ };
878
+ }
879
+
880
+ export function wireCodexPlugin({
881
+ codexDir = codexHomeDir(),
882
+ codexHome = codexDir,
883
+ codexBin = process.env.CODEX_BIN || 'codex',
884
+ marketplaceSource = CODEX_MARKETPLACE_SOURCE,
885
+ expectedVersion = PACKAGE_VERSION,
886
+ cwd = process.cwd(),
887
+ announce = true,
888
+ runJson = runCodexJson,
889
+ } = {}) {
890
+ if (!fs.existsSync(codexDir)) return { host: false, action: 'no-host' };
891
+ const options = { codexBin, codexHome, cwd };
892
+ const before = codexPluginStatus({ ...options, runJson });
893
+ if (!before.available) {
894
+ if (announce) warn(`Codex plugin lifecycle not installed — ${before.error}`);
895
+ return { host: true, action: 'codex-unavailable', ...before };
896
+ }
897
+ if (before.installed && !before.enabled) {
898
+ if (announce) warn(`Codex Brain plugin is installed but disabled by user or policy — left disabled (${CODEX_PLUGIN_ID}).`);
899
+ return { host: true, action: 'disabled', ...before };
900
+ }
901
+ if (before.installed && before.enabled && (!expectedVersion || before.version === expectedVersion)) {
902
+ if (announce) ok(`Codex Brain plugin already installed and enabled (${before.version || 'version unknown'}) — no changes.`);
903
+ return { host: true, action: 'unchanged', ...before };
904
+ }
905
+
906
+ const markets = runJson(['plugin', 'marketplace', 'list', '--json'], options);
907
+ if (!markets.ok) {
908
+ if (announce) warn(`Codex marketplace check failed — ${markets.error}`);
909
+ return { host: true, action: 'marketplace-check-failed', error: markets.error };
910
+ }
911
+ const marketRows = codexMarketplaceRows(markets.value);
912
+ const known = marketRows.some((market) => market?.name === CODEX_MARKETPLACE);
913
+ const marketAction = known
914
+ ? runJson(['plugin', 'marketplace', 'upgrade', CODEX_MARKETPLACE, '--json'], options)
915
+ : runJson(['plugin', 'marketplace', 'add', marketplaceSource, '--json'], options);
916
+ if (!marketAction.ok) {
917
+ if (announce) warn(`Codex marketplace ${known ? 'upgrade' : 'add'} failed — ${marketAction.error}`);
918
+ return { host: true, action: 'marketplace-failed', error: marketAction.error };
919
+ }
920
+ const added = runJson(['plugin', 'add', CODEX_PLUGIN_ID, '--json'], options);
921
+ if (!added.ok) {
922
+ if (announce) warn(`Codex plugin install failed — ${added.error}. The prior MCP registration remains intact.`);
923
+ return { host: true, action: 'plugin-failed', error: added.error };
924
+ }
925
+ const after = codexPluginStatus({ ...options, runJson });
926
+ if (!after.installed || !after.enabled) {
927
+ if (announce) warn('Codex accepted the install command but the Brain plugin is not installed and enabled.');
928
+ return { host: true, action: 'verification-failed', ...after };
929
+ }
930
+ if (announce) {
931
+ ok(`Codex Brain plugin installed and enabled (${after.version || 'version unknown'}).`);
932
+ }
933
+ return { host: true, action: before.installed ? 'updated' : 'installed', ...after };
934
+ }
935
+
936
+ function codexHooksList({
937
+ codexBin = process.env.CODEX_BIN || 'codex',
938
+ codexHome = codexHomeDir(),
939
+ cwd = process.cwd(),
940
+ timeoutMs = 8_000,
941
+ } = {}) {
942
+ return new Promise((resolve) => {
943
+ let settled = false;
944
+ let buffer = '';
945
+ let stderr = '';
946
+ const finish = (value) => {
947
+ if (settled) return;
948
+ settled = true;
949
+ clearTimeout(timer);
950
+ try { child.kill(); } catch { /* already exited */ }
951
+ resolve(value);
952
+ };
953
+ const child = spawn(codexBin, ['app-server'], {
954
+ cwd,
955
+ env: { ...process.env, CODEX_HOME: codexHome },
956
+ stdio: ['pipe', 'pipe', 'pipe'],
957
+ });
958
+ const timer = setTimeout(() => finish({ ok: false, error: `Codex hooks probe timed out after ${timeoutMs}ms` }), timeoutMs);
959
+ child.on('error', (error) => finish({ ok: false, error: error.message }));
960
+ child.stderr.on('data', (chunk) => { stderr += String(chunk); });
961
+ child.stdout.on('data', (chunk) => {
962
+ buffer += String(chunk);
963
+ let newline;
964
+ while ((newline = buffer.indexOf('\n')) !== -1) {
965
+ const line = buffer.slice(0, newline);
966
+ buffer = buffer.slice(newline + 1);
967
+ let message;
968
+ try { message = JSON.parse(line); } catch { continue; }
969
+ if (message.id === 1) {
970
+ child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`);
971
+ child.stdin.write(`${JSON.stringify({ method: 'hooks/list', id: 2, params: { cwds: [cwd] } })}\n`);
972
+ } else if (message.id === 2) {
973
+ finish(message.error
974
+ ? { ok: false, error: message.error.message || JSON.stringify(message.error) }
975
+ : { ok: true, value: message.result });
976
+ }
977
+ }
978
+ });
979
+ child.on('exit', (code) => {
980
+ if (!settled) finish({ ok: false, error: stderr.trim() || `Codex app-server exited ${code} before hooks/list` });
981
+ });
982
+ child.stdin.write(`${JSON.stringify({
983
+ method: 'initialize',
984
+ id: 1,
985
+ params: { clientInfo: { name: 'ruvnet_brain_doctor', title: 'RuvNet Brain Doctor', version: PACKAGE_VERSION || '0' } },
986
+ })}\n`);
987
+ });
988
+ }
989
+
990
+ export function classifyCodexLifecycle(plugin, listed = null) {
991
+ if (!plugin.available) return { state: 'probe-failed', plugin, hooks: [], error: plugin.error };
992
+ if (!plugin.installed) return { state: 'not-installed', plugin, hooks: [] };
993
+ if (!plugin.enabled) return { state: 'disabled', plugin, hooks: [] };
994
+ if (!listed.ok) return { state: 'probe-failed', plugin, hooks: [], error: listed.error };
995
+ const groups = Array.isArray(listed.value?.data) ? listed.value.data : [];
996
+ const hooks = groups.flatMap((group) => Array.isArray(group?.hooks) ? group.hooks : [])
997
+ .filter((hook) => hook?.pluginId === CODEX_PLUGIN_ID);
998
+ const errors = groups.flatMap((group) => Array.isArray(group?.errors) ? group.errors : []);
999
+ if (errors.length || hooks.length === 0) {
1000
+ return { state: 'missing-runtime-hooks', plugin, hooks, errors };
1001
+ }
1002
+ if (hooks.some((hook) => hook.enabled === false)) return { state: 'disabled', plugin, hooks, errors };
1003
+ if (hooks.every((hook) => hook.trustStatus === 'trusted')) return { state: 'active', plugin, hooks, errors };
1004
+ return { state: 'pending-trust', plugin, hooks, errors };
1005
+ }
1006
+
1007
+ export async function codexLifecycleStatus(options = {}) {
1008
+ const plugin = codexPluginStatus(options);
1009
+ if (!plugin.available || !plugin.installed || !plugin.enabled) {
1010
+ return classifyCodexLifecycle(plugin);
1011
+ }
1012
+ return classifyCodexLifecycle(plugin, await codexHooksList(options));
1013
+ }
1014
+
1015
+ export function codexLifecycleGuidance(status) {
1016
+ const hookCount = Array.isArray(status?.hooks) ? status.hooks.length : 0;
1017
+ switch (status?.state) {
1018
+ case 'active':
1019
+ return {
1020
+ healthy: true,
1021
+ intentional: false,
1022
+ summary: `Codex lifecycle active (${hookCount} Brain hook${hookCount === 1 ? '' : 's'} enabled and trusted).`,
1023
+ detail: 'Proactive grounding, routing, learning capture, and session continuity are on.',
1024
+ action: null,
1025
+ };
1026
+ case 'pending-trust':
1027
+ return {
1028
+ healthy: false,
1029
+ intentional: false,
1030
+ summary: `Codex installed the Brain, but ${hookCount || 'its'} lifecycle hook${hookCount === 1 ? '' : 's'} await review.`,
1031
+ detail: 'Search works now; proactive interventions start after Codex records hook trust.',
1032
+ action: `Start a fresh Codex session, run /hooks, and trust only ${CODEX_PLUGIN_ID}.`,
1033
+ };
1034
+ case 'disabled':
1035
+ return {
1036
+ healthy: false,
1037
+ intentional: true,
1038
+ summary: 'Codex Brain lifecycle is disabled.',
1039
+ detail: 'The installer preserved your explicit disabled state instead of silently overriding it.',
1040
+ action: null,
1041
+ };
1042
+ case 'not-installed':
1043
+ return {
1044
+ healthy: false,
1045
+ intentional: false,
1046
+ summary: 'Codex can reach the Brain MCP, but the proactive lifecycle plugin is not installed.',
1047
+ detail: 'Questions can still be grounded; automatic routing, learning, and session guidance are inactive.',
1048
+ action: 'Run npx ruvnet-brain to install and verify the Codex lifecycle plugin.',
1049
+ };
1050
+ case 'missing-runtime-hooks':
1051
+ return {
1052
+ healthy: false,
1053
+ intentional: false,
1054
+ summary: 'Codex has the Brain plugin, but its runtime hooks are missing or invalid.',
1055
+ detail: Array.isArray(status?.errors) && status.errors.length
1056
+ ? status.errors.map(String).join('; ')
1057
+ : 'The installed plugin snapshot did not produce runnable lifecycle definitions.',
1058
+ action: `Run codex plugin marketplace upgrade ${CODEX_MARKETPLACE}, then npx ruvnet-brain.`,
1059
+ };
1060
+ default:
1061
+ return {
1062
+ healthy: false,
1063
+ intentional: false,
1064
+ summary: 'Codex lifecycle status could not be verified.',
1065
+ detail: status?.error || 'Codex did not return a lifecycle status.',
1066
+ action: 'Run npx ruvnet-brain --doctor after confirming codex is on PATH.',
1067
+ };
1068
+ }
1069
+ }
1070
+
1071
+ function printCodexLifecycle(status) {
1072
+ const guidance = codexLifecycleGuidance(status);
1073
+ console.log(` ${guidance.healthy ? c.green('✓') : guidance.intentional ? c.dim('○') : c.yellow('!')} ${guidance.summary}`);
1074
+ if (guidance.detail) console.log(` ${guidance.detail}`);
1075
+ if (guidance.action) console.log(` ${c.bold(`Fix: ${guidance.action}`)}`);
1076
+ return guidance;
689
1077
  }
690
1078
 
691
1079
  // ── step: verify the install is REAL (counts — never take "installed" on faith) ──────────────────
@@ -768,6 +1156,12 @@ async function loadCitationVerifier(cacheDir) {
768
1156
  // real, indexed passage on this disk. The old check here tested `/rvf|ruvector|hnsw/` against the
769
1157
  // answer text, which a hallucinated "just use RVF!" passes with zero sources. Keyword presence is
770
1158
  // not evidence. We now print the cited path as a receipt, so you can go look at it yourself.
1159
+ export function resolveRuntimeModelCache(env = process.env, home = os.homedir()) {
1160
+ if (env.KB_MODEL_CACHE) return env.KB_MODEL_CACHE;
1161
+ const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
1162
+ return path.join(brainHome, 'models');
1163
+ }
1164
+
771
1165
  async function smokeQuery(cacheDir) {
772
1166
  const ask = path.join(cacheDir, 'forge-ask-all.mjs');
773
1167
  if (!fs.existsSync(ask)) return { ran: false };
@@ -790,7 +1184,10 @@ async function smokeQuery(cacheDir) {
790
1184
  cwd: cacheDir,
791
1185
  encoding: 'utf8',
792
1186
  timeout: 240000,
793
- env: process.env,
1187
+ // The stable MCP shell sets this exact default before spawning its worker. Passing the same
1188
+ // path here makes the install smoke warm the model cache the product will actually reopen,
1189
+ // instead of a second kb-local cache that can go green while the real door stays cold.
1190
+ env: { ...process.env, KB_MODEL_CACHE: resolveRuntimeModelCache() },
794
1191
  });
795
1192
  } catch {
796
1193
  warn("skipped the live test (couldn't launch the reader) — it'll warm on your first real question");
@@ -934,7 +1331,13 @@ async function runDemo() {
934
1331
  console.log(` ${line}`);
935
1332
  }
936
1333
  }
937
- } catch { /* informational only — never break an install */ }
1334
+ } catch (e) {
1335
+ // REPORT, never swallow. A bare `catch {}` here is how this block stayed dead: install-scope.mjs
1336
+ // was missing from package.json `files[]`, so on every real npm install the import threw
1337
+ // MODULE_NOT_FOUND and the empty catch made that indistinguishable from "nothing to say". Both
1338
+ // halves are fixed — it ships now, and if it ever stops shipping, this line says so out loud.
1339
+ warn(`scope explainer could not run (${e && e.message}) — the setup summary was skipped`);
1340
+ }
938
1341
 
939
1342
  try {
940
1343
  const { shouldNotify, noticeFor } = await import(new URL('../scripts/upgrade-notice.mjs', import.meta.url).href);
@@ -988,6 +1391,16 @@ function meterSummaryLine() {
988
1391
  }
989
1392
 
990
1393
  // ── `--doctor`: a standalone health check the user can run any time ───────────────────────────────
1394
+ //
1395
+ // EXIT CODES ARE THE POINT (the 40/100 finding). Before this, doctor() printed "! Needs attention."
1396
+ // on an install with zero stores and no reader, then returned `undefined` — so:
1397
+ //
1398
+ // $ npx ruvnet-brain --doctor >/dev/null 2>&1; echo $?
1399
+ // 0
1400
+ //
1401
+ // Honest prose that no machine can read is not a check. It could not gate a CI job, a nightly probe,
1402
+ // or a `&&` in someone's shell. It now RETURNS a verdict and main() exits with it, so "needs
1403
+ // attention" and "success" can never again be the same thing to a script.
991
1404
  async function doctor() {
992
1405
  printBanner('doctor');
993
1406
  console.log(c.dim('Checking every part of the install and reporting green/red.\n'));
@@ -996,7 +1409,7 @@ async function doctor() {
996
1409
  const present = fs.existsSync(path.join(cacheDir, 'forge-mcp-all.mjs'));
997
1410
  if (!present) {
998
1411
  warn('brain not found here — run the installer first: npx ruvnet-brain');
999
- return;
1412
+ return 1; // "not installed" is a FAILING doctor, not a neutral one
1000
1413
  }
1001
1414
  have('node') ? ok('node present') : warn('node missing');
1002
1415
  have('npm') ? ok('npm present') : warn('npm missing');
@@ -1004,9 +1417,17 @@ async function doctor() {
1004
1417
  // Two independent version streams (KB bundle vs plugin wrapper) — see checkVersionDrift()'s
1005
1418
  // header comment for the full story. Silent unless they've genuinely diverged.
1006
1419
  reportVersionDrift(cacheDir);
1007
- have('unzip') || have('pwsh') || have('powershell')
1008
- ? ok('zip extraction available (unzip or PowerShell Expand-Archive)')
1009
- : warn('no zip tool found — unzip or PowerShell needed for re-install');
1420
+ // Extraction no longer needs an external binary at all — kb/zip-extract.mjs does it with node:zlib
1421
+ // (see unzipInto()). So this reports the file's PRESENCE, not a PATH lookup: if it is missing from
1422
+ // the install, extraction on Windows silently loses its primary method, which is exactly the class
1423
+ // of "shipped, never actually there" gap tests/unit/installer-sibling-imports-packaged.test.mjs
1424
+ // exists to catch. Naming the external tools too, because they are still the fallback/primary
1425
+ // depending on platform.
1426
+ fs.existsSync(fileURLToPath(new URL('../kb/zip-extract.mjs', import.meta.url)))
1427
+ ? ok(`zip extraction available (built-in node:zlib${have('unzip') ? ' + unzip' : ''}${have('pwsh') || have('powershell') ? ' + PowerShell Expand-Archive' : ''})`)
1428
+ : (have('unzip') || have('pwsh') || have('powershell')
1429
+ ? warn('built-in extractor kb/zip-extract.mjs is MISSING from this install — falling back to an external tool')
1430
+ : warn('no zip extraction available: kb/zip-extract.mjs missing AND no unzip/PowerShell on PATH'));
1010
1431
  have('git')
1011
1432
  ? ok('git present (not required by this installer, but handy)')
1012
1433
  : info('git not found — that\'s fine, this installer never needs it');
@@ -1022,17 +1443,30 @@ async function doctor() {
1022
1443
  // pull used to hang FOREVER. Diagnose the condition here, explicitly and in 3 seconds flat: if the
1023
1444
  // model host is unreachable AND no local model cache exists, the first query needs the network and
1024
1445
  // will fail loud (bounded by RUVNET_BRAIN_FETCH_TIMEOUT_MS) — tell the user BEFORE they hit it.
1025
- const modelCacheDir = process.env.KB_MODEL_CACHE || path.join(cacheDir, 'models-cache');
1026
- const haveLocalModel = fs.existsSync(path.join(modelCacheDir, 'Xenova', 'all-MiniLM-L6-v2'))
1027
- || fs.existsSync(path.join(modelCacheDir, 'Xenova/all-MiniLM-L6-v2'));
1446
+ // ONE CACHE, NOT TWO (2026-07-27). This defaulted to `<cacheDir>/models-cache` — and cacheDir is
1447
+ // `~/.cache/ruvnet-brain/KB` (note the /kb) — while the RUNTIME
1448
+ // reads `<BRAIN_HOME>/models` (plugin/mcp/server.mjs:99, mirrored by plugin/test/model-cache.mjs:36).
1449
+ // So the installer verified — and warmed — a directory the product never opens.
1450
+ //
1451
+ // MEASURED ON THIS MACHINE, which is how it was found: `<cacheDir>/models-cache` DID NOT EXIST at
1452
+ // all, while `<BRAIN_HOME>/models` held 23MB containing ONLY the ms-marco reranker — the bge-base
1453
+ // EMBEDDER, the model every query needs first, was absent (0 files). That is the 53s cold start:
1454
+ // every cold query re-fetches the embedder because install warmed the wrong path, and it is why
1455
+ // search_ruvnet timed out twice in one session. A smoke test that passes against a cache the
1456
+ // runtime never reads proves nothing about the runtime — the D8 deduction, in one line of path.
1457
+ const modelCacheDir = resolveRuntimeModelCache();
1458
+ const requiredModels = requiredEmbedderModels(cacheDir);
1459
+ const missingModels = missingEmbedderModels(modelCacheDir, requiredModels);
1460
+ const haveLocalModel = missingModels.length === 0;
1028
1461
  try {
1029
1462
  await fetch('https://huggingface.co', { method: 'HEAD', signal: AbortSignal.timeout(3000) });
1030
1463
  ok('model host reachable (huggingface.co) — cold-cache model download would work');
1031
1464
  } catch {
1032
1465
  if (haveLocalModel) {
1033
- ok(`model host UNREACHABLE, but the embedder is already cached locally (${modelCacheDir}) — queries work offline`);
1466
+ ok(`model host UNREACHABLE, but every required embedder is cached locally (${modelCacheDir}) — queries work offline`);
1034
1467
  } else {
1035
1468
  warn('network-restricted environment detected: huggingface.co unreachable (3s probe) AND no local model cache.');
1469
+ warn(` Missing query model(s): ${missingModels.join(', ')}`);
1036
1470
  warn(` The first query needs the embedder model once. Fix: on a networked machine run one query, then copy`);
1037
1471
  warn(` its model cache to this machine and set KB_MODEL_CACHE to that path. (Queries fail loud, not hang.)`);
1038
1472
  }
@@ -1062,10 +1496,13 @@ async function doctor() {
1062
1496
  // from disk (our entry present AND the server.mjs it names really there), never asserted from the
1063
1497
  // fact that an install once ran.
1064
1498
  const cx = codexStatus();
1499
+ let codexLifecycle = null;
1065
1500
  if (!cx.host) {
1066
1501
  console.log(` ${c.dim('Codex: no host detected (no ~/.codex) — nothing to wire.')}`);
1067
1502
  } else if (cx.wired) {
1068
1503
  console.log(` ${c.green('✓ Codex: wired.')} search_ruvnet is registered in ~/.codex/config.toml and its server exists.`);
1504
+ codexLifecycle = await codexLifecycleStatus();
1505
+ printCodexLifecycle(codexLifecycle);
1069
1506
  } else {
1070
1507
  console.log(` ${c.yellow('! Codex: host detected but NOT wired')}${
1071
1508
  cx.serverPath && !cx.serverExists ? ` — registered server is missing (${cx.serverPath})` : ' — no [mcp_servers.ruvnet-brain] entry'
@@ -1094,6 +1531,79 @@ async function doctor() {
1094
1531
  console.log(
1095
1532
  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'),
1096
1533
  );
1534
+
1535
+ // ── THE MECHANICAL VERDICT ────────────────────────────────────────────────────────────────────
1536
+ // `--hooks` additionally fires every registration in the INSTALLED hooks.json through the real
1537
+ // shim under the four stdin regimes. Opt-in because it spawns real hooks; plain --doctor stays a
1538
+ // pure read that anyone can run without side effects.
1539
+ let hookResult = null;
1540
+ if (FLAG_HOOKS) {
1541
+ console.log(` ${c.dim('── hook battery (installed hooks.json, four stdin regimes, external watchdog) ──')}`);
1542
+ hookResult = await runSelfCheck({ installState: { repos: v.repos, reader: v.reader, mcp: v.mcp } });
1543
+ }
1544
+
1545
+ // ── THE PERSISTED GROUNDING VERDICT (ADR-058 §D8) — read-only, never re-derived here ────────────
1546
+ // bin/install.mjs's own install run is the ONLY writer (right after its real smoke query), so a
1547
+ // failed smoke stays non-fatal there. `--doctor` is different: it is the command someone runs
1548
+ // SPECIFICALLY TO ASK whether the install is healthy, so this is the one place an unresolved
1549
+ // "unproven" verdict DOES gate the exit code — without doctor() re-running a second live query
1550
+ // (the live smoke result printed above already updates the SAME file the next real install or
1551
+ // search_ruvnet touches; this just reads back whatever the most recent real attempt recorded).
1552
+ let groundingUnprovenPersisted = false;
1553
+ try {
1554
+ const mod = await import(new URL('../scripts/selfcheck.mjs', import.meta.url).href);
1555
+ groundingUnprovenPersisted = mod.groundingUnproven(mod.readInstallState());
1556
+ if (groundingUnprovenPersisted) {
1557
+ console.log(` ${c.yellow('! Grounding UNPROVEN')} (recorded at ${c.bold(mod.installStatePath())}).`);
1558
+ console.log(` This is what makes ${c.bold('--doctor')} fail here even though nothing above crashed — re-run`);
1559
+ console.log(` ${c.bold('npx ruvnet-brain')} once you're online, or ask a real question, to clear it.`);
1560
+ }
1561
+ } catch { /* selfcheck.mjs unavailable — degrade to the live smoke signal already printed above */ }
1562
+
1563
+ // Without --hooks the verdict is the install-state check the old code already computed and threw
1564
+ // away, PLUS the persisted grounding verdict above. `allGreen` was RIGHT here all along; nothing
1565
+ // ever read it.
1566
+ const codexLifecycleFailed = Boolean(
1567
+ codexLifecycle
1568
+ && !codexLifecycleGuidance(codexLifecycle).healthy
1569
+ && !codexLifecycleGuidance(codexLifecycle).intentional,
1570
+ );
1571
+ const failed = (hookResult ? hookResult.exitCode !== 0 : !allGreen)
1572
+ || groundingUnprovenPersisted
1573
+ || codexLifecycleFailed;
1574
+ if (failed && !hookResult && !groundingUnprovenPersisted) {
1575
+ console.log(` ${c.red('✗ FAILING')} — the warnings above are real. Re-run ${c.bold('npx ruvnet-brain')} to repair.`);
1576
+ }
1577
+ return failed ? 1 : 0;
1578
+ }
1579
+
1580
+ // ── the post-install self-check, wired for both --doctor --hooks and the installer's last step ────
1581
+ //
1582
+ // Loaded dynamically and failing SOFT on a load error: bin/install.mjs is the one file that runs
1583
+ // before anything is installed, so it must survive a partial/odd delivery rather than crash. A load
1584
+ // failure is REPORTED, never silently swallowed — a self-check that quietly does not run is exactly
1585
+ // the 40/100 finding again.
1586
+ //
1587
+ // THE COMMENT THAT USED TO BE HERE SAID "scripts/selfcheck.mjs is shipped in package.json `files`".
1588
+ // It was not. Checked with `npm pack --dry-run` on 2026-07-27, one day after this block merged under
1589
+ // the headline "the installer can finally FAIL": the tarball carried 21 entries and selfcheck.mjs was
1590
+ // not among them, so on every real npm install this import threw and the installer could NOT fail.
1591
+ // The same comment correctly identified install-scope.mjs as having that exact defect and deferred
1592
+ // fixing it — while asserting from intent, not from `npm pack`, that its own dependency was fine.
1593
+ // All three of install.mjs's dynamic imports are in `files[]` now, and
1594
+ // tests/integration/pack-completeness.test.mjs derives the list from this file instead of trusting a
1595
+ // sentence about it.
1596
+ async function runSelfCheck({ installState = null, quiet = false } = {}) {
1597
+ let mod;
1598
+ try {
1599
+ mod = await import(new URL('../scripts/selfcheck.mjs', import.meta.url).href);
1600
+ } catch (e) {
1601
+ warn(`self-check could not run (${e && e.message}) — this install has NOT been verified end to end`);
1602
+ return { exitCode: 0, violations: [], lines: [], unavailable: true };
1603
+ }
1604
+ const result = await mod.selfCheck({ installState, security: true });
1605
+ if (!quiet || result.violations.length) console.log(mod.formatVerdict(result, { color: c }));
1606
+ return result;
1097
1607
  }
1098
1608
 
1099
1609
  // ── `--feedback`: the easiest possible way to tell us how it went ────────────────────────────────
@@ -1923,7 +2433,7 @@ export async function offerRouterProfile() {
1923
2433
  // invisible; without the VIEWER, the user has no scoreboard to hold it to. Shipping one without the
1924
2434
  // other is how a router ends up "working" with three test pings in its log and nobody the wiser.
1925
2435
  // (dispatch-receipt.mjs relative-imports route-cheap.mjs — they land in the same bin/ dir, so it resolves.)
1926
- for (const t of ['model-router-engine.mjs', 'model-router-setup.mjs', 'model-router-status.mjs', 'model-router-outcome.mjs', 'route-cheap.mjs', 'dispatch-receipt.mjs', 'metaharness-receipts.mjs', 'codex-routed.sh']) {
2436
+ for (const t of ['model-router-engine.mjs', 'model-router-setup.mjs', 'model-router-status.mjs', 'model-router-outcome.mjs', 'subscription-hosts.mjs', 'dual-host-deliberation.mjs', 'dual-host-suggest.mjs', 'route-cheap.mjs', 'dispatch-receipt.mjs', 'metaharness-receipts.mjs', 'codex-routed.sh']) {
1927
2437
  const s = path.join(pkgRoot, 'scripts', t);
1928
2438
  if (fs.existsSync(s)) { fs.copyFileSync(s, path.join(routerDir, 'bin', t)); copied++; }
1929
2439
  }
@@ -2572,7 +3082,10 @@ export async function offerClaudeMd() {
2572
3082
  // sitting under the <50ms budget and blowing past it on every single prompt. `.cjs` forces
2573
3083
  // CommonJS unambiguously regardless of any stray package.json a user's HOME might contain.
2574
3084
  const STATUSLINE_HELPER_NAME = 'ruvnet-brain-statusline.cjs';
2575
- const statuslineHelperPath = () => path.join(telemetryStateDir(), STATUSLINE_HELPER_NAME);
3085
+ // Exported (ADR-058 D5): the coexistence suite reconstructs the exact "ours" command string under
3086
+ // an overridden HOME, so the settings.json byte-preservation claim is measured against the real
3087
+ // path computation rather than a hand-copied guess.
3088
+ export const statuslineHelperPath = () => path.join(telemetryStateDir(), STATUSLINE_HELPER_NAME);
2576
3089
  const statuslinePrefPath = () => path.join(telemetryStateDir(), '.statusline-pref');
2577
3090
  const settingsJsonPath = () => path.join(os.homedir(), '.claude', 'settings.json');
2578
3091
 
@@ -2637,7 +3150,10 @@ function backupSettingsJson(settingsPath) {
2637
3150
  return backup;
2638
3151
  }
2639
3152
 
2640
- function writeSettingsStatusLine(detected, command) {
3153
+ // Exported (ADR-058 D5): same testability contract as wireCodexHost — the coexistence suite calls
3154
+ // this directly against a scratch settings.json rather than reaching for the CLI, so the invariant
3155
+ // under test is the real write path, never a reimplementation of it.
3156
+ export function writeSettingsStatusLine(detected, command) {
2641
3157
  const backup = detected.exists ? backupSettingsJson(detected.path) : null;
2642
3158
  const next = { ...(detected.json || {}), statusLine: { type: 'command', command } };
2643
3159
  fs.mkdirSync(path.dirname(detected.path), { recursive: true });
@@ -2651,7 +3167,9 @@ function writeSettingsStatusLine(detected, command) {
2651
3167
  // machineFootprint() comment this replaces) so a status line the user has since folded their own
2652
3168
  // script into, or edited by hand, is left completely alone. Same "refuse rather than guess"
2653
3169
  // discipline removeClaudeMdBlock() already applies to CLAUDE.md, and the same backup-first courtesy.
2654
- function removeSettingsStatusLine() {
3170
+ // Exported (ADR-058 D5): the uninstall-side mirror, made independently callable for the same
3171
+ // reason as writeSettingsStatusLine above.
3172
+ export function removeSettingsStatusLine() {
2655
3173
  const settingsPath = settingsJsonPath();
2656
3174
  const detected = detectStatusLine(settingsPath);
2657
3175
  if (!detected.exists || detected.parseError || !detected.hasStatusLine) return 'absent';
@@ -2765,13 +3283,24 @@ export async function offerStatusline() {
2765
3283
  }
2766
3284
 
2767
3285
  // ── final success block ──────────────────────────────────────────────────────────────────────────
3286
+ function installedRepoCount(cacheDir) {
3287
+ try {
3288
+ const manifest = JSON.parse(fs.readFileSync(path.join(cacheDir, 'manifest.json'), 'utf8'));
3289
+ return Array.isArray(manifest.builtRepos) && manifest.builtRepos.length
3290
+ ? String(manifest.builtRepos.length)
3291
+ : 'dozens of';
3292
+ } catch {
3293
+ return 'dozens of';
3294
+ }
3295
+ }
3296
+
2768
3297
  function success({ cacheDir, isCustom, plugin, env, nightly }) {
2769
3298
  const line = '─'.repeat(64);
2770
3299
  console.log(`\n${c.green(line)}`);
2771
3300
  console.log(`${c.green(c.bold(' RuvNet Brain is installed.'))}`);
2772
3301
  console.log(`${c.green(line)}`);
2773
3302
  console.log(`\n What you now have:`);
2774
- console.log(` • the brain (embedded source of 20+ RuvNet repos) at:`);
3303
+ console.log(` • the brain (embedded source of ${installedRepoCount(cacheDir)} RuvNet repos) at:`);
2775
3304
  console.log(` ${c.bold(cacheDir)}`);
2776
3305
  console.log(
2777
3306
  ` • the Claude Code plugin ${plugin.wired ? c.green('wired at user scope') : c.yellow('(finish the 2 commands above)')} — search_ruvnet + grounding hook`,
@@ -2853,7 +3382,16 @@ and falls back to a known-good version if GitHub can't be reached.
2853
3382
  Usage:
2854
3383
  npx ruvnet-brain Install the brain + Claude Code plugin (recommended, npm)
2855
3384
  npx github:stuinfla/ruvnet-brain Same, but from the bleeding-edge GitHub commit
2856
- npx ruvnet-brain --doctor Health-check an existing install (green/red per part)
3385
+ npx ruvnet-brain --doctor Health-check an existing install (green/red per part).
3386
+ EXITS NON-ZERO when the install is genuinely broken, so it can gate a
3387
+ script: npx ruvnet-brain --doctor && ./deploy.sh
3388
+ npx ruvnet-brain --doctor --hooks
3389
+ …and additionally fire every hook this plugin registered on THIS
3390
+ machine through the real shim under four stdin regimes (valid event
3391
+ JSON, empty EOF, 1MB garbage, and stdin held open past budget), with an
3392
+ external process-group watchdog. Asserts each hook's declared exit
3393
+ codes, a 4KB stdout cap, its declared timeout with margin, and zero
3394
+ surviving descendants. Your own hooks are listed, never executed.
2857
3395
  npx ruvnet-brain --demo Guided walkthrough — 2 real questions, real cited answers
2858
3396
  npx ruvnet-brain --feedback Tell us how it went — prefills a GitHub Discussion with your brain
2859
3397
  version, platform, and a 3-line health summary (you see exactly
@@ -2879,7 +3417,7 @@ Usage:
2879
3417
  ~/.cache/ruvnet-brain/.telemetry-consent)
2880
3418
  node bin/install.mjs --version <tag> Install a specific Release tag (e.g. --version v0.5.0-dev)
2881
3419
  node bin/install.mjs --pin Skip the latest-check; use the bundled known-good version
2882
- node bin/install.mjs --local Install from a repo clone's dist/ruvnet-brain.zip
3420
+ node bin/install.mjs --local Install from a repo clone's assembled dist/ruvnet-brain/
2883
3421
  node bin/install.mjs --force Re-fetch and reinstall even if already present
2884
3422
  node bin/install.mjs --no-verify Skip the post-install verify + warm-up smoke test
2885
3423
  node bin/install.mjs --with-stack Also add missing Ruflo / RuVector (no prompt)
@@ -2893,7 +3431,10 @@ Usage:
2893
3431
  node bin/install.mjs --yes, -y Accept every optional offer (good for scripted installs)
2894
3432
 
2895
3433
  Env:
2896
- RUVNET_BRAIN_KB Override where the brain is stored (default ~/.cache/ruvnet-brain/kb)
3434
+ RUVNET_BRAIN_KB Override where the brain is stored (default ~/.cache/ruvnet-brain/kb)
3435
+ RUVNET_STRICT_INSTALL Make an unproven grounding smoke FATAL (default: never — a first-run model
3436
+ download or an air-gapped machine is not a broken install). Only ever set
3437
+ this for a locked-down environment where you want to know immediately.
2897
3438
 
2898
3439
  It is safe to re-run at any time. After installing, restart Claude Code so the grounding hook loads.
2899
3440
  `);
@@ -2903,7 +3444,10 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
2903
3444
  (async () => {
2904
3445
  if (IMPORT_ONLY) return; // imported for its exports (tests) — never run the installer as a side effect
2905
3446
  if (FLAG_HELP) return showHelp();
2906
- if (FLAG_DOCTOR) return await doctor();
3447
+ // `process.exitCode`, not `return` — doctor()'s verdict is the whole point of running it in a
3448
+ // script. A bare `return await doctor()` discarded the number, which is how "! Needs attention"
3449
+ // and `echo $?` → 0 coexisted for so long.
3450
+ if (FLAG_DOCTOR) { process.exitCode = await doctor(); return; }
2907
3451
  if (FLAG_DEMO) return runDemo();
2908
3452
  if (FLAG_FEEDBACK) return runFeedback();
2909
3453
  if (FLAG_UPDATE) return runUpdate();
@@ -3015,12 +3559,12 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
3015
3559
  info(`${c.dim('(the old installer skipped this whenever any brain was present, which is why stale installs never moved)')}`);
3016
3560
  }
3017
3561
  // Resolve which Release to fetch BEFORE downloading. Skipped entirely on the --local path
3018
- // (obtainBundle short-circuits to the repo's dist/ zip and never touches the network).
3562
+ // (obtainBundle short-circuits to the repo's assembled dist/ directory and never touches the network).
3019
3563
  const localZipPresent =
3020
3564
  FLAG_LOCAL || fs.existsSync(path.join(REPO_ROOT, 'dist', 'ruvnet-brain.zip'));
3021
3565
  // Reuse the staleness check's resolution when it already ran — one network round-trip, not two.
3022
3566
  const release = localZipPresent ? null : (resolvedRelease || await resolveRelease());
3023
- const { zipPath, tmpDir, downloaded, sigError } = await obtainBundle(release);
3567
+ const { zipPath, sourceDir, tmpDir, downloaded, sigError } = await obtainBundle(release);
3024
3568
  // Verify the Ed25519 signature BEFORE extracting a downloaded bundle into the user's config
3025
3569
  // (SEC-0010 #6 — trust root = the pubkey EMBEDDED in this file, so an attacker who swaps the
3026
3570
  // bundle cannot also swap the key it is checked against).
@@ -3050,7 +3594,12 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
3050
3594
  ok(reason);
3051
3595
  }
3052
3596
  }
3053
- unzipInto(zipPath, cacheDir);
3597
+ await unzipInto(zipPath, cacheDir, sourceDir);
3598
+ const brainProfile = readBrainProfile();
3599
+ if (brainProfile !== 'complete') {
3600
+ const scoped = applyBrainProfile(cacheDir, brainProfile);
3601
+ ok(`${brainProfile} profile preserved (${scoped.stores.join(', ')} kept; ${scoped.removed.length} unselected artifact(s) removed)`);
3602
+ }
3054
3603
  if (downloaded && tmpDir) {
3055
3604
  try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* leave temp behind, not fatal */ }
3056
3605
  }
@@ -3060,10 +3609,29 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
3060
3609
  const plugin = wirePlugin();
3061
3610
  // Codex hosts got nothing before this (issue #42): shipped, never registered. Non-fatal like every
3062
3611
  // other wiring step — a second host we cannot reach must never break the one we can.
3063
- try { wireCodexHost(); } catch (e) { warn(`(Codex wiring skipped: ${e && e.message})`); }
3612
+ try {
3613
+ const codexHost = wireCodexHost();
3614
+ if (codexHost.host) {
3615
+ const codexPlugin = wireCodexPlugin();
3616
+ if (codexPlugin.available) printCodexLifecycle(await codexLifecycleStatus());
3617
+ }
3618
+ } catch (e) {
3619
+ warn(`(Codex wiring skipped: ${e && e.message})`);
3620
+ }
3621
+ // THE CONSUMPTION FIX (the 40/100 finding, half two). These two lines used to be exactly this:
3622
+ //
3623
+ // verifyInstall(cacheDir);
3624
+ // await smokeQuery(cacheDir);
3625
+ //
3626
+ // Both functions RETURN a verdict. Both were called as statements. So an install with zero vector
3627
+ // stores, missing reader deps, or no MCP server printed three yellow warnings and then exited 0 —
3628
+ // and every downstream consumer (a CI job, a Dockerfile, a `&&` chain, a nightly probe) was told
3629
+ // the install succeeded. The detection was never the problem; dropping the result was.
3630
+ let verified = null;
3631
+ let smoke = null;
3064
3632
  if (!FLAG_NO_VERIFY) {
3065
- verifyInstall(cacheDir);
3066
- await smokeQuery(cacheDir);
3633
+ verified = verifyInstall(cacheDir);
3634
+ smoke = await smokeQuery(cacheDir);
3067
3635
  }
3068
3636
 
3069
3637
  // ── onboarding: detect the toolkit + make offers (all optional, all non-fatal) ──
@@ -3112,7 +3680,10 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
3112
3680
  console.log(`\n${c.dim(' ── how this is set up on your machine ──')}`);
3113
3681
  for (const line of String(explainChoice({ current: current.scope })).split('\n').slice(0, 12)) console.log(` ${line}`);
3114
3682
  }
3115
- } catch { /* informational only */ }
3683
+ } catch (e) {
3684
+ // Same reasoning as the sibling block above — a silent import failure is what hid this for good.
3685
+ warn(`scope explainer could not run (${e && e.message}) — the setup summary was skipped`);
3686
+ }
3116
3687
 
3117
3688
  try {
3118
3689
  const { shouldNotify, noticeFor, recordNotified } = await import(new URL('../scripts/upgrade-notice.mjs', import.meta.url).href);
@@ -3128,6 +3699,63 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
3128
3699
  }
3129
3700
  }
3130
3701
  } catch { /* informational only */ }
3702
+
3703
+ // ── FINAL STEP: THE POST-INSTALL SELF-CHECK — the thing that runs on a stranger's machine and
3704
+ // CAN FAIL (ADR-053 §2, ADR-055 build item 2). Deliberately last, so it observes the machine in
3705
+ // the exact state the user is left in, after every wiring step and every optional offer.
3706
+ //
3707
+ // ON A HEALTHY MACHINE: one calm confirming line. No nagging, no restating what already
3708
+ // printed above.
3709
+ // ON A BROKEN ONE: the violations, plainly, and a NON-ZERO exit code.
3710
+ //
3711
+ // "Never block a healthy install" is honoured literally: a healthy install still exits 0 and
3712
+ // nothing above this point is undone. What changes is that a genuinely broken install can no
3713
+ // longer masquerade as a successful one to a script. The user's OWN hooks and third-party
3714
+ // plugins are enumerated and reported but NEVER charged against them — only registrations this
3715
+ // package ships can make this fail.
3716
+ if (!FLAG_NO_SELFCHECK) {
3717
+ const selfcheck = await runSelfCheck({
3718
+ installState: verified ? { repos: verified.repos, reader: verified.reader, mcp: verified.mcp } : null,
3719
+ quiet: true,
3720
+ });
3721
+ if (selfcheck.exitCode !== 0) {
3722
+ console.log(`\n ${c.dim(`Re-run ${c.bold('npx ruvnet-brain --doctor --hooks')} after fixing, to re-check.`)}`);
3723
+ process.exitCode = selfcheck.exitCode;
3724
+ }
3725
+ }
3726
+ // The grounding smoke result is consumed here rather than discarded: it is a SEPARATE claim from
3727
+ // "installed and reachable" (an unproven grounding is not a broken install), so it informs the
3728
+ // user without failing the install.
3729
+ if (smoke && smoke.grounded === false) {
3730
+ warn('grounding was not proven on this run — the install is present but no verifiable citation came back.');
3731
+ }
3732
+
3733
+ // ── THE DEGRADED STATE, PERSISTED, NOT DODGED (ADR-058 §D8 "the grounding-smoke decision") ──────
3734
+ // A failed smoke stays NON-FATAL here — a first-run model download or an air-gapped machine is
3735
+ // not a broken install, and blocking on it here would fail every offline user on first contact.
3736
+ // What changes is that the verdict stops EVAPORATING: it is written to disk so `--doctor` and
3737
+ // session-start.sh can see it without re-running a live query, and the FIRST REAL search_ruvnet
3738
+ // later either clears it (a real cited answer came back) or reconfirms it. `RUVNET_STRICT_INSTALL`
3739
+ // is the one place this non-fatal default flips to fatal — used ONLY by the hostile-machine CI
3740
+ // cell to prove the strict path is real, never set by a real install.
3741
+ try {
3742
+ const mod = await import(new URL('../scripts/selfcheck.mjs', import.meta.url).href);
3743
+ const grounding = smoke && smoke.grounded === true ? 'proven' : 'unproven';
3744
+ mod.writeInstallState({
3745
+ grounding,
3746
+ reason: !smoke ? 'verify-skipped' : (smoke.grounded === true ? null : (smoke.reason || (smoke.ran ? 'not-grounded' : 'no-answer'))),
3747
+ });
3748
+ if (grounding !== 'proven' && process.env.RUVNET_STRICT_INSTALL === '1') {
3749
+ die(
3750
+ 'RUVNET_STRICT_INSTALL=1 and grounding was not proven on this run — refusing to report success.',
3751
+ 'This strict mode exists only to prove the DEGRADED state is real (ADR-058 §D8); a default\ninstall never fails here — unset RUVNET_STRICT_INSTALL to get the normal, non-fatal behavior.',
3752
+ );
3753
+ }
3754
+ } catch (e) {
3755
+ // Persisting the verdict must never break a finished install — but a silently-skipped write is
3756
+ // exactly the kind of thing that must be visible, not swallowed, so it is named here.
3757
+ warn(`(could not persist the grounding verdict: ${e && e.message})`);
3758
+ }
3131
3759
  })().catch((e) => {
3132
3760
  die(e && e.message ? e.message : String(e));
3133
3761
  });