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/README.md +17 -15
- package/bin/install.mjs +698 -70
- package/kb/brain-profile.mjs +145 -0
- package/kb/model-requirements.mjs +72 -0
- package/kb/zip-extract.mjs +297 -0
- package/package.json +32 -4
- package/plugin/mcp/managed-cli-interface.mjs +236 -0
- package/plugin/mcp/server.mjs +133 -32
- package/plugin/scripts/codex-hook-wrapper.mjs +37 -0
- package/scripts/dual-host-deliberation.mjs +284 -0
- package/scripts/dual-host-suggest.mjs +58 -0
- package/scripts/hook-registry.mjs +567 -0
- package/scripts/install-scope.mjs +708 -0
- package/scripts/model-router-outcome.mjs +19 -7
- package/scripts/selfcheck.mjs +646 -0
- package/scripts/subscription-hosts.mjs +105 -0
- package/scripts/upgrade-notice.mjs +465 -0
- package/scripts/user-settings.mjs +640 -0
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
|
|
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 && !
|
|
339
|
+
if (FLAG_LOCAL && !haveLocalDir) {
|
|
314
340
|
die(
|
|
315
|
-
`--local was passed but ${
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
483
|
+
`extraction failed — every available method was tried and each one is reported below.\n${failures.join('\n')}`,
|
|
381
484
|
[
|
|
382
|
-
`
|
|
383
|
-
`
|
|
384
|
-
` •
|
|
385
|
-
` •
|
|
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)
|
|
561
|
-
//
|
|
562
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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/
|
|
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 {
|
|
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 {
|
|
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
|
});
|