ruvnet-brain 4.3.9 → 4.3.11
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 +26 -9
- package/bin/install.mjs +646 -366
- package/bin/nightly-refresh.mjs +115 -0
- package/kb/brain-profile.mjs +118 -32
- package/kb/lifecycle-evidence-retention.mjs +237 -0
- package/kb/refresh-run.mjs +367 -0
- package/kb/retrieval-result.mjs +39 -0
- package/kb/update-storage-transaction.mjs +439 -0
- package/package.json +12 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +2 -165
- package/plugin/hooks/hook-contracts.json +4 -65
- package/plugin/hooks/hooks.json +2 -208
- package/plugin/scripts/adr-currency-gate.mjs +29 -19
- package/plugin/scripts/capability-registry.mjs +10 -83
- package/plugin/scripts/codex-hook-adapter.mjs +62 -9
- package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
- package/plugin/scripts/continuation-gate.mjs +30 -28
- package/plugin/scripts/continuation-objective.mjs +34 -0
- package/plugin/scripts/development-maintenance.mjs +49 -0
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/learn-flush.mjs +6 -1
- package/plugin/scripts/lesson-gate.mjs +5 -2
- package/plugin/scripts/lesson-presentation.mjs +5 -0
- package/plugin/scripts/md-stamp.mjs +109 -6
- package/plugin/scripts/memory-doctor.mjs +3 -7
- package/plugin/scripts/nightly-controller.mjs +9 -27
- package/plugin/scripts/nightly-scheduler.mjs +494 -0
- package/plugin/scripts/project-progression-contract.mjs +4 -4
- package/plugin/scripts/project-progression-store.mjs +35 -6
- package/plugin/scripts/ruflo-bin.mjs +20 -0
- package/plugin/scripts/session-snapshot-contract.mjs +6 -1
- package/plugin/scripts/version-bump-gate.sh +14 -2
- package/plugin/skills/release-proof/SKILL.md +6 -2
- package/plugin/skills/release-proof/references/receipt-contract.md +31 -2
- package/plugin/skills/release-proof/scripts/release-proof.mjs +11 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
- package/scripts/build-bundle.mjs +8 -0
- package/scripts/build-primer.mjs +2 -3
- package/scripts/candidate-host-evidence.mjs +59 -41
- package/scripts/ci/build-fixture-kb.mjs +59 -2
- package/scripts/ci/mutate-hook-timeout.mjs +24 -47
- package/scripts/ci/stranger-scenario.mjs +13 -28
- package/scripts/claims-verify.mjs +219 -21
- package/scripts/console-engine.mjs +4 -18
- package/scripts/console-runtime-identity.mjs +6 -0
- package/scripts/development-maintenance.mjs +47 -0
- package/scripts/development-push-check.mjs +35 -0
- package/scripts/distill-project.mjs +24 -47
- package/scripts/doc-currency.mjs +91 -19
- package/scripts/git-hooks/pre-push +4 -162
- package/scripts/health-repair.mjs +22 -4
- package/scripts/hook-retirement-check.mjs +22 -0
- package/scripts/host-install-matrix.mjs +125 -61
- package/scripts/integration-evidence.mjs +11 -6
- package/scripts/learning-replay-fixture.mjs +23 -2
- package/scripts/nightly-package-observation.mjs +67 -0
- package/scripts/nightly-two-run-proof.mjs +489 -0
- package/scripts/nightly-watchdog.mjs +17 -2
- package/scripts/npm-invocation.mjs +20 -0
- package/scripts/prepublication-evidence.mjs +42 -6
- package/scripts/primer-grounding.mjs +20 -0
- package/scripts/product-integrity-contract.mjs +22 -2
- package/scripts/public-verification-abandon.mjs +101 -0
- package/scripts/public-verification-aggregate.mjs +61 -19
- package/scripts/public-verification-finalizer.mjs +8 -2
- package/scripts/public-verification-lane.mjs +64 -8
- package/scripts/publication-receipt.mjs +156 -50
- package/scripts/published-surface-probe.mjs +10 -2
- package/scripts/qa-contract.mjs +57 -0
- package/scripts/qa-lanes.mjs +40 -0
- package/scripts/qa-runner.mjs +54 -64
- package/scripts/qe/ux-suite.mjs +10 -34
- package/scripts/qualified-candidate-check.mjs +124 -0
- package/scripts/release-abort-stale.mjs +2 -2
- package/scripts/release-projection.mjs +20 -1
- package/scripts/release-qualification-contract.mjs +87 -0
- package/scripts/release-qualification.mjs +135 -0
- package/scripts/release-transaction.mjs +81 -1
- package/scripts/remedy-registry.mjs +6 -13
- package/scripts/retrieval-canary.mjs +91 -18
- package/scripts/selfcheck.mjs +12 -4
- package/scripts/snapshot-freshness.mjs +58 -0
- package/scripts/stack-sync.mjs +41 -43
- package/scripts/staged-host-verifier.mjs +63 -14
- package/scripts/wired-check.mjs +31 -2
package/bin/install.mjs
CHANGED
|
@@ -21,12 +21,27 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
21
21
|
import readline from 'node:readline';
|
|
22
22
|
import crypto from 'node:crypto';
|
|
23
23
|
import { applyBrainProfile, readBrainProfile } from '../kb/brain-profile.mjs';
|
|
24
|
+
import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, recordRefreshAdvisory,
|
|
25
|
+
recordRefreshPhase, settleRefreshRun, UPDATE_REFRESH_PHASES } from '../kb/refresh-run.mjs';
|
|
26
|
+
import { pruneLifecycleEvidence } from '../kb/lifecycle-evidence-retention.mjs';
|
|
24
27
|
import {
|
|
25
28
|
requiredEmbedderModels,
|
|
26
29
|
missingEmbedderModels,
|
|
27
30
|
} from '../kb/model-requirements.mjs';
|
|
28
31
|
import { applyManagedCatalogUpdate } from '../scripts/model-router-catalog.mjs';
|
|
29
32
|
import { cmpVersion } from '../scripts/stack-sync.mjs';
|
|
33
|
+
import { validateCoverageDirectory } from '../plugin/scripts/coverage-integrity.mjs';
|
|
34
|
+
import {
|
|
35
|
+
NIGHTLY_LABEL,
|
|
36
|
+
installNightlyRunner,
|
|
37
|
+
installScheduler,
|
|
38
|
+
nightlyArtifact,
|
|
39
|
+
removeScheduler,
|
|
40
|
+
readNightlyRegistration,
|
|
41
|
+
resolveNightlyProofBundle,
|
|
42
|
+
schedulerStatus,
|
|
43
|
+
verifyNightlyExecutionIdentity,
|
|
44
|
+
} from '../plugin/scripts/nightly-scheduler.mjs';
|
|
30
45
|
|
|
31
46
|
// ISSUE #123 — AN INSTALL THAT IS AHEAD OF THE PUBLISHED RELEASE IS NOT A BROKEN INSTALL.
|
|
32
47
|
// Host convergence compared installed === PACKAGE_VERSION, so 4.0.29-dev against a published
|
|
@@ -94,10 +109,8 @@ const FLAG_LOCAL = argv.includes('--local');
|
|
|
94
109
|
const FLAG_FORCE = argv.includes('--force');
|
|
95
110
|
const FLAG_HELP = argv.includes('--help') || argv.includes('-h');
|
|
96
111
|
const FLAG_DOCTOR = argv.includes('--doctor');
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
// external process-group watchdog. Separate flag because it spawns real hooks — the plain --doctor
|
|
100
|
-
// stays a pure read.
|
|
112
|
+
// Backward-compatible spelling for the retirement check. It never executes a hook: the only healthy
|
|
113
|
+
// state is zero Brain-owned registrations in both shipped host manifests.
|
|
101
114
|
const FLAG_HOOKS = argv.includes('--hooks');
|
|
102
115
|
const FLAG_NO_VERIFY = argv.includes('--no-verify');
|
|
103
116
|
// Escape hatch for the installer's closing self-check ONLY (it never disables --doctor's verdict).
|
|
@@ -164,11 +177,13 @@ function printBanner(subtitle) {
|
|
|
164
177
|
console.log(`${c.cyan(line)}`);
|
|
165
178
|
}
|
|
166
179
|
|
|
167
|
-
function die(msg, hint) {
|
|
180
|
+
function die(msg, hint, recoveryRequired = false) {
|
|
168
181
|
console.error(`\n${c.red('✗ install stopped:')} ${msg}`);
|
|
169
182
|
if (hint) console.error(`\n${hint}`);
|
|
170
183
|
console.error(
|
|
171
|
-
|
|
184
|
+
recoveryRequired
|
|
185
|
+
? '\nRecovery requires inspection of the retained directories before retrying.'
|
|
186
|
+
: `\nNothing is left half-installed — fix the above and re-run the same command (it's safe to re-run).`,
|
|
172
187
|
);
|
|
173
188
|
process.exit(1);
|
|
174
189
|
}
|
|
@@ -356,6 +371,15 @@ function resolveCacheDir() {
|
|
|
356
371
|
|
|
357
372
|
// ── step: obtain the bundle (local or download) ──────────────────────────────────────────────────
|
|
358
373
|
async function obtainBundle(release) {
|
|
374
|
+
const proofBundle = resolveNightlyProofBundle({
|
|
375
|
+
brainHome: process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'),
|
|
376
|
+
env: process.env,
|
|
377
|
+
});
|
|
378
|
+
if (proofBundle) {
|
|
379
|
+
step('Using the registered proof bundle', 'the native scheduler proof binds these exact bytes');
|
|
380
|
+
info(`source: ${proofBundle.spec}`);
|
|
381
|
+
return { zipPath: proofBundle.spec, downloaded: false };
|
|
382
|
+
}
|
|
359
383
|
const localZip = path.join(REPO_ROOT, 'dist', 'ruvnet-brain.zip');
|
|
360
384
|
const localDir = path.join(REPO_ROOT, 'dist', 'ruvnet-brain');
|
|
361
385
|
const haveLocal = fs.existsSync(localZip);
|
|
@@ -455,21 +479,23 @@ export function copyLocalBundleInto(sourceDir, cacheDir) {
|
|
|
455
479
|
return copied;
|
|
456
480
|
}
|
|
457
481
|
|
|
458
|
-
async function unzipInto(zipPath, cacheDir, sourceDir = null) {
|
|
482
|
+
export async function unzipInto(zipPath, cacheDir, sourceDir = null) {
|
|
459
483
|
step(
|
|
460
484
|
'Unpacking the brain into place',
|
|
461
485
|
'so the plugin finds forge-mcp-all.mjs and the vector stores right where it looks',
|
|
462
486
|
);
|
|
463
487
|
|
|
464
|
-
|
|
488
|
+
fs.mkdirSync(path.dirname(cacheDir), { recursive: true });
|
|
489
|
+
const stageDir = fs.mkdtempSync(path.join(path.dirname(cacheDir), `.${path.basename(cacheDir)}.install-stage-`));
|
|
490
|
+
const localCopy = async () => `local directory copy — ${copyLocalBundleInto(sourceDir, stageDir)} top-level entries`;
|
|
465
491
|
const nodeExtract = async () => {
|
|
466
492
|
const { extractZip } = await import(new URL('../kb/zip-extract.mjs', import.meta.url).href);
|
|
467
|
-
const r = await extractZip(zipPath,
|
|
493
|
+
const r = await extractZip(zipPath, stageDir);
|
|
468
494
|
return `node:zlib — ${r.files} files, ${(r.bytes / 1e6).toFixed(1)}MB${r.crcChecked ? ', CRC verified' : ''}`;
|
|
469
495
|
};
|
|
470
496
|
const unzipExtract = async () => {
|
|
471
497
|
if (!have('unzip')) throw new Error('`unzip` is not on PATH');
|
|
472
|
-
run('unzip', ['-q', '-o', zipPath, '-d',
|
|
498
|
+
run('unzip', ['-q', '-o', zipPath, '-d', stageDir]);
|
|
473
499
|
return 'unzip';
|
|
474
500
|
};
|
|
475
501
|
const psExtract = async () => {
|
|
@@ -484,7 +510,7 @@ async function unzipInto(zipPath, cacheDir, sourceDir = null) {
|
|
|
484
510
|
if (!psExe) throw new Error('neither `pwsh` nor `powershell` is on PATH');
|
|
485
511
|
run(psExe, [
|
|
486
512
|
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command',
|
|
487
|
-
`Expand-Archive -LiteralPath "${zipPath}" -DestinationPath "${
|
|
513
|
+
`Expand-Archive -LiteralPath "${zipPath}" -DestinationPath "${stageDir}" -Force`,
|
|
488
514
|
], { shell: false });
|
|
489
515
|
return `${psExe} Expand-Archive`;
|
|
490
516
|
};
|
|
@@ -504,6 +530,7 @@ async function unzipInto(zipPath, cacheDir, sourceDir = null) {
|
|
|
504
530
|
catch (e) { failures.push(` • ${label}: ${(e && e.message) || e}`); }
|
|
505
531
|
}
|
|
506
532
|
if (!extractedBy) {
|
|
533
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
507
534
|
die(
|
|
508
535
|
`extraction failed — every available method was tried and each one is reported below.\n${failures.join('\n')}`,
|
|
509
536
|
[
|
|
@@ -516,34 +543,109 @@ async function unzipInto(zipPath, cacheDir, sourceDir = null) {
|
|
|
516
543
|
}
|
|
517
544
|
if (failures.length) warn(`extracted via ${extractedBy} after ${failures.length} method(s) failed:\n${failures.join('\n')}`);
|
|
518
545
|
|
|
519
|
-
const nested = path.join(
|
|
546
|
+
const nested = path.join(stageDir, 'ruvnet-brain');
|
|
520
547
|
if (fs.existsSync(path.join(nested, 'forge-mcp-all.mjs'))) {
|
|
521
548
|
for (const entry of fs.readdirSync(nested)) {
|
|
522
549
|
const from = path.join(nested, entry);
|
|
523
|
-
const to = path.join(
|
|
524
|
-
fs.rmSync(to, { recursive: true, force: true }); // idempotent overwrite
|
|
550
|
+
const to = path.join(stageDir, entry);
|
|
525
551
|
fs.renameSync(from, to); // same filesystem → cheap rename
|
|
526
552
|
}
|
|
527
553
|
fs.rmdirSync(nested);
|
|
528
554
|
}
|
|
529
555
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
// behind. discoverRepos() then served those stale stores as if they were part of the new bundle.
|
|
533
|
-
// Only repo-artifact families are touched; reader deps, local logs, preferences, and unrelated
|
|
534
|
-
// files remain byte-for-byte.
|
|
535
|
-
const pruned = pruneUnlistedStores(cacheDir);
|
|
536
|
-
if (pruned.length) {
|
|
537
|
-
warn(`pruned ${pruned.length} stale repo artifact file(s) omitted by this bundle: ${[...new Set(pruned.map((p) => p.repo))].join(', ')}`);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
if (!fs.existsSync(path.join(cacheDir, 'forge-mcp-all.mjs'))) {
|
|
556
|
+
if (!fs.existsSync(path.join(stageDir, 'forge-mcp-all.mjs'))) {
|
|
557
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
541
558
|
die(
|
|
542
|
-
`the brain
|
|
559
|
+
`the staged brain is missing forge-mcp-all.mjs.`,
|
|
543
560
|
`The archive layout may have changed. Re-run, or report this at https://github.com/stuinfla/ruvnet-brain/issues`,
|
|
544
561
|
);
|
|
545
562
|
}
|
|
563
|
+
const stagedCoverage = validateCoverageDirectory(stageDir, { expectedVersion: PACKAGE_VERSION });
|
|
564
|
+
if (!stagedCoverage.valid) {
|
|
565
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
566
|
+
die(`staged ReleaseCoverage failed integrity — ${stagedCoverage.failures.join('; ')}`,
|
|
567
|
+
'The live brain was not touched. Fetch a complete current release and retry.');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Activation is an exact directory generation swap. A malformed candidate never reaches this
|
|
571
|
+
// point, retired public files cannot survive as overlay debris, and a failed rename restores the
|
|
572
|
+
// prior generation. Existing private overlays are update-owned and must never be stripped by the
|
|
573
|
+
// fresh-install fallback.
|
|
574
|
+
if (fs.existsSync(path.join(cacheDir, 'SOURCE.json'))) {
|
|
575
|
+
try {
|
|
576
|
+
const current = JSON.parse(fs.readFileSync(path.join(cacheDir, 'SOURCE.json'), 'utf8'));
|
|
577
|
+
const stores = Array.isArray(current.stores) ? current.stores : Object.values(current.stores || {});
|
|
578
|
+
if (stores.some((store) => store?.updateManaged === false)) {
|
|
579
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
580
|
+
die('fresh-install activation refused because this brain contains a private overlay',
|
|
581
|
+
`Run ${c.bold('npx ruvnet-brain --update')} so the bundle updater preserves those private stores.`);
|
|
582
|
+
}
|
|
583
|
+
} catch (error) {
|
|
584
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
585
|
+
die(`existing SOURCE.json is unreadable — refusing an exact-tree replacement (${error.message})`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const parent = path.dirname(cacheDir);
|
|
589
|
+
const priorPrefix = `${path.basename(cacheDir)}.install-prior-`;
|
|
590
|
+
const unresolved = fs.readdirSync(parent).filter((name) => name.startsWith(priorPrefix));
|
|
591
|
+
if (unresolved.length) {
|
|
592
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
593
|
+
die(`unresolved installer rollback state exists: ${unresolved.join(', ')}`,
|
|
594
|
+
'Restore or remove that retained generation after inspection, then retry.');
|
|
595
|
+
}
|
|
596
|
+
const priorDir = `${cacheDir}.install-prior-${Date.now()}-${process.pid}`;
|
|
597
|
+
const preservedDir = `${cacheDir}.install-preserved-${path.basename(stageDir).split('.install-stage-').pop()}`;
|
|
598
|
+
const hadPrior = fs.existsSync(cacheDir);
|
|
599
|
+
const stageIdentity = fs.lstatSync(stageDir);
|
|
600
|
+
let priorMoved = false;
|
|
601
|
+
let activated = false;
|
|
602
|
+
try {
|
|
603
|
+
if (hadPrior) {
|
|
604
|
+
fs.renameSync(cacheDir, priorDir);
|
|
605
|
+
priorMoved = true;
|
|
606
|
+
}
|
|
607
|
+
fs.renameSync(stageDir, cacheDir);
|
|
608
|
+
activated = true;
|
|
609
|
+
const landedCoverage = validateCoverageDirectory(cacheDir, { expectedVersion: PACKAGE_VERSION });
|
|
610
|
+
if (!landedCoverage.valid) throw new Error(`landed ReleaseCoverage failed integrity: ${landedCoverage.failures.join('; ')}`);
|
|
611
|
+
if (hadPrior) {
|
|
612
|
+
// SOURCE only identifies declared stores; ReleaseCoverage does not establish ownership of
|
|
613
|
+
// every old code/custom file. Preserve the ENTIRE old tree, including symlinks without
|
|
614
|
+
// following them. This is unclassified user-data retention, NOT a managed cleanup backup.
|
|
615
|
+
if (fs.existsSync(preservedDir)) throw new Error(`preservation path already exists: ${preservedDir}`);
|
|
616
|
+
fs.renameSync(priorDir, preservedDir);
|
|
617
|
+
}
|
|
618
|
+
} catch (error) {
|
|
619
|
+
try {
|
|
620
|
+
// Never remove the live path merely because activation was attempted. In particular,
|
|
621
|
+
// a failed first rename leaves the ORIGINAL generation there. Retain a failed candidate
|
|
622
|
+
// for inspection, and refuse to move a replacement directory we did not activate.
|
|
623
|
+
if (activated) {
|
|
624
|
+
const landedIdentity = fs.lstatSync(cacheDir);
|
|
625
|
+
if (landedIdentity.dev !== stageIdentity.dev || landedIdentity.ino !== stageIdentity.ino ||
|
|
626
|
+
!landedIdentity.isDirectory() || fs.existsSync(stageDir)) {
|
|
627
|
+
throw new Error('activated directory identity changed; refusing rollback mutation');
|
|
628
|
+
}
|
|
629
|
+
fs.renameSync(cacheDir, stageDir);
|
|
630
|
+
}
|
|
631
|
+
if (priorMoved) {
|
|
632
|
+
if (fs.existsSync(cacheDir)) throw new Error('live path is occupied; refusing to replace it during rollback');
|
|
633
|
+
fs.renameSync(priorDir, cacheDir);
|
|
634
|
+
}
|
|
635
|
+
} catch (rollbackError) {
|
|
636
|
+
die(`activation failed (${error.message}); rollback also failed (${rollbackError.message})`,
|
|
637
|
+
`Recovery paths: prior=${priorDir}, candidate=${stageDir}, live=${cacheDir}.`, true);
|
|
638
|
+
}
|
|
639
|
+
die(`activation failed (${error.message}); ${priorMoved ? 'restored the prior brain generation' :
|
|
640
|
+
hadPrior ? 'the prior brain generation was not moved' : 'no prior brain generation existed'}`,
|
|
641
|
+
`Candidate retained for inspection at ${stageDir}.`);
|
|
642
|
+
}
|
|
643
|
+
if (hadPrior) warn(`PRESERVED_UNCLASSIFIED: prior generation retained at ${preservedDir}. ` +
|
|
644
|
+
'Not eligible for automatic cleanup; repeated installs can grow disk usage. Inspect manually before removal.');
|
|
546
645
|
ok(`brain unpacked to ${cacheDir}`);
|
|
646
|
+
return { status: 'ACTIVATED', priorGeneration: hadPrior
|
|
647
|
+
? { status: 'PRESERVED_UNCLASSIFIED', path: preservedDir, automaticCleanupEligible: false }
|
|
648
|
+
: null };
|
|
547
649
|
}
|
|
548
650
|
|
|
549
651
|
/**
|
|
@@ -1022,7 +1124,7 @@ export function consoleRestartState(identity, {
|
|
|
1022
1124
|
//
|
|
1023
1125
|
// The brain ships as TWO independent artifacts and this is the one people lose:
|
|
1024
1126
|
// • KB + search_ruvnet — installed by this script into ~/.cache/ruvnet-brain
|
|
1025
|
-
// • the Claude Code plugin — slash commands, the Console,
|
|
1127
|
+
// • the Claude Code plugin — slash commands, the Console, skills, and MCP declaration
|
|
1026
1128
|
// Checking the commands directory on disk is what distinguishes them; a `claude plugin install`
|
|
1027
1129
|
// exit code does not.
|
|
1028
1130
|
/** @returns {string|null} the commands dir if the plugin is really installed, else null */
|
|
@@ -1074,7 +1176,7 @@ export function claudePluginStatus({ home = os.homedir() } = {}) {
|
|
|
1074
1176
|
function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false } = {}) {
|
|
1075
1177
|
step(
|
|
1076
1178
|
'Wiring the Claude Code plugin',
|
|
1077
|
-
'this registers search_ruvnet
|
|
1179
|
+
'this registers search_ruvnet, native skills, and slash commands without automatic lifecycle hooks',
|
|
1078
1180
|
);
|
|
1079
1181
|
const marketplaceSource = process.env.RUVNET_CLAUDE_MARKETPLACE_SOURCE || 'stuinfla/ruvnet-brain';
|
|
1080
1182
|
const manualMarketplace = `claude plugin marketplace add ${marketplaceSource}`;
|
|
@@ -1110,7 +1212,8 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1110
1212
|
// code says the command ran, not that the plugin is usable; the commands either exist on disk or
|
|
1111
1213
|
// they do not. This is the difference between `/rvbc` working and "Unknown command: /rvbc".
|
|
1112
1214
|
const installed = claudePluginStatus();
|
|
1113
|
-
|
|
1215
|
+
const installedHookRetirement = claudeInstalledHookRetirementStatus({ plugin: installed });
|
|
1216
|
+
if (installed.installed && versionSatisfies(installed.version, expectedVersion) && installedHookRetirement.ok) {
|
|
1114
1217
|
ok(`plugin installed at user scope (global, alongside Ruflo / RuVector) — exact version ${installed.version}`);
|
|
1115
1218
|
info(` commands available after a restart: ${c.bold('/rvbc')}, ${c.bold('/ruvnet-brain:configure')}`);
|
|
1116
1219
|
return { host: true, wired: true, version: installed.version, manualMarketplace, manualInstall };
|
|
@@ -1119,18 +1222,21 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1119
1222
|
// The honest failure. The brain still WORKS — this is the difference between a broken install and
|
|
1120
1223
|
// a partial one, and the user is told exactly which they have instead of being congratulated.
|
|
1121
1224
|
const mismatch = installed.installed && !versionSatisfies(installed.version, expectedVersion);
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1225
|
+
const staleHooks = installed.installed && !installedHookRetirement.ok;
|
|
1226
|
+
warn(staleHooks
|
|
1227
|
+
? `Claude installed the plugin with ${installedHookRetirement.registrations.length} retired lifecycle registration(s); refusing to call this host converged.`
|
|
1228
|
+
: mismatch
|
|
1229
|
+
? `Claude installed ${installed.version || 'an unknown version'}, not required ${expectedVersion}; refusing to call this host converged.`
|
|
1230
|
+
: `the plugin did NOT land — so slash commands like ${c.bold('/rvbc')} will not exist yet.`);
|
|
1125
1231
|
info(`${c.green('Your brain still works')}: search_ruvnet is wired and Claude will ground answers with it.`);
|
|
1126
|
-
info(`Only the plugin extras (slash commands, the Console,
|
|
1232
|
+
info(`Only the plugin extras (slash commands, the Console, skills, and MCP declaration) are missing.`);
|
|
1127
1233
|
info(`Run these two yourself to finish:`);
|
|
1128
1234
|
info(` ${c.bold(manualMarketplace)}`);
|
|
1129
1235
|
info(` ${c.bold(manualInstall)}`);
|
|
1130
1236
|
return {
|
|
1131
1237
|
host: true,
|
|
1132
1238
|
wired: false,
|
|
1133
|
-
action: mismatch ? 'verification-failed' : 'install-failed',
|
|
1239
|
+
action: staleHooks ? 'unexpected-runtime-hooks' : mismatch ? 'verification-failed' : 'install-failed',
|
|
1134
1240
|
expectedVersion,
|
|
1135
1241
|
version: installed.version,
|
|
1136
1242
|
manualMarketplace,
|
|
@@ -1350,8 +1456,6 @@ export function wireCodexHost({
|
|
|
1350
1456
|
configPath = path.join(codexDir, 'config.toml'),
|
|
1351
1457
|
serverDir = codexServerDir(),
|
|
1352
1458
|
source = path.join(__dirname, '..', 'plugin', 'mcp', 'server.mjs'),
|
|
1353
|
-
hookWrapperSource = path.join(__dirname, '..', 'plugin', 'scripts', 'codex-hook-wrapper.mjs'),
|
|
1354
|
-
runtimePreferencesSource = path.join(__dirname, '..', 'plugin', 'scripts', 'runtime-preferences.mjs'),
|
|
1355
1459
|
hookWrapperPath = codexHookWrapperPath(codexDir),
|
|
1356
1460
|
announce = true,
|
|
1357
1461
|
} = {}) {
|
|
@@ -1408,9 +1512,26 @@ export function wireCodexHost({
|
|
|
1408
1512
|
atomicReplace(target, (tmp) => fs.copyFileSync(dep.from, tmp));
|
|
1409
1513
|
}
|
|
1410
1514
|
atomicReplace(serverPath, (tmp) => fs.copyFileSync(source, tmp));
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1515
|
+
retireManagedHookRegistrations({ home: path.dirname(codexDir), codexDir, wrapperPath: hookWrapperPath });
|
|
1516
|
+
// Versions through 4.3.10 copied a durable hook bridge outside the versioned plugin cache. An
|
|
1517
|
+
// already-running Codex session may still hold old registrations, so removing this exact
|
|
1518
|
+
// installer-owned file makes those frozen callbacks fail open immediately. The source adapter is
|
|
1519
|
+
// retained in the package for audit and possible explicit tooling; it is never installed here.
|
|
1520
|
+
let retiredHookWrapper = false;
|
|
1521
|
+
try {
|
|
1522
|
+
let wrapperStat = null;
|
|
1523
|
+
try { wrapperStat = fs.lstatSync(hookWrapperPath); }
|
|
1524
|
+
catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
1525
|
+
if (wrapperStat) {
|
|
1526
|
+
if (!wrapperStat.isFile() && !wrapperStat.isSymbolicLink()) {
|
|
1527
|
+
throw new Error('installer-owned wrapper path is not a regular file or symlink');
|
|
1528
|
+
}
|
|
1529
|
+
fs.unlinkSync(hookWrapperPath);
|
|
1530
|
+
retiredHookWrapper = true;
|
|
1531
|
+
}
|
|
1532
|
+
} catch (error) {
|
|
1533
|
+
if (announce) warn(`could not retire the legacy Codex hook wrapper at ${hookWrapperPath}: ${error.message}`);
|
|
1534
|
+
return { host: true, action: 'legacy-hook-retirement-failed', error: error.message };
|
|
1414
1535
|
}
|
|
1415
1536
|
|
|
1416
1537
|
let before = '';
|
|
@@ -1421,7 +1542,7 @@ export function wireCodexHost({
|
|
|
1421
1542
|
ok('Codex already declares ruvnet-brain in your own config — left exactly as you wrote it');
|
|
1422
1543
|
info(` to hand it to us instead, delete that ${c.bold('[mcp_servers.ruvnet-brain]')} block and re-run this installer`);
|
|
1423
1544
|
}
|
|
1424
|
-
return { host: true, action, serverPath, managedCliPath, runtimePreferencesPath,
|
|
1545
|
+
return { host: true, action, serverPath, managedCliPath, runtimePreferencesPath, retiredHookWrapper };
|
|
1425
1546
|
}
|
|
1426
1547
|
if (text !== before) {
|
|
1427
1548
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
@@ -1432,7 +1553,7 @@ export function wireCodexHost({
|
|
|
1432
1553
|
info(` server: ${serverPath} ${c.dim('(persistent copy — the npx dir vanishes)')}`);
|
|
1433
1554
|
info(` ${c.dim('only our marked block is written; every other section is byte-preserved')}`);
|
|
1434
1555
|
}
|
|
1435
|
-
return { host: true, action, serverPath, managedCliPath, runtimePreferencesPath,
|
|
1556
|
+
return { host: true, action, serverPath, managedCliPath, runtimePreferencesPath, retiredHookWrapper, changed: text !== before };
|
|
1436
1557
|
}
|
|
1437
1558
|
|
|
1438
1559
|
const CODEX_PLUGIN_ID = 'ruvnet-brain@ruvnet-brain';
|
|
@@ -1668,12 +1789,11 @@ export function classifyCodexLifecycle(plugin, listed = null) {
|
|
|
1668
1789
|
const hooks = groups.flatMap((group) => Array.isArray(group?.hooks) ? group.hooks : [])
|
|
1669
1790
|
.filter((hook) => hook?.pluginId === CODEX_PLUGIN_ID);
|
|
1670
1791
|
const errors = groups.flatMap((group) => Array.isArray(group?.errors) ? group.errors : []);
|
|
1671
|
-
if (errors.length
|
|
1792
|
+
if (errors.length) {
|
|
1672
1793
|
return { state: 'missing-runtime-hooks', plugin, hooks, errors };
|
|
1673
1794
|
}
|
|
1674
|
-
if (hooks.
|
|
1675
|
-
|
|
1676
|
-
return { state: 'pending-trust', plugin, hooks, errors };
|
|
1795
|
+
if (hooks.length === 0) return { state: 'inactive-by-design', plugin, hooks, errors };
|
|
1796
|
+
return { state: 'unexpected-runtime-hooks', plugin, hooks, errors };
|
|
1677
1797
|
}
|
|
1678
1798
|
|
|
1679
1799
|
export async function codexLifecycleStatus(options = {}) {
|
|
@@ -1687,37 +1807,37 @@ export async function codexLifecycleStatus(options = {}) {
|
|
|
1687
1807
|
export function codexLifecycleGuidance(status) {
|
|
1688
1808
|
const hookCount = Array.isArray(status?.hooks) ? status.hooks.length : 0;
|
|
1689
1809
|
switch (status?.state) {
|
|
1690
|
-
case '
|
|
1810
|
+
case 'unexpected-runtime-hooks':
|
|
1691
1811
|
return {
|
|
1692
|
-
healthy:
|
|
1812
|
+
healthy: false,
|
|
1693
1813
|
intentional: false,
|
|
1694
|
-
summary: `Codex
|
|
1695
|
-
detail: '
|
|
1696
|
-
action:
|
|
1814
|
+
summary: `Codex still exposes ${hookCount} retired Brain lifecycle hook${hookCount === 1 ? '' : 's'}.`,
|
|
1815
|
+
detail: 'Any Brain-owned runtime registration is stale and must not be trusted or executed.',
|
|
1816
|
+
action: `Upgrade ${CODEX_PLUGIN_ID}, then start a fresh Codex session and re-run --doctor.`,
|
|
1697
1817
|
};
|
|
1698
|
-
case '
|
|
1818
|
+
case 'inactive-by-design':
|
|
1699
1819
|
return {
|
|
1700
|
-
healthy:
|
|
1701
|
-
intentional:
|
|
1702
|
-
summary:
|
|
1703
|
-
detail: '
|
|
1704
|
-
action:
|
|
1820
|
+
healthy: true,
|
|
1821
|
+
intentional: true,
|
|
1822
|
+
summary: 'Codex Brain automatic lifecycle hooks are inactive by design.',
|
|
1823
|
+
detail: 'MCP search, skills, slash commands, and explicit proof workflows remain available.',
|
|
1824
|
+
action: null,
|
|
1705
1825
|
};
|
|
1706
1826
|
case 'disabled':
|
|
1707
1827
|
return {
|
|
1708
1828
|
healthy: false,
|
|
1709
1829
|
intentional: true,
|
|
1710
|
-
summary: 'Codex Brain
|
|
1711
|
-
detail: 'The installer preserved your explicit disabled state instead of silently overriding it.',
|
|
1830
|
+
summary: 'Codex Brain plugin is disabled.',
|
|
1831
|
+
detail: 'The installer preserved your explicit disabled state instead of silently overriding it; no Brain lifecycle hook can run.',
|
|
1712
1832
|
action: null,
|
|
1713
1833
|
};
|
|
1714
1834
|
case 'not-installed':
|
|
1715
1835
|
return {
|
|
1716
1836
|
healthy: false,
|
|
1717
1837
|
intentional: false,
|
|
1718
|
-
summary: 'Codex can reach the Brain MCP, but the
|
|
1719
|
-
detail: '
|
|
1720
|
-
action: 'Run npx ruvnet-brain to install and verify the Codex
|
|
1838
|
+
summary: 'Codex can reach the Brain MCP, but the Brain plugin is not installed.',
|
|
1839
|
+
detail: 'MCP search remains available; skills and explicit plugin workflows are not installed.',
|
|
1840
|
+
action: 'Run npx ruvnet-brain to install and verify the Codex plugin.',
|
|
1721
1841
|
};
|
|
1722
1842
|
case 'missing-runtime-hooks':
|
|
1723
1843
|
return {
|
|
@@ -1740,6 +1860,168 @@ export function codexLifecycleGuidance(status) {
|
|
|
1740
1860
|
}
|
|
1741
1861
|
}
|
|
1742
1862
|
|
|
1863
|
+
// Remove only explicit Brain ownership or the exact installer-owned durable bridge.
|
|
1864
|
+
// Foreign commands, mixed command strings, unknown shapes, and their surrounding settings survive.
|
|
1865
|
+
export function retireManagedHookRegistrations({ home = os.homedir(), codexDir = codexHomeDir(),
|
|
1866
|
+
files = [path.join(home, '.claude', 'settings.json'), path.join(home, '.claude', 'settings.local.json'),
|
|
1867
|
+
path.join(codexDir, 'hooks.json')], wrapperPath = codexHookWrapperPath(codexDir) } = {}) {
|
|
1868
|
+
let removed = 0;
|
|
1869
|
+
const changedFiles = [];
|
|
1870
|
+
const owns = (hook) => {
|
|
1871
|
+
if (hook?.pluginId === 'ruvnet-brain@ruvnet-brain') return true;
|
|
1872
|
+
const command = hook?.command;
|
|
1873
|
+
if (typeof command !== 'string' || /[;&|`\n\r]/.test(command)) return false;
|
|
1874
|
+
const tokens = command.match(/"[^"]*"|'[^']*'|[^\s]+/g) || [];
|
|
1875
|
+
const values = tokens.map((token) => token.replace(/^["']|["']$/g, ''));
|
|
1876
|
+
return values.length >= 2 && /(?:^|[/\\])node(?:\.exe)?$/.test(values[0])
|
|
1877
|
+
&& values[1] === wrapperPath;
|
|
1878
|
+
};
|
|
1879
|
+
for (const file of files) {
|
|
1880
|
+
let before;
|
|
1881
|
+
try { before = fs.readFileSync(file, 'utf8'); } catch (error) {
|
|
1882
|
+
if (error.code === 'ENOENT') continue;
|
|
1883
|
+
throw error;
|
|
1884
|
+
}
|
|
1885
|
+
const document = JSON.parse(before);
|
|
1886
|
+
if (!document.hooks || typeof document.hooks !== 'object' || Array.isArray(document.hooks)) continue;
|
|
1887
|
+
let count = 0;
|
|
1888
|
+
for (const [event, groups] of Object.entries(document.hooks)) {
|
|
1889
|
+
if (!Array.isArray(groups)) continue;
|
|
1890
|
+
document.hooks[event] = groups.flatMap((group) => {
|
|
1891
|
+
if (!Array.isArray(group?.hooks)) return [group];
|
|
1892
|
+
const hooks = group.hooks.filter((hook) => { if (!owns(hook)) return true; count++; return false; });
|
|
1893
|
+
return hooks.length || group.hooks.length === 0 ? [{ ...group, hooks }] : [];
|
|
1894
|
+
});
|
|
1895
|
+
if (groups.length && document.hooks[event].length === 0) delete document.hooks[event];
|
|
1896
|
+
}
|
|
1897
|
+
if (!count) continue;
|
|
1898
|
+
atomicReplace(file, (temp) => fs.writeFileSync(temp, JSON.stringify(document, null, 2) + '\n'));
|
|
1899
|
+
removed += count;
|
|
1900
|
+
changedFiles.push(file);
|
|
1901
|
+
}
|
|
1902
|
+
return { removed, changedFiles };
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'source' } = {}) {
|
|
1906
|
+
if (!['source', 'installed'].includes(scope)) throw new Error('invalid hook retirement scope');
|
|
1907
|
+
const files = [
|
|
1908
|
+
'plugin/hooks/hooks.json',
|
|
1909
|
+
'plugin/hooks/codex-hooks.json',
|
|
1910
|
+
...(scope === 'source' ? ['.claude/settings.json', '.codex/hooks.json'] : []),
|
|
1911
|
+
];
|
|
1912
|
+
const pointerContracts = [
|
|
1913
|
+
['plugin/.codex-plugin/plugin.json', 'hooks', './hooks/codex-hooks.json'],
|
|
1914
|
+
['plugin/host-adapters/codex.json', 'hooks', 'plugin/hooks/codex-hooks.json'],
|
|
1915
|
+
['plugin/host-adapters/claude.json', 'hooks', 'plugin/hooks/hooks.json'],
|
|
1916
|
+
];
|
|
1917
|
+
const registrations = [];
|
|
1918
|
+
const errors = [];
|
|
1919
|
+
const checkedFiles = [...files];
|
|
1920
|
+
for (const relative of files) {
|
|
1921
|
+
try {
|
|
1922
|
+
const doc = JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8'));
|
|
1923
|
+
if (!doc?.hooks || typeof doc.hooks !== 'object' || Array.isArray(doc.hooks)) {
|
|
1924
|
+
errors.push(`${relative}: hooks must be an object`);
|
|
1925
|
+
continue;
|
|
1926
|
+
}
|
|
1927
|
+
for (const [event, groups] of Object.entries(doc.hooks)) {
|
|
1928
|
+
if (!Array.isArray(groups)) {
|
|
1929
|
+
errors.push(`${relative}: ${event} must be an array`);
|
|
1930
|
+
continue;
|
|
1931
|
+
}
|
|
1932
|
+
errors.push(`${relative}: retired registry must not declare event ${event}`);
|
|
1933
|
+
for (const [groupIndex, group] of groups.entries()) {
|
|
1934
|
+
if (!group || typeof group !== 'object' || Array.isArray(group) || !Array.isArray(group.hooks)) {
|
|
1935
|
+
errors.push(`${relative}: ${event}[${groupIndex}].hooks must be an array`);
|
|
1936
|
+
continue;
|
|
1937
|
+
}
|
|
1938
|
+
for (const hook of group.hooks) {
|
|
1939
|
+
registrations.push({ file: relative, event, command: String(hook?.command || '') });
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
} catch (error) {
|
|
1944
|
+
errors.push(`${relative}: ${error.message}`);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
try {
|
|
1948
|
+
const contractsFile = 'plugin/hooks/hook-contracts.json';
|
|
1949
|
+
const contracts = JSON.parse(fs.readFileSync(path.join(root, contractsFile), 'utf8'));
|
|
1950
|
+
checkedFiles.push(contractsFile);
|
|
1951
|
+
if (!Array.isArray(contracts.contracts) || contracts.contracts.length !== 0) {
|
|
1952
|
+
errors.push(`${contractsFile}: contracts must be an empty array`);
|
|
1953
|
+
}
|
|
1954
|
+
if (!Array.isArray(contracts.matcherAllowlist) || contracts.matcherAllowlist.length !== 0) {
|
|
1955
|
+
errors.push(`${contractsFile}: matcherAllowlist must be an empty array`);
|
|
1956
|
+
}
|
|
1957
|
+
} catch (error) {
|
|
1958
|
+
errors.push(`plugin/hooks/hook-contracts.json: ${error.message}`);
|
|
1959
|
+
}
|
|
1960
|
+
for (const [relative, field, expected] of pointerContracts) {
|
|
1961
|
+
try {
|
|
1962
|
+
const doc = JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8'));
|
|
1963
|
+
checkedFiles.push(relative);
|
|
1964
|
+
if (doc?.[field] !== expected) errors.push(`${relative}: ${field} must equal ${expected}`);
|
|
1965
|
+
} catch (error) {
|
|
1966
|
+
errors.push(`${relative}: ${error.message}`);
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
return { ok: errors.length === 0 && registrations.length === 0, files: checkedFiles, registrations, errors };
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
export function claudeInstalledHookRetirementStatus({ home = os.homedir(), plugin: suppliedPlugin = null } = {}) {
|
|
1973
|
+
const plugin = suppliedPlugin ?? claudePluginStatus({ home });
|
|
1974
|
+
if (!plugin.managed && !plugin.installed) {
|
|
1975
|
+
return { ok: true, state: 'not-installed', plugin, file: null, registrations: [], errors: [] };
|
|
1976
|
+
}
|
|
1977
|
+
if (!plugin.installed || !plugin.installPath) {
|
|
1978
|
+
return {
|
|
1979
|
+
ok: false,
|
|
1980
|
+
state: 'installed-state-invalid',
|
|
1981
|
+
plugin,
|
|
1982
|
+
file: null,
|
|
1983
|
+
registrations: [],
|
|
1984
|
+
errors: [plugin.error || 'Claude Brain plugin registry record does not resolve to a managed install'],
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
const file = path.join(plugin.installPath, 'hooks', 'hooks.json');
|
|
1988
|
+
const registrations = [];
|
|
1989
|
+
const errors = [];
|
|
1990
|
+
try {
|
|
1991
|
+
const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1992
|
+
if (!doc?.hooks || typeof doc.hooks !== 'object' || Array.isArray(doc.hooks)) {
|
|
1993
|
+
errors.push('installed Claude hooks must be an object');
|
|
1994
|
+
} else {
|
|
1995
|
+
for (const [event, groups] of Object.entries(doc.hooks)) {
|
|
1996
|
+
errors.push(`installed Claude registry must not declare event ${event}`);
|
|
1997
|
+
if (!Array.isArray(groups)) {
|
|
1998
|
+
errors.push(`installed Claude ${event} must be an array`);
|
|
1999
|
+
continue;
|
|
2000
|
+
}
|
|
2001
|
+
for (const [groupIndex, group] of groups.entries()) {
|
|
2002
|
+
if (!group || typeof group !== 'object' || Array.isArray(group) || !Array.isArray(group.hooks)) {
|
|
2003
|
+
errors.push(`installed Claude ${event}[${groupIndex}].hooks must be an array`);
|
|
2004
|
+
continue;
|
|
2005
|
+
}
|
|
2006
|
+
for (const hook of group.hooks) {
|
|
2007
|
+
registrations.push({ file, event, command: String(hook?.command || '') });
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
} catch (error) {
|
|
2013
|
+
errors.push(`installed Claude hooks: ${error.message}`);
|
|
2014
|
+
}
|
|
2015
|
+
return {
|
|
2016
|
+
ok: errors.length === 0 && registrations.length === 0,
|
|
2017
|
+
state: errors.length === 0 && registrations.length === 0 ? 'inactive-by-design' : 'unexpected-runtime-hooks',
|
|
2018
|
+
plugin,
|
|
2019
|
+
file,
|
|
2020
|
+
registrations,
|
|
2021
|
+
errors,
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
|
|
1743
2025
|
function printCodexLifecycle(status) {
|
|
1744
2026
|
const guidance = codexLifecycleGuidance(status);
|
|
1745
2027
|
console.log(` ${guidance.healthy ? c.green('✓') : guidance.intentional ? c.dim('○') : c.yellow('!')} ${guidance.summary}`);
|
|
@@ -2024,7 +2306,7 @@ async function runDemo() {
|
|
|
2024
2306
|
} catch { /* informational only */ }
|
|
2025
2307
|
|
|
2026
2308
|
console.log(`\n Now try it for real: open Claude Code in any project and ask it something about`);
|
|
2027
|
-
console.log(` RuVector, Ruflo, AgentDB, or SPARC —
|
|
2309
|
+
console.log(` RuVector, Ruflo, AgentDB, or SPARC — use ${c.bold('search_ruvnet')} when you need source grounding.`);
|
|
2028
2310
|
console.log(` Run this demo again any time: ${c.bold('npx ruvnet-brain --demo')}`);
|
|
2029
2311
|
console.log(` Full health check: ${c.bold('npx ruvnet-brain --doctor')}\n`);
|
|
2030
2312
|
}
|
|
@@ -2098,6 +2380,18 @@ async function doctor() {
|
|
|
2098
2380
|
warn(`host convergence receipt is invalid: ${error.message}`);
|
|
2099
2381
|
}
|
|
2100
2382
|
}
|
|
2383
|
+
const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(cacheDir);
|
|
2384
|
+
const nightlyHealth = schedulerStatus({ platform: process.platform, env: process.env,
|
|
2385
|
+
brainHome, kbDir: cacheDir });
|
|
2386
|
+
if (nightlyHealth.state === 'on') {
|
|
2387
|
+
ok(`nightly scheduler: ${nightlyHealth.evidence}`);
|
|
2388
|
+
if (nightlyHealth.runHealth?.state === 'ok' || nightlyHealth.runHealth?.state === 'running') {
|
|
2389
|
+
ok(`nightly execution: ${nightlyHealth.runHealth.evidence}`);
|
|
2390
|
+
} else warn(`nightly execution unproven: ${nightlyHealth.runHealth?.evidence || 'no run receipt'}`);
|
|
2391
|
+
}
|
|
2392
|
+
else if (nightlyHealth.state === 'degraded') warn(`nightly scheduler degraded: ${nightlyHealth.evidence}`);
|
|
2393
|
+
else if (nightlyHealth.state === 'off') info('nightly scheduler is off (optional; enable with --enable-nightly)');
|
|
2394
|
+
else info(`nightly scheduler status unavailable: ${nightlyHealth.evidence}`);
|
|
2101
2395
|
have('node') ? ok('node present') : warn('node missing');
|
|
2102
2396
|
have('npm') ? ok('npm present') : warn('npm missing');
|
|
2103
2397
|
have('claude') ? ok('claude CLI present') : warn('claude CLI missing (plugin wiring needs it)');
|
|
@@ -2226,23 +2520,33 @@ async function doctor() {
|
|
|
2226
2520
|
console.log(` window and it's there. ${c.bold('No reinstall per project. No second download.')} One brain, shared.`);
|
|
2227
2521
|
}
|
|
2228
2522
|
console.log(` • ${c.bold('Nothing to git-ignore')} in your projects — it drops zero files into your working repos.`);
|
|
2229
|
-
console.log(` • ${c.bold('To use it:')}
|
|
2230
|
-
console.log(
|
|
2231
|
-
console.log(` • ${c.bold("To know it's
|
|
2232
|
-
console.log(`
|
|
2523
|
+
console.log(` • ${c.bold('To use it:')} invoke ${c.bold('search_ruvnet')} or a Brain skill when source-grounded`);
|
|
2524
|
+
console.log(' RuvNet context is needed. Nothing is injected into an ordinary turn automatically.');
|
|
2525
|
+
console.log(` • ${c.bold("To know it's available:")} run this ${c.bold('--doctor')} command any time; no Brain lifecycle`);
|
|
2526
|
+
console.log(` hook should fire automatically.`);
|
|
2233
2527
|
}
|
|
2234
2528
|
console.log(
|
|
2235
2529
|
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'),
|
|
2236
2530
|
);
|
|
2237
2531
|
|
|
2238
2532
|
// ── THE MECHANICAL VERDICT ────────────────────────────────────────────────────────────────────
|
|
2239
|
-
// `--hooks`
|
|
2240
|
-
//
|
|
2241
|
-
// pure read that anyone can run without side effects.
|
|
2533
|
+
// `--hooks` is retained as a compatibility alias for a read-only zero-registration proof. It must
|
|
2534
|
+
// never execute dormant hook bodies.
|
|
2242
2535
|
let hookResult = null;
|
|
2243
2536
|
if (FLAG_HOOKS) {
|
|
2244
|
-
|
|
2245
|
-
|
|
2537
|
+
const retirement = automaticHookRetirementStatus(REPO_ROOT, { scope: 'installed' });
|
|
2538
|
+
const installedClaude = claudeInstalledHookRetirementStatus();
|
|
2539
|
+
const hookOk = retirement.ok && installedClaude.ok;
|
|
2540
|
+
hookResult = { exitCode: hookOk ? 0 : 1 };
|
|
2541
|
+
console.log(` ${hookOk ? c.green('✓') : c.red('✗')} automatic Brain hook retirement: ${retirement.registrations.length + installedClaude.registrations.length} registration(s), ${retirement.errors.length + installedClaude.errors.length} manifest error(s)`);
|
|
2542
|
+
for (const error of retirement.errors) console.log(` ${error}`);
|
|
2543
|
+
for (const error of installedClaude.errors) console.log(` ${error}`);
|
|
2544
|
+
for (const registration of retirement.registrations) {
|
|
2545
|
+
console.log(` ${registration.file} ${registration.event}: ${registration.command || '(empty command)'}`);
|
|
2546
|
+
}
|
|
2547
|
+
for (const registration of installedClaude.registrations) {
|
|
2548
|
+
console.log(` ${registration.file} ${registration.event}: ${registration.command || '(empty command)'}`);
|
|
2549
|
+
}
|
|
2246
2550
|
}
|
|
2247
2551
|
|
|
2248
2552
|
// ── THE PERSISTED GROUNDING VERDICT (ADR-058 §D8) — synchronize stronger live proof first ───────
|
|
@@ -2282,6 +2586,8 @@ async function doctor() {
|
|
|
2282
2586
|
|| groundingUnprovenPersisted
|
|
2283
2587
|
|| (codexLifecycleFailed && !codexTrustBypassed)
|
|
2284
2588
|
|| codexWiringFailed
|
|
2589
|
+
|| nightlyHealth.state === 'degraded'
|
|
2590
|
+
|| (nightlyHealth.state === 'on' && !['ok', 'running'].includes(nightlyHealth.runHealth?.state))
|
|
2285
2591
|
|| codexReadinessFailed
|
|
2286
2592
|
|| !hostConvergence.healthy
|
|
2287
2593
|
|| Boolean(rufloOperational && !rufloOperational.healthy);
|
|
@@ -2313,7 +2619,7 @@ async function runSelfCheck({ installState = null, quiet = false } = {}) {
|
|
|
2313
2619
|
mod = await import(new URL('../scripts/selfcheck.mjs', import.meta.url).href);
|
|
2314
2620
|
} catch (e) {
|
|
2315
2621
|
warn(`self-check could not run (${e && e.message}) — this install has NOT been verified end to end`);
|
|
2316
|
-
return { exitCode:
|
|
2622
|
+
return { exitCode: FLAG_HOOKS ? 1 : 0, violations: [], lines: [], unavailable: true };
|
|
2317
2623
|
}
|
|
2318
2624
|
const result = await mod.selfCheck({ installState, security: true });
|
|
2319
2625
|
if (!quiet || result.violations.length) console.log(mod.formatVerdict(result, { color: c }));
|
|
@@ -2518,11 +2824,8 @@ function runFeedback() {
|
|
|
2518
2824
|
// canonical Release bundle, backs the current copy up, extracts, and re-verifies with forge-guard —
|
|
2519
2825
|
// failing loud with no partial clobber. These flags never reimplement any of that; they only INVOKE
|
|
2520
2826
|
// it once (--update) or SCHEDULE it per-user (--enable-nightly). Nothing here ever publishes.
|
|
2521
|
-
const NIGHTLY_LABEL = 'com.ruvnet.brain-update';
|
|
2522
2827
|
const resolvedKbDir = () =>
|
|
2523
2828
|
process.env.RUVNET_BRAIN_KB || path.join(os.homedir(), '.cache', 'ruvnet-brain', 'kb');
|
|
2524
|
-
const nightlyPlistPath = () =>
|
|
2525
|
-
path.join(os.homedir(), 'Library', 'LaunchAgents', `${NIGHTLY_LABEL}.plist`);
|
|
2526
2829
|
// RUVNET_BRAIN_TEST=1 → write/remove the plist but NEVER call launchctl. Tests point HOME at a temp
|
|
2527
2830
|
// dir; bootstrapping a temp-dir plist into the user's real gui domain would mutate exactly the
|
|
2528
2831
|
// system state the tests promise not to touch.
|
|
@@ -2533,112 +2836,16 @@ const TEST_MODE = process.env.RUVNET_BRAIN_TEST === '1';
|
|
|
2533
2836
|
// (see smokeQuery's launch note) — and a silently-skipped installer main is the worst possible
|
|
2534
2837
|
// failure mode for a stranger's first contact. With the variable unset, behavior is unchanged.
|
|
2535
2838
|
const IMPORT_ONLY = process.env.RUVNET_BRAIN_IMPORT_ONLY === '1';
|
|
2536
|
-
const xmlEscape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
2537
|
-
|
|
2538
|
-
/**
|
|
2539
|
-
* THE ONE SCHEDULED-UPDATE COMMAND (issue #129). Every scheduler — cron, systemd, LaunchAgent —
|
|
2540
|
-
* runs THIS, and it is byte-for-byte the entrypoint plugin/scripts/host-update.mjs already uses on
|
|
2541
|
-
* SessionStart.
|
|
2542
|
-
*
|
|
2543
|
-
* It used to be `forge-update.mjs --apply`, which advances the KB BYTES ONLY. That path never
|
|
2544
|
-
* reaches host convergence, so a scheduled run could move the corpus forward while the Stable Spine,
|
|
2545
|
-
* the Claude and Codex plugin payloads, the Console runtime and `host-convergence.json` all stayed
|
|
2546
|
-
* behind — silently, on a schedule, with the update log reporting success. A machine updating itself
|
|
2547
|
-
* into a split state overnight is worse than one that never updates, because nothing looks wrong.
|
|
2548
|
-
*
|
|
2549
|
-
* Two entrypoints for one job is the defect; naming it once here is the fix. 03:47 on purpose: an
|
|
2550
|
-
* off-hour minute, so it never piles onto the :00 cron rush.
|
|
2551
|
-
*/
|
|
2552
|
-
const NIGHTLY_ARGV = ['--yes', 'ruvnet-brain@latest', '--update', '--host-sync-only', '--no-nightly-prompt'];
|
|
2553
|
-
const nightlyCommand = () => `npx ${NIGHTLY_ARGV.join(' ')}`;
|
|
2554
2839
|
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
try {
|
|
2565
|
-
const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['npx'], { encoding: 'utf8' });
|
|
2566
|
-
const found = String(r.stdout || '').split('\n')[0].trim();
|
|
2567
|
-
if (found && fs.existsSync(found)) return found;
|
|
2568
|
-
} catch { /* not on PATH here either — fall through */ }
|
|
2569
|
-
return process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
2570
|
-
}
|
|
2571
|
-
/**
|
|
2572
|
-
* Add the nightly line to the user's crontab, idempotently (issue #129).
|
|
2573
|
-
*
|
|
2574
|
-
* Read-modify-write through `crontab -l` / `crontab -` because that is the only portable interface;
|
|
2575
|
-
* writing /var/spool/cron directly needs root and bypasses the daemon's reload. Every failure mode
|
|
2576
|
-
* returns a REASON rather than a boolean: "no crontab binary" and "the write was rejected" need
|
|
2577
|
-
* different things from the user, and collapsing them into "failed" is how a person ends up
|
|
2578
|
-
* re-running a command that can never work.
|
|
2579
|
-
*/
|
|
2580
|
-
export function installCronEntry(line, { run = spawnSync } = {}) {
|
|
2581
|
-
const marker = 'ruvnet-brain@latest';
|
|
2582
|
-
const list = run('crontab', ['-l'], { encoding: 'utf8' });
|
|
2583
|
-
if (list.error) return { ok: false, why: 'no crontab command was found on this system' };
|
|
2584
|
-
// Exit 1 with no output is the documented "this user has no crontab yet" case, not an error.
|
|
2585
|
-
const current = String(list.stdout || '');
|
|
2586
|
-
if (current.split('\n').some((l) => l.includes(marker) && !l.trim().startsWith('#'))) {
|
|
2587
|
-
return { ok: true, already: true };
|
|
2588
|
-
}
|
|
2589
|
-
const next = `${current.replace(/\n*$/, '')}\n${line}\n`.replace(/^\n+/, '');
|
|
2590
|
-
const write = run('crontab', ['-'], { input: next, encoding: 'utf8' });
|
|
2591
|
-
if (write.error) return { ok: false, why: `crontab could not be written (${write.error.message})` };
|
|
2592
|
-
if (write.status !== 0) {
|
|
2593
|
-
return { ok: false, why: `crontab rejected the entry (exit ${write.status})${String(write.stderr || '').trim() ? `: ${String(write.stderr).trim()}` : ''}` };
|
|
2594
|
-
}
|
|
2595
|
-
return { ok: true, already: false };
|
|
2596
|
-
}
|
|
2597
|
-
|
|
2598
|
-
/**
|
|
2599
|
-
* launchd's minimal PATH plus the user-level executable homes used by the supported hosts.
|
|
2600
|
-
*
|
|
2601
|
-
* The updater does more than run npx: --host-sync-only executes the installed Claude and Codex
|
|
2602
|
-
* doors. A real 2026-08-22 launchd run found npx but then failed with `claude unavailable` and
|
|
2603
|
-
* `spawnSync codex ENOENT`; interactive shells had supplied ~/.npm-global/bin and ~/.local/bin,
|
|
2604
|
-
* launchd had not. Derive these from HOME so the fix is portable rather than pinned to one user.
|
|
2605
|
-
*/
|
|
2606
|
-
const launchdPath = () => [...new Set([
|
|
2607
|
-
path.dirname(process.execPath), path.dirname(npxPath()),
|
|
2608
|
-
path.join(os.homedir(), '.npm-global', 'bin'), path.join(os.homedir(), '.local', 'bin'),
|
|
2609
|
-
...(process.platform === 'darwin' ? ['/Applications/Codex.app/Contents/Resources'] : []),
|
|
2610
|
-
'/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin',
|
|
2611
|
-
])].filter(Boolean).join(':');
|
|
2612
|
-
const cronExample = (kbDir) =>
|
|
2613
|
-
`47 3 * * * ${nightlyCommand()} >> ${kbDir}/update.log 2>&1`;
|
|
2614
|
-
|
|
2615
|
-
/**
|
|
2616
|
-
* kb/forge-update.mjs exit 11 = "the download completed but the bundle on disk did NOT change"
|
|
2617
|
-
* (its `EXIT_NOT_LANDED`). Duplicated as a literal on purpose: the updater lives in the KB BUNDLE,
|
|
2618
|
-
* which versions and ships independently of this installer, so there is no import to share. The
|
|
2619
|
-
* two are pinned together by tests/unit/update-not-landed-exit.test.mjs, which reads the constant
|
|
2620
|
-
* out of kb/forge-update.mjs and fails if they ever drift.
|
|
2621
|
-
*/
|
|
2622
|
-
export const UPDATE_NOT_LANDED = 11;
|
|
2623
|
-
|
|
2624
|
-
/**
|
|
2625
|
-
* What `--update` does with the KB updater's exit code (issue #106).
|
|
2626
|
-
*
|
|
2627
|
-
* `--update` used to convert an honest failure into a reported success. The updater detected the
|
|
2628
|
-
* problem itself and said so — "UPDATE MISMATCH: SOURCE.json on disk is IDENTICAL to before the
|
|
2629
|
-
* update … REFUSING to report success" — and then the fresh-install FALLBACK below ran, succeeded
|
|
2630
|
-
* at re-installing the very same bytes, and its exit 0 became the exit code of the whole command.
|
|
2631
|
-
* A user's scheduled job read that as success while the corpus had not moved.
|
|
2632
|
-
*
|
|
2633
|
-
* The fallback exists for ONE thing: an old bundle whose canonical manifest URL 404s, where a fresh
|
|
2634
|
-
* install genuinely rescues the user. "Nothing landed" is not that case — it is a TRUE verdict
|
|
2635
|
-
* about an intact KB, and re-downloading the same bundle cannot change it. So that verdict is
|
|
2636
|
-
* terminal and keeps its own exit code; every other failure keeps today's fallback behaviour.
|
|
2637
|
-
*/
|
|
2638
|
-
export function classifyUpdaterExit(status, { fallbackAllowed = true } = {}) {
|
|
2639
|
-
if (status === 0) return { verdict: 'updated', fallback: false, exitCode: 0 };
|
|
2640
|
-
if (status === UPDATE_NOT_LANDED) {
|
|
2641
|
-
return { verdict: 'not-landed', fallback: false, exitCode: UPDATE_NOT_LANDED };
|
|
2840
|
+
export function classifyUpdaterExit(status, { fallbackAllowed = true, result = null, requireResult = false } = {}) {
|
|
2841
|
+
if (status === 12 && result?.terminalVerdict === 'cleanup-pending') {
|
|
2842
|
+
return { verdict: 'cleanup-pending', fallback: false, exitCode: 12 };
|
|
2843
|
+
}
|
|
2844
|
+
if (status === 0) {
|
|
2845
|
+
if (result?.terminalVerdict === 'applied') return { verdict: 'applied', fallback: false, exitCode: 0 };
|
|
2846
|
+
if (result?.terminalVerdict === 'noop') return { verdict: 'noop', fallback: false, exitCode: 0 };
|
|
2847
|
+
if (!requireResult) return { verdict: 'legacy-success', fallback: false, exitCode: 0 };
|
|
2848
|
+
return { verdict: 'invalid-result', fallback: false, exitCode: 1 };
|
|
2642
2849
|
}
|
|
2643
2850
|
return { verdict: 'failed', fallback: fallbackAllowed, exitCode: status || 1 };
|
|
2644
2851
|
}
|
|
@@ -2676,12 +2883,16 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
2676
2883
|
};
|
|
2677
2884
|
|
|
2678
2885
|
try {
|
|
2886
|
+
if (wireClaude === wirePlugin) results.hookRetirement = retireManagedHookRegistrations();
|
|
2679
2887
|
results.claude = wireClaude === wirePlugin
|
|
2680
2888
|
? wirePlugin({ expectedVersion: PACKAGE_VERSION, requireManaged: true })
|
|
2681
2889
|
: wireClaude({ expectedVersion: PACKAGE_VERSION, requireManaged: true });
|
|
2682
2890
|
if (results.claude.host && !results.claude.wired) return fail();
|
|
2683
2891
|
results.codexHost = detectCodexHost();
|
|
2684
2892
|
if (results.codexHost.host) {
|
|
2893
|
+
if (!['added', 'rewritten', 'unchanged', 'user-owned'].includes(results.codexHost.action)) {
|
|
2894
|
+
return fail({ error: `Codex host convergence failed: ${results.codexHost.action || 'unknown action'}` });
|
|
2895
|
+
}
|
|
2685
2896
|
results.codex = installCodexPlugin({ expectedVersion: PACKAGE_VERSION });
|
|
2686
2897
|
if (!['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
|
|
2687
2898
|
return fail();
|
|
@@ -2697,17 +2908,20 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
2697
2908
|
const applied = runStableSpine(apply);
|
|
2698
2909
|
const okApplied = !applied.error && applied.status === 0;
|
|
2699
2910
|
if (okApplied) {
|
|
2700
|
-
// ISSUE #153 — running
|
|
2701
|
-
// leases prove
|
|
2911
|
+
// ISSUE #153 — a running host may freeze an old plugin root. Reclaim only generations whose
|
|
2912
|
+
// modern leases prove no live consumer, or legacy generations past the compatibility grace.
|
|
2702
2913
|
try {
|
|
2703
2914
|
const gens = prunePluginGenerations({ apply: true });
|
|
2915
|
+
if (gens.retired?.length) {
|
|
2916
|
+
ok(`retired ${gens.retired.length} stale plugin generation(s) to compatibility shells: ${gens.retired.join(', ')}`);
|
|
2917
|
+
}
|
|
2704
2918
|
if (gens.removed.length) {
|
|
2705
2919
|
ok(`pruned ${gens.removed.length} stale plugin generation(s), freed ${(gens.bytes / 1048576).toFixed(1)} MB: ${gens.removed.join(', ')}`);
|
|
2706
2920
|
}
|
|
2707
2921
|
if (gens.cleanupBlocked?.length) {
|
|
2708
|
-
warn(`plugin cleanup
|
|
2709
|
-
} else if (!gens.removed.length && gens.why) {
|
|
2710
|
-
|
|
2922
|
+
warn(`plugin cleanup blocked for ${gens.cleanupBlocked.length} generation(s): ${gens.cleanupBlocked.map((item) => `${item.version} (${item.reason})`).join(', ')}`);
|
|
2923
|
+
} else if (!gens.removed.length && !gens.retired?.length && gens.why) {
|
|
2924
|
+
info(`plugin generation reconciliation: ${gens.why}`);
|
|
2711
2925
|
}
|
|
2712
2926
|
} catch (e) {
|
|
2713
2927
|
warn(`stale plugin generations were not pruned (${e.message}); nothing was removed`);
|
|
@@ -2745,7 +2959,8 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
2745
2959
|
},
|
|
2746
2960
|
consoleRuntime: results.consoleRuntime,
|
|
2747
2961
|
});
|
|
2748
|
-
return { ok: true, convergence, results, applyStatus: applied.status
|
|
2962
|
+
return { ok: convergence.healthy === true, convergence, results, applyStatus: applied.status,
|
|
2963
|
+
error: convergence.healthy === true ? null : `host convergence is ${convergence.state}: ${convergence.action}` };
|
|
2749
2964
|
}
|
|
2750
2965
|
|
|
2751
2966
|
export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSION) {
|
|
@@ -2768,6 +2983,76 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
|
|
|
2768
2983
|
function runUpdate() {
|
|
2769
2984
|
printBanner('update');
|
|
2770
2985
|
const kbDir = resolvedKbDir();
|
|
2986
|
+
const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(kbDir);
|
|
2987
|
+
if (FLAG_HOST_SYNC_ONLY) {
|
|
2988
|
+
info(c.dim('repairing host shells and managed model catalog without mutating KB bytes…\n'));
|
|
2989
|
+
const convergence = syncHostsAfterUpdate(kbDir);
|
|
2990
|
+
if (!convergence.ok) {
|
|
2991
|
+
warn(`host synchronization is incomplete${convergence.error ? ` (${convergence.error})` : ''}`);
|
|
2992
|
+
process.exitCode = 1;
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2995
|
+
try {
|
|
2996
|
+
const managed = applyManagedCatalogUpdate({
|
|
2997
|
+
routerDir: path.join(os.homedir(), '.claude', 'model-router'),
|
|
2998
|
+
packageRoot: REPO_ROOT,
|
|
2999
|
+
});
|
|
3000
|
+
if (managed.action === 'merged' && managed.added.length) {
|
|
3001
|
+
ok(`model router: added ${managed.added.length} managed model(s) — ${managed.added.join(', ')} (your catalog edits were preserved)`);
|
|
3002
|
+
}
|
|
3003
|
+
} catch (error) {
|
|
3004
|
+
warn(`managed model additions were not merged (${error.message}); your catalog was left unchanged`);
|
|
3005
|
+
}
|
|
3006
|
+
process.exitCode = 0;
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
let refreshLock;
|
|
3010
|
+
let refreshReceipt;
|
|
3011
|
+
let refreshSettled = false;
|
|
3012
|
+
const nightlyExecution = process.env.RUVNET_NIGHTLY === '1'
|
|
3013
|
+
? verifyNightlyExecutionIdentity({ brainHome, env: process.env })
|
|
3014
|
+
: null;
|
|
3015
|
+
if (nightlyExecution && !nightlyExecution.ok) {
|
|
3016
|
+
console.error(`\n${c.red('✗ nightly refresh identity is invalid:')} ${nightlyExecution.why}`);
|
|
3017
|
+
process.exitCode = 1;
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
try {
|
|
3021
|
+
const refreshAction = process.env.RUVNET_NIGHTLY === '1' ? 'nightly' : 'update';
|
|
3022
|
+
const refreshExecutableIdentity = nightlyExecution?.identity
|
|
3023
|
+
|| { path: fileURLToPath(import.meta.url), version: PACKAGE_VERSION };
|
|
3024
|
+
refreshLock = acquireRefreshLock({ kbDir, brainHome, action: refreshAction, desiredVersion: PACKAGE_VERSION,
|
|
3025
|
+
schedulerIdentity: nightlyExecution?.identity.schedulerIdentity || null,
|
|
3026
|
+
executableIdentity: refreshExecutableIdentity });
|
|
3027
|
+
refreshReceipt = openRefreshReceipt({ brainHome, lock: refreshLock,
|
|
3028
|
+
action: refreshAction, desiredVersion: PACKAGE_VERSION,
|
|
3029
|
+
schedulerIdentity: nightlyExecution?.identity.schedulerIdentity || null,
|
|
3030
|
+
executableIdentity: refreshExecutableIdentity });
|
|
3031
|
+
} catch (error) {
|
|
3032
|
+
console.error(`\n${c.red('✗ refresh transaction could not start:')} ${error.message}`);
|
|
3033
|
+
process.exitCode = 1;
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
const refreshEnv = { ...process.env, RUVNET_REFRESH_RUN_TOKEN: refreshLock.token,
|
|
3037
|
+
RUVNET_REFRESH_RECEIPT: refreshReceipt.file };
|
|
3038
|
+
const settleRefresh = (code, detail) => {
|
|
3039
|
+
if (!refreshSettled) {
|
|
3040
|
+
try {
|
|
3041
|
+
settleRefreshRun({ handle: refreshReceipt, lock: refreshLock,
|
|
3042
|
+
status: code === 0 ? 'SUCCEEDED' : 'FAILED', detail });
|
|
3043
|
+
} catch (error) {
|
|
3044
|
+
code = 1;
|
|
3045
|
+
}
|
|
3046
|
+
refreshSettled = true;
|
|
3047
|
+
}
|
|
3048
|
+
process.exitCode = code;
|
|
3049
|
+
};
|
|
3050
|
+
const exitGuard = () => {
|
|
3051
|
+
if (refreshSettled) return;
|
|
3052
|
+
try { settleRefreshRun({ handle: refreshReceipt, lock: refreshLock, status: 'FAILED',
|
|
3053
|
+
detail: { reason: 'process exited before terminal settlement' } }); } catch { /* retained lock and receipt are recovery evidence */ }
|
|
3054
|
+
};
|
|
3055
|
+
process.once('exit', exitGuard);
|
|
2771
3056
|
info(`brain dir: ${c.bold(kbDir)}`);
|
|
2772
3057
|
let updateStatus = 1;
|
|
2773
3058
|
// NO updater at all = no brain installed here (or a pre-self-updater bundle). That is a USER
|
|
@@ -2779,19 +3064,29 @@ function runUpdate() {
|
|
|
2779
3064
|
// private KB stores a surprise fresh PUBLIC install is exactly the store-stripping hazard the
|
|
2780
3065
|
// project docs warn about. The fallback's own comment scopes it to an updater that EXISTS but
|
|
2781
3066
|
// is broken — this branch enforces that scope.)
|
|
2782
|
-
if (!fs.existsSync(path.join(kbDir, 'forge-update.mjs'))
|
|
3067
|
+
if (!fs.existsSync(path.join(kbDir, 'forge-update.mjs'))) {
|
|
2783
3068
|
missingUpdaterHelp(kbDir);
|
|
2784
|
-
|
|
3069
|
+
recordRefreshPhase(refreshReceipt, 'source-enumeration', 'FAIL', { reason: 'forge-update.mjs is missing' });
|
|
3070
|
+
settleRefresh(1, { phase: 'source-enumeration' });
|
|
3071
|
+
return;
|
|
2785
3072
|
}
|
|
2786
|
-
if (FLAG_HOST_SYNC_ONLY && !fs.existsSync(path.join(kbDir, 'forge-update.mjs'))) {
|
|
2787
|
-
warn('KB updater is absent — continuing with host-shell repair only; the knowledge bundle remains unchanged.');
|
|
2788
|
-
updateStatus = 0;
|
|
2789
|
-
} else {
|
|
2790
3073
|
info(c.dim("running the bundle's own self-updater (backs up first, re-verifies, never half-applies)…\n"));
|
|
2791
3074
|
// Relative filename + matching cwd — same launch convention as smokeQuery(); stdio:'inherit'
|
|
2792
3075
|
// streams the updater's narration live and unedited.
|
|
2793
|
-
const
|
|
3076
|
+
const updaterSource = fs.readFileSync(path.join(kbDir, 'forge-update.mjs'), 'utf8');
|
|
3077
|
+
const supportsResultReceipt = updaterSource.includes("'--result-file'");
|
|
3078
|
+
const updaterResultDir = supportsResultReceipt ? fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-update-result-')) : null;
|
|
3079
|
+
const updaterResultFile = updaterResultDir ? path.join(updaterResultDir, 'result.json') : null;
|
|
3080
|
+
const updaterArgs = ['forge-update.mjs', '--apply', ...(updaterResultFile ? ['--result-file', updaterResultFile] : [])];
|
|
3081
|
+
const r = spawnSync(process.execPath, updaterArgs, {
|
|
3082
|
+
cwd: kbDir, stdio: 'inherit', env: refreshEnv,
|
|
3083
|
+
});
|
|
2794
3084
|
updateStatus = r.error ? 1 : (r.status === null ? 1 : r.status);
|
|
3085
|
+
const updaterExit = updateStatus;
|
|
3086
|
+
let updaterResult = null;
|
|
3087
|
+
try { if (updaterResultFile && fs.existsSync(updaterResultFile)) updaterResult = JSON.parse(fs.readFileSync(updaterResultFile, 'utf8')); }
|
|
3088
|
+
catch { updaterResult = null; }
|
|
3089
|
+
if (updaterResultDir) fs.rmSync(updaterResultDir, { recursive: true, force: true });
|
|
2795
3090
|
// FALLBACK (2026-07-17), scoped 2026-07-18 to exists-but-FAILED only. An OLDER bundle whose
|
|
2796
3091
|
// canonicalManifestUrl points at the dead main/kb/.last-built.json path and 404s (the exact break a
|
|
2797
3092
|
// real user, Jan Lafko, hit). NEVER leave the user stranded at a 404: re-run THIS installer as a
|
|
@@ -2803,15 +3098,9 @@ function runUpdate() {
|
|
|
2803
3098
|
// updater's own correct refusal into a reported success.
|
|
2804
3099
|
const outcome = classifyUpdaterExit(updateStatus, {
|
|
2805
3100
|
fallbackAllowed: !process.env.RUVNET_BRAIN_NO_UPDATE_FALLBACK,
|
|
3101
|
+
result: updaterResult,
|
|
3102
|
+
requireResult: supportsResultReceipt,
|
|
2806
3103
|
});
|
|
2807
|
-
if (outcome.verdict === 'not-landed') {
|
|
2808
|
-
console.error(`\n ${c.red('✗ the knowledge bundle did not change.')} The updater downloaded a bundle and refused to`);
|
|
2809
|
-
console.error(` call it an update because the copy on disk is identical to the one it replaced.`);
|
|
2810
|
-
console.error(` Nothing is broken and nothing was lost — but this run is ${c.bold('not')} a success, so it exits`);
|
|
2811
|
-
console.error(` ${c.bold(String(UPDATE_NOT_LANDED))} rather than 0 and a scheduled job will see the failure (issue #106).`);
|
|
2812
|
-
console.error(` If you believe a newer build exists, check: ${c.bold('node forge-update.mjs --check')} in ${kbDir}`);
|
|
2813
|
-
process.exit(outcome.exitCode);
|
|
2814
|
-
}
|
|
2815
3104
|
if (outcome.fallback && FLAG_HOST_SYNC_ONLY) {
|
|
2816
3105
|
// Host synchronization has a narrower contract than a full update: it must converge the
|
|
2817
3106
|
// executable plugin/spine to the published package even when an optional large KB asset is
|
|
@@ -2825,11 +3114,47 @@ function runUpdate() {
|
|
|
2825
3114
|
warn("\nthe bundle's own updater couldn't complete — falling back to a fresh install of the latest Release (this always works)…\n");
|
|
2826
3115
|
const self = fileURLToPath(import.meta.url);
|
|
2827
3116
|
const fr = spawnSync(process.execPath, [self, '--force'], { stdio: 'inherit',
|
|
2828
|
-
env: { ...
|
|
3117
|
+
env: { ...refreshEnv, RUVNET_BRAIN_NO_UPDATE_FALLBACK: '1' } });
|
|
2829
3118
|
updateStatus = fr.error ? 1 : (fr.status === null ? 1 : fr.status);
|
|
2830
3119
|
} else {
|
|
2831
3120
|
updateStatus = outcome.exitCode;
|
|
2832
3121
|
}
|
|
3122
|
+
const cleanupPending = outcome.verdict === 'cleanup-pending';
|
|
3123
|
+
if (updateStatus !== 0 && !cleanupPending) {
|
|
3124
|
+
recordRefreshPhase(refreshReceipt, 'source-enumeration', 'FAIL', {
|
|
3125
|
+
updaterExit, finalExit: updateStatus, fallback: outcome.fallback, verdict: outcome.verdict,
|
|
3126
|
+
updateResult: updaterResult,
|
|
3127
|
+
});
|
|
3128
|
+
settleRefresh(updateStatus, { phase: 'source-enumeration', terminalVerdict: 'failed' });
|
|
3129
|
+
process.removeListener('exit', exitGuard);
|
|
3130
|
+
return;
|
|
3131
|
+
}
|
|
3132
|
+
if (cleanupPending) updateStatus = 0;
|
|
3133
|
+
let phaseEvidence = updaterResult?.phaseEvidence || null;
|
|
3134
|
+
if (!phaseEvidence) {
|
|
3135
|
+
const installed = validateCoverageDirectory(kbDir, { expectedVersion: PACKAGE_VERSION });
|
|
3136
|
+
if (!installed.valid) {
|
|
3137
|
+
recordRefreshPhase(refreshReceipt, 'source-enumeration', 'FAIL', { failures: installed.failures,
|
|
3138
|
+
reason: 'legacy updater returned success without a valid installed ReleaseCoverage projection' });
|
|
3139
|
+
settleRefresh(1, { phase: 'source-enumeration', terminalVerdict: 'failed' });
|
|
3140
|
+
process.removeListener('exit', exitGuard);
|
|
3141
|
+
return;
|
|
3142
|
+
}
|
|
3143
|
+
phaseEvidence = Object.fromEntries(UPDATE_REFRESH_PHASES.map((phase) => [phase, {
|
|
3144
|
+
compatibility: 'validated-legacy-updater', coverageSha256: installed.coverageSha256,
|
|
3145
|
+
releaseCoverageGeneration: installed.coverage.releaseCoverageGeneration,
|
|
3146
|
+
execution: { kind: 'validated-legacy', sourceSnapshot: installed.coverage.releaseIdentity?.sourceSnapshot || null,
|
|
3147
|
+
upstreamFreshness: 'UNKNOWN' },
|
|
3148
|
+
}]));
|
|
3149
|
+
}
|
|
3150
|
+
for (const phase of UPDATE_REFRESH_PHASES) {
|
|
3151
|
+
if (!phaseEvidence[phase]) {
|
|
3152
|
+
recordRefreshPhase(refreshReceipt, phase, 'FAIL', { reason: `updater omitted ${phase} evidence` });
|
|
3153
|
+
settleRefresh(1, { phase, terminalVerdict: 'failed' });
|
|
3154
|
+
process.removeListener('exit', exitGuard);
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
recordRefreshPhase(refreshReceipt, phase, 'PASS', phaseEvidence[phase]);
|
|
2833
3158
|
}
|
|
2834
3159
|
if (updateStatus === 0) {
|
|
2835
3160
|
info(c.dim('\nsynchronizing every detected host to this exact published version…\n'));
|
|
@@ -2838,6 +3163,10 @@ function runUpdate() {
|
|
|
2838
3163
|
warn(`host synchronization is incomplete — runtime stays on the prior verified generation${convergence.error ? ` (${convergence.error})` : ''}`);
|
|
2839
3164
|
updateStatus = 1;
|
|
2840
3165
|
}
|
|
3166
|
+
recordRefreshPhase(refreshReceipt, 'host-convergence', convergence.ok && convergence.convergence?.healthy === true ? 'PASS' : 'FAIL', {
|
|
3167
|
+
state: convergence.convergence?.state || null, error: convergence.error || null,
|
|
3168
|
+
execution: { kind: 'executed', runId: refreshReceipt.runId },
|
|
3169
|
+
});
|
|
2841
3170
|
}
|
|
2842
3171
|
// Issue #87: a normal lifecycle update must also carry MANAGED MODEL additions to an existing
|
|
2843
3172
|
// user. This call used to live only in offerRouterProfile() — the fresh-install path — so someone
|
|
@@ -2854,8 +3183,10 @@ function runUpdate() {
|
|
|
2854
3183
|
if (managed.action === 'merged' && managed.added.length) {
|
|
2855
3184
|
ok(`model router: added ${managed.added.length} managed model(s) — ${managed.added.join(', ')} (your catalog edits were preserved)`);
|
|
2856
3185
|
}
|
|
3186
|
+
recordRefreshAdvisory(refreshReceipt, 'managed-catalog', 'PASS', { action: managed.action, added: managed.added || [] });
|
|
2857
3187
|
} catch (e) {
|
|
2858
3188
|
warn(`managed model additions were not merged (${e.message}); your catalog was left unchanged`);
|
|
3189
|
+
recordRefreshAdvisory(refreshReceipt, 'managed-catalog', 'SKIP', { reason: e.message });
|
|
2859
3190
|
}
|
|
2860
3191
|
}
|
|
2861
3192
|
// `--update --auto` = update now AND enroll in Evergreen, so this is the LAST time it's ever run by
|
|
@@ -2865,152 +3196,96 @@ function runUpdate() {
|
|
|
2865
3196
|
info(c.dim("\n--auto set: enrolling in Evergreen auto-update so you never run this again…\n"));
|
|
2866
3197
|
enableNightly(); // exits on its own with verified output; if it returns, fall through to the update verdict
|
|
2867
3198
|
}
|
|
2868
|
-
|
|
3199
|
+
let retention;
|
|
3200
|
+
try {
|
|
3201
|
+
retention = pruneLifecycleEvidence({ brainHome, kbDir, preserveRefreshRunIds: [refreshReceipt.runId],
|
|
3202
|
+
preserveTransactionPaths: updaterResult?.transactionReceipts ? [updaterResult.transactionReceipts] : [] });
|
|
3203
|
+
} catch (error) {
|
|
3204
|
+
retention = { schemaVersion: 1, kind: 'ruvnet-brain-lifecycle-evidence-retention',
|
|
3205
|
+
withinBudget: false, unsafe: [{ path: brainHome, reason: error.message }] };
|
|
3206
|
+
}
|
|
3207
|
+
const retentionFailed = retention.withinBudget !== true;
|
|
3208
|
+
const cleanupFailed = cleanupPending || retentionFailed;
|
|
3209
|
+
recordRefreshPhase(refreshReceipt, 'cleanup', cleanupFailed ? 'FAIL' : (updateStatus === 0 ? 'PASS' : 'SKIP'), {
|
|
3210
|
+
reason: cleanupPending ? 'verified live generation has redundant rollback cleanup pending'
|
|
3211
|
+
: retentionFailed ? 'lifecycle evidence exceeds its fixed retention safety budget'
|
|
3212
|
+
: (updateStatus === 0 ? 'transaction settled' : 'upstream required phase failed'),
|
|
3213
|
+
storageDelta: updaterResult?.storageDelta || null,
|
|
3214
|
+
lifecycleRetention: retention,
|
|
3215
|
+
execution: { kind: 'executed', runId: refreshReceipt.runId },
|
|
3216
|
+
required: cleanupFailed || updateStatus === 0,
|
|
3217
|
+
});
|
|
3218
|
+
settleRefresh(cleanupPending ? 12 : (retentionFailed ? 1 : updateStatus), {
|
|
3219
|
+
phase: cleanupFailed ? 'cleanup' : (updateStatus === 0 ? 'complete' : 'failed'),
|
|
3220
|
+
terminalVerdict: cleanupPending ? 'cleanup-pending' : retentionFailed ? 'recovery-required'
|
|
3221
|
+
: (outcome.verdict === 'noop' ? 'noop' : 'applied'),
|
|
3222
|
+
storageDelta: updaterResult?.storageDelta || null, lifecycleRetention: retention });
|
|
3223
|
+
process.removeListener('exit', exitGuard);
|
|
2869
3224
|
}
|
|
2870
3225
|
|
|
2871
3226
|
function enableNightly() {
|
|
2872
3227
|
printBanner('enable nightly updates');
|
|
2873
3228
|
const kbDir = resolvedKbDir();
|
|
2874
|
-
|
|
2875
|
-
if (process.platform !== 'darwin') {
|
|
2876
|
-
// ISSUE #129 — A COMMAND NAMED `--enable-nightly` MAY NOT EXIT 0 WITHOUT ENABLING ANYTHING.
|
|
2877
|
-
//
|
|
2878
|
-
// This printed a cron recipe and returned success. Every automated check of "is Evergreen on?"
|
|
2879
|
-
// — a provisioning script, a CI step, a user reading the exit code — was told yes on a machine
|
|
2880
|
-
// where no schedule existed. So it now INSTALLS the entry, and when it cannot, it says so and
|
|
2881
|
-
// exits non-zero rather than dressing a manual instruction up as a completed action.
|
|
2882
|
-
const line = cronExample(kbDir);
|
|
2883
|
-
const installed = installCronEntry(line);
|
|
2884
|
-
if (installed.ok) {
|
|
2885
|
-
ok(`installed a nightly crontab entry (03:47 local):\n\n ${c.bold(line)}\n`);
|
|
2886
|
-
info(`(${c.bold('crontab -l')} to see it, ${c.bold('crontab -e')} to remove it.)`);
|
|
2887
|
-
return;
|
|
2888
|
-
}
|
|
2889
|
-
console.error(`\n${c.red('✗ nightly updates were NOT enabled:')} ${installed.why}`);
|
|
2890
|
-
info('\nAdd this line by hand and nightly updates will work exactly as they do on macOS:');
|
|
2891
|
-
console.log(`\n ${c.bold(line)}\n`);
|
|
2892
|
-
info(`(${c.bold('crontab -e')}, paste the line, save. Remove the line to disable.)`);
|
|
2893
|
-
process.exit(1); // the caller asked for a schedule and does not have one
|
|
2894
|
-
}
|
|
2895
|
-
|
|
2896
3229
|
info(`brain dir: ${c.bold(kbDir)}`);
|
|
2897
3230
|
if (!fs.existsSync(path.join(kbDir, 'forge-update.mjs'))) {
|
|
2898
|
-
// Refuse to schedule a job that is guaranteed to fail every night — fail loud NOW instead.
|
|
2899
3231
|
missingUpdaterHelp(kbDir);
|
|
2900
|
-
process.
|
|
3232
|
+
process.exitCode = 1;
|
|
3233
|
+
return;
|
|
2901
3234
|
}
|
|
2902
|
-
|
|
2903
|
-
// Template the plist to THIS user's kb dir + node binary.
|
|
2904
|
-
//
|
|
2905
|
-
// No `/bin/sh -c` (ADR-038): a LaunchAgent whose ProgramArguments invoke a shell is the standard
|
|
2906
|
-
// macOS persistence pattern, and EDR persistence monitors score it well above a plist that execs a
|
|
2907
|
-
// binary directly. launchd provides everything the shell was doing here natively —
|
|
2908
|
-
// WorkingDirectory replaces `cd`, StandardOutPath/StandardErrorPath replace `>>` and `2>&1` — so
|
|
2909
|
-
// dropping the shell costs nothing and removes both a shell parse of interpolated paths and the
|
|
2910
|
-
// signature. Same schedule, same command, same log.
|
|
2911
|
-
const logPath = path.join(kbDir, 'update.log');
|
|
2912
|
-
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
2913
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2914
|
-
<plist version="1.0">
|
|
2915
|
-
<dict>
|
|
2916
|
-
<key>Label</key>
|
|
2917
|
-
<string>${NIGHTLY_LABEL}</string>
|
|
2918
|
-
<!-- Issue #129: the SAME host-convergent entrypoint the session updater runs, not the KB-only
|
|
2919
|
-
forge-update.mjs it used to schedule. See NIGHTLY_ARGV. Still no shell wrapper (ADR-038) —
|
|
2920
|
-
this execs npx directly. -->
|
|
2921
|
-
<key>ProgramArguments</key>
|
|
2922
|
-
<array>
|
|
2923
|
-
<string>${xmlEscape(npxPath())}</string>
|
|
2924
|
-
${NIGHTLY_ARGV.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n')}
|
|
2925
|
-
</array>
|
|
2926
|
-
<!-- launchd starts agents with a MINIMAL PATH (/usr/bin:/bin:/usr/sbin:/sbin) - it does not read
|
|
2927
|
-
the user's shell profile. npx resolved by bare name would therefore fail to launch on every
|
|
2928
|
-
Homebrew/nvm/Volta install, which is most of them, and the only symptom would be a nightly
|
|
2929
|
-
that silently never ran. The absolute path above is resolved at install time; PATH is also set
|
|
2930
|
-
so npx can find node and the package's own bin shims once it starts. -->
|
|
2931
|
-
<key>EnvironmentVariables</key>
|
|
2932
|
-
<dict>
|
|
2933
|
-
<key>PATH</key>
|
|
2934
|
-
<string>${xmlEscape(launchdPath())}</string>
|
|
2935
|
-
</dict>
|
|
2936
|
-
<key>WorkingDirectory</key>
|
|
2937
|
-
<string>${xmlEscape(kbDir)}</string>
|
|
2938
|
-
<key>StandardOutPath</key>
|
|
2939
|
-
<string>${xmlEscape(logPath)}</string>
|
|
2940
|
-
<key>StandardErrorPath</key>
|
|
2941
|
-
<string>${xmlEscape(logPath)}</string>
|
|
2942
|
-
<key>StartCalendarInterval</key>
|
|
2943
|
-
<dict>
|
|
2944
|
-
<key>Hour</key>
|
|
2945
|
-
<integer>3</integer>
|
|
2946
|
-
<key>Minute</key>
|
|
2947
|
-
<integer>47</integer>
|
|
2948
|
-
</dict>
|
|
2949
|
-
<key>RunAtLoad</key>
|
|
2950
|
-
<false/>
|
|
2951
|
-
</dict>
|
|
2952
|
-
</plist>
|
|
2953
|
-
`;
|
|
2954
|
-
const plistPath = nightlyPlistPath();
|
|
2955
3235
|
try {
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
warn(`launchctl bootstrap failed (${(boot.stderr || '').trim() || `exit ${boot.status}`}) — the plist is in place;`);
|
|
2975
|
-
info(`load it yourself: ${c.bold(`launchctl bootstrap gui/${uid} ${plistPath}`)}`);
|
|
3236
|
+
const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(kbDir);
|
|
3237
|
+
const registration = installNightlyRunner({ brainHome, env: { ...process.env, RUVNET_BRAIN_HOME: brainHome, RUVNET_BRAIN_KB: kbDir },
|
|
3238
|
+
source: path.join(REPO_ROOT, 'bin', 'nightly-refresh.mjs') });
|
|
3239
|
+
const installed = installScheduler(registration, {
|
|
3240
|
+
platform: process.platform, env: process.env, kbDir, testMode: TEST_MODE,
|
|
3241
|
+
pathValue: [...new Set([path.dirname(process.execPath), ...(process.env.PATH || '').split(path.delimiter)])].filter(Boolean).join(path.delimiter),
|
|
3242
|
+
});
|
|
3243
|
+
if (!installed.ok) throw new Error(installed.why);
|
|
3244
|
+
const status = schedulerStatus({ platform: process.platform, env: process.env, brainHome, kbDir,
|
|
3245
|
+
testMode: TEST_MODE });
|
|
3246
|
+
if (status.state !== 'on') throw new Error(status.evidence);
|
|
3247
|
+
ok(`nightly updates enabled — ${status.evidence}`);
|
|
3248
|
+
info(c.dim(`identity ${NIGHTLY_LABEL}; immutable runner ${registration.runnerSha256.slice(0, 16)}…; 03:47 local`));
|
|
3249
|
+
if (TEST_MODE) warn('RUVNET_BRAIN_TEST=1 — scheduler registration was written but OS activation was safely simulated');
|
|
3250
|
+
} catch (error) {
|
|
3251
|
+
console.error(`\n${c.red('✗ nightly updates were NOT enabled:')} ${error.message}`);
|
|
3252
|
+
process.exitCode = 1;
|
|
3253
|
+
return;
|
|
2976
3254
|
}
|
|
2977
|
-
}
|
|
2978
|
-
|
|
2979
|
-
console.log(`\n ${c.bold('Verify it:')} launchctl list | grep ${NIGHTLY_LABEL}`);
|
|
2980
|
-
console.log(` ${c.bold('Watch it:')} tail ${logPath} ${c.dim('(appears after the first nightly run)')}`);
|
|
2981
3255
|
console.log(` ${c.bold('Disable it:')} npx ruvnet-brain --disable-nightly`);
|
|
2982
|
-
console.log(`\n ${c.dim('It only ever PULLS the published Release bundle (backup + re-verify built in) — it never publishes,')}`);
|
|
2983
|
-
console.log(` ${c.dim('and a night with no new Release is a clean no-op.')}\n`);
|
|
2984
3256
|
}
|
|
2985
3257
|
|
|
2986
3258
|
function disableNightly() {
|
|
2987
3259
|
printBanner('disable nightly updates');
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
3260
|
+
const kbDir = resolvedKbDir();
|
|
3261
|
+
const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(kbDir);
|
|
3262
|
+
const registration = readNightlyRegistration({ brainHome });
|
|
3263
|
+
const removed = removeScheduler({ platform: process.platform, env: process.env, testMode: TEST_MODE });
|
|
3264
|
+
if (!removed.ok) {
|
|
3265
|
+
console.error(`\n${c.red('✗ nightly updates were NOT disabled:')} ${removed.why}`);
|
|
3266
|
+
process.exitCode = 1;
|
|
3267
|
+
return false;
|
|
2992
3268
|
}
|
|
2993
|
-
const
|
|
2994
|
-
|
|
2995
|
-
if (
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
spawnSync('launchctl', ['bootout', `gui/${process.getuid()}/${NIGHTLY_LABEL}`], { stdio: 'ignore' });
|
|
3269
|
+
const status = schedulerStatus({ platform: process.platform, env: process.env, brainHome, kbDir,
|
|
3270
|
+
testMode: TEST_MODE });
|
|
3271
|
+
if (!['off', 'unsupported'].includes(status.state)) {
|
|
3272
|
+
console.error(`\n${c.red('✗ nightly disable could not be verified:')} ${status.evidence}`);
|
|
3273
|
+
process.exitCode = 1;
|
|
3274
|
+
return false;
|
|
3000
3275
|
}
|
|
3001
|
-
if (
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3276
|
+
if (registration.ok) {
|
|
3277
|
+
fs.rmSync(registration.record.recordPath, { force: true });
|
|
3278
|
+
const dir = path.dirname(registration.record.recordPath);
|
|
3279
|
+
const otherRegistrations = fs.readdirSync(dir).filter(name => /^registration.*\.json$/.test(name));
|
|
3280
|
+
// A proof registration may share the immutable runner; retain it if any registration remains.
|
|
3281
|
+
if (!otherRegistrations.length && path.dirname(registration.record.runnerPath) === dir
|
|
3282
|
+
&& path.basename(registration.record.runnerPath) === `nightly-refresh-${registration.record.runnerSha256}.mjs`) {
|
|
3283
|
+
fs.rmSync(registration.record.runnerPath, { force: true });
|
|
3008
3284
|
}
|
|
3009
|
-
ok(`nightly updates disabled — removed ${plistPath}`);
|
|
3010
|
-
} else {
|
|
3011
|
-
ok('nightly updates were already off — nothing to remove (safe to run any time)');
|
|
3012
3285
|
}
|
|
3286
|
+
ok(removed.already ? 'nightly updates were already off — nothing to remove' : 'nightly updates disabled and absence verified');
|
|
3013
3287
|
info(`re-enable any time: ${c.bold('npx ruvnet-brain --enable-nightly')}`);
|
|
3288
|
+
return true;
|
|
3014
3289
|
}
|
|
3015
3290
|
|
|
3016
3291
|
// ── spend guard: the alarm that catches a runaway agentic fleet BEFORE it drains a card ───────────
|
|
@@ -3137,8 +3412,12 @@ export function machineFootprint() {
|
|
|
3137
3412
|
const add = (label, p, undo) => { try { if (p && fs.existsSync(p)) items.push({ label, path: p, undo }); } catch { /* unreadable → not ours to claim */ } };
|
|
3138
3413
|
|
|
3139
3414
|
add('Brain bundle (knowledge base)', resolvedKbDir(), 'npx ruvnet-brain --uninstall');
|
|
3415
|
+
{
|
|
3416
|
+
const artifact = nightlyArtifact({ platform: process.platform, env: process.env });
|
|
3417
|
+
if (artifact.kind === 'launchd') add('Nightly updater (LaunchAgent)', artifact.path, 'npx ruvnet-brain --disable-nightly');
|
|
3418
|
+
add('Nightly scheduler registration', path.join(process.env.RUVNET_BRAIN_HOME || path.dirname(resolvedKbDir()), 'scheduler', 'registration.json'), 'npx ruvnet-brain --disable-nightly');
|
|
3419
|
+
}
|
|
3140
3420
|
if (process.platform === 'darwin') {
|
|
3141
|
-
add('Nightly updater (LaunchAgent)', nightlyPlistPath(), 'npx ruvnet-brain --disable-nightly');
|
|
3142
3421
|
add('Spend watchdog (LaunchAgent)', spendGuardPlistPath(), 'npx ruvnet-brain --disable-spend-guard');
|
|
3143
3422
|
add('Spend watchdog script', spendGuardScriptPath(), 'npx ruvnet-brain --disable-spend-guard');
|
|
3144
3423
|
}
|
|
@@ -3207,8 +3486,8 @@ export function machineFootprint() {
|
|
|
3207
3486
|
undo: 'npx ruvnet-brain --uninstall (removes only the managed block)',
|
|
3208
3487
|
});
|
|
3209
3488
|
}
|
|
3210
|
-
add('Codex lifecycle hook wrapper', codexHookWrapperPath(),
|
|
3211
|
-
'npx ruvnet-brain --uninstall');
|
|
3489
|
+
add('Legacy Codex lifecycle hook wrapper', codexHookWrapperPath(),
|
|
3490
|
+
're-run npx ruvnet-brain to retire it, or use --uninstall');
|
|
3212
3491
|
add('Codex MCP server files', codexServerDir(), 'npx ruvnet-brain --uninstall');
|
|
3213
3492
|
const codexPlugin = codexPluginStatus();
|
|
3214
3493
|
if (codexPlugin.installed) {
|
|
@@ -3316,13 +3595,13 @@ function uninstallAll() {
|
|
|
3316
3595
|
// Ours-by-construction directories and files are removed; things that live INSIDE a file the user
|
|
3317
3596
|
// owns (settings.json entries, the MCP registration) and the Claude Code plugin itself are not
|
|
3318
3597
|
// ours to delete, so they are handed over as commands.
|
|
3319
|
-
const AUTO = new Set(['Brain bundle (knowledge base)', 'Nightly updater (LaunchAgent)',
|
|
3598
|
+
const AUTO = new Set(['Brain bundle (knowledge base)', 'Nightly updater (LaunchAgent)', 'Nightly scheduler registration',
|
|
3320
3599
|
'Spend watchdog (LaunchAgent)', 'Spend watchdog script', 'CLAUDE.md block (6 lines, between markers)',
|
|
3321
3600
|
'Model-router files', 'Status-bar version script', 'Status-bar preference', 'Usage-counts preference',
|
|
3322
3601
|
// Two gaps closed here: the statusLine KEY is now removable in place (we know exactly what we
|
|
3323
3602
|
// wrote — see removeSettingsStatusLine()), and the upgrade-notice tracker is a file we fully own.
|
|
3324
3603
|
'The statusLine entry in settings.json', 'Upgrade-notice state (release-notice tracking)',
|
|
3325
|
-
'The Codex search_ruvnet MCP registration', 'Codex lifecycle hook wrapper',
|
|
3604
|
+
'The Codex search_ruvnet MCP registration', 'Legacy Codex lifecycle hook wrapper',
|
|
3326
3605
|
'Codex MCP server files']);
|
|
3327
3606
|
const willRemove = before.filter((it) => AUTO.has(it.label));
|
|
3328
3607
|
const manual = before.filter((it) => !AUTO.has(it.label));
|
|
@@ -3337,7 +3616,10 @@ function uninstallAll() {
|
|
|
3337
3616
|
console.log(`\n ${c.dim('Your own CLAUDE.md content is preserved — only our marked block is taken out,')}`);
|
|
3338
3617
|
console.log(` ${c.dim('and the file is backed up first.')}\n`);
|
|
3339
3618
|
|
|
3340
|
-
if (process.platform
|
|
3619
|
+
if (nightlyArtifact({ platform: process.platform, env: process.env }).supported) {
|
|
3620
|
+
if (disableNightly() === false) return;
|
|
3621
|
+
}
|
|
3622
|
+
if (process.platform === 'darwin') disableSpendGuard();
|
|
3341
3623
|
|
|
3342
3624
|
const claudeMd = removeClaudeMdBlock();
|
|
3343
3625
|
if (claudeMd === 'removed') ok('removed our block from ~/.claude/CLAUDE.md (your content untouched, backup saved)');
|
|
@@ -3389,7 +3671,7 @@ function uninstallAll() {
|
|
|
3389
3671
|
['upgrade-notice state', upgradeNoticeStatePath()],
|
|
3390
3672
|
// ADR-054 §5: a reinstall must never inherit an invisible OFF. See brainOffSentinelPath().
|
|
3391
3673
|
['brain on/off switch', brainOffSentinelPath()],
|
|
3392
|
-
['Codex lifecycle hook wrapper', codexHookWrapperPath()],
|
|
3674
|
+
['legacy Codex lifecycle hook wrapper', codexHookWrapperPath()],
|
|
3393
3675
|
['Codex MCP server files', codexServerDir()],
|
|
3394
3676
|
]) {
|
|
3395
3677
|
if (!fs.existsSync(target)) continue;
|
|
@@ -3571,14 +3853,15 @@ export async function offerNightly() {
|
|
|
3571
3853
|
'rUv ships constantly; a brain that updates itself stays current with zero effort from you',
|
|
3572
3854
|
);
|
|
3573
3855
|
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
info(`
|
|
3577
|
-
info(`(or schedule it yourself with the cron line documented in the brain's own forge-update.mjs)`);
|
|
3856
|
+
const artifact = nightlyArtifact({ platform: process.platform, env: process.env });
|
|
3857
|
+
if (!artifact.supported) {
|
|
3858
|
+
info(`No reversible scheduler adapter is available for ${process.platform}.`);
|
|
3578
3859
|
return 'unsupported';
|
|
3579
3860
|
}
|
|
3580
3861
|
|
|
3581
|
-
|
|
3862
|
+
const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(kbDir);
|
|
3863
|
+
if (schedulerStatus({ platform: process.platform, env: process.env, brainHome, kbDir,
|
|
3864
|
+
testMode: process.env.RUVNET_BRAIN_SCHEDULER_TEST === '1' }).state === 'on') {
|
|
3582
3865
|
ok('nightly auto-updates are already on — new repos and gists arrive while you sleep');
|
|
3583
3866
|
return 'already-on';
|
|
3584
3867
|
}
|
|
@@ -3590,7 +3873,7 @@ export async function offerNightly() {
|
|
|
3590
3873
|
// nudged past a decision they'd have made differently. Both failures cost trust; only one is loud.
|
|
3591
3874
|
info(`${c.green('Recommended')} — rUv ships constantly, and this is how fixes reach you without you`);
|
|
3592
3875
|
info(`thinking about it. ${c.bold('Entirely your call, though')}, and easy to undo.`);
|
|
3593
|
-
info(`${c.dim('What it sets up:')} a small background job (
|
|
3876
|
+
info(`${c.dim('What it sets up:')} a small background job (${artifact.kind}) that checks each night`);
|
|
3594
3877
|
info(`${c.dim(' ')} and downloads a fresher brain — signature-verified before anything is applied.`);
|
|
3595
3878
|
info(`${c.dim('If you skip:')} nothing changes; update whenever you like with ${c.bold('npx ruvnet-brain --update')}`);
|
|
3596
3879
|
info(`${c.dim('Turn it off:')} ${c.bold('npx ruvnet-brain --disable-nightly')} ${c.dim('(any time, no reinstall)')}`);
|
|
@@ -4401,7 +4684,7 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
|
|
|
4401
4684
|
console.log(` • the brain (embedded source of ${installedRepoCount(cacheDir)} RuvNet repos) at:`);
|
|
4402
4685
|
console.log(` ${c.bold(cacheDir)}`);
|
|
4403
4686
|
console.log(
|
|
4404
|
-
` • the Claude Code plugin ${plugin.wired ? c.green('wired at user scope') : c.yellow('(finish the 2 commands above)')} — search_ruvnet +
|
|
4687
|
+
` • the Claude Code plugin ${plugin.wired ? c.green('wired at user scope') : c.yellow('(finish the 2 commands above)')} — search_ruvnet + skills + commands`,
|
|
4405
4688
|
);
|
|
4406
4689
|
if (codexHost?.host) {
|
|
4407
4690
|
const hostReady = ['added', 'rewritten', 'user-owned'].includes(codexHost.action);
|
|
@@ -4424,14 +4707,15 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
|
|
|
4424
4707
|
console.log(`\n ${c.bold('Where it runs:')} ${c.bold(`everywhere you use ${hostLabel}`)} — CLI and supported editor/desktop surfaces.`);
|
|
4425
4708
|
console.log(` It's ${c.bold('user-level (global)')}: open ANY repo or folder and it's already there. Nothing per project,`);
|
|
4426
4709
|
console.log(` nothing to copy in, nothing to git-ignore. ${c.dim('(Runs locally — it is not active in the claude.ai web app.)')}`);
|
|
4427
|
-
console.log(`\n ${c.bold('How it runs:')} ${c.bold('
|
|
4428
|
-
console.log(`
|
|
4710
|
+
console.log(`\n ${c.bold('How it runs:')} ${c.bold('explicitly')} — use search_ruvnet, a Brain skill, or a proof command when you want`);
|
|
4711
|
+
console.log(` source-grounded RuvNet context. Brain code does not run on host lifecycle events.`);
|
|
4429
4712
|
console.log(`\n ${c.bold('Keep it fresh:')} re-run ${c.bold('npx ruvnet-brain')} any time — the brain itself always pulls the latest`);
|
|
4430
4713
|
console.log(` Release regardless (that part isn't cached). For the bleeding-edge installer too, use ${c.bold('npx github:stuinfla/ruvnet-brain')}.`);
|
|
4431
4714
|
|
|
4432
|
-
//
|
|
4433
|
-
|
|
4434
|
-
console.log(
|
|
4715
|
+
// Hosts cache plugin registries for the lifetime of a session. A restart is therefore required to
|
|
4716
|
+
// unload callbacks from an older hook-bearing generation even though this version registers none.
|
|
4717
|
+
console.log(`\n ${c.yellow(c.bold('One required cleanup step:'))} restart any ${hostLabel} window that was open before this update.`);
|
|
4718
|
+
console.log(` ${c.dim('That unloads the old in-memory hook registry; new sessions load the intentional empty registry.')}`);
|
|
4435
4719
|
|
|
4436
4720
|
// ── what to do now ──
|
|
4437
4721
|
console.log(`\n ${c.bold('What to do now:')}`);
|
|
@@ -4441,19 +4725,19 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
|
|
|
4441
4725
|
|
|
4442
4726
|
// ── how you'll KNOW it's working ──
|
|
4443
4727
|
console.log(`\n ${c.bold('How you\'ll know it\'s working:')}`);
|
|
4444
|
-
console.log(` ${c.green('✓')}
|
|
4445
|
-
console.log(` ${c.green('✓')}
|
|
4446
|
-
console.log(` ${c.green('✓')}
|
|
4728
|
+
console.log(` ${c.green('✓')} ${c.bold('search_ruvnet')} returns cited source paths when invoked.`);
|
|
4729
|
+
console.log(` ${c.green('✓')} Brain skills and slash commands remain available.`);
|
|
4730
|
+
console.log(` ${c.green('✓')} ${c.bold('No Brain hook message or tool-call refusal appears by itself.')}`);
|
|
4447
4731
|
|
|
4448
4732
|
// ── honest expectations ──
|
|
4449
4733
|
console.log(`\n ${c.bold('What to expect (honestly):')}`);
|
|
4450
|
-
console.log(` •
|
|
4451
|
-
console.log(` •
|
|
4734
|
+
console.log(` • Grounding is available on demand; it is no longer injected into every turn.`);
|
|
4735
|
+
console.log(` • Host work proceeds normally unless you explicitly invoke a Brain command or skill.`);
|
|
4452
4736
|
console.log(` • Not sure it's on? Run ${c.bold('npx ruvnet-brain --doctor')} any time for a health check.
|
|
4453
4737
|
• Want to see it answer, live, right now? ${c.bold('npx ruvnet-brain --demo')} — 2 real questions, real cited answers.`);
|
|
4454
4738
|
|
|
4455
|
-
console.log(`\n ${c.bold('Set it up your way:')} this default is ${c.bold('global')} —
|
|
4456
|
-
console.log(`
|
|
4739
|
+
console.log(`\n ${c.bold('Set it up your way:')} this default is ${c.bold('global')} — MCP, skills, and commands are available in every project,`);
|
|
4740
|
+
console.log(` but they run only when invoked. Want it different (project-only, moved, with the build stack`);
|
|
4457
4741
|
console.log(` added)? ${c.bold('Just tell Claude')} once it's on. You never have to learn its internals.`);
|
|
4458
4742
|
|
|
4459
4743
|
if (nightly === 'enabled' || nightly === 'already-on') {
|
|
@@ -4474,7 +4758,7 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
|
|
|
4474
4758
|
console.log(`\n ${c.bold('If it earns it:')} a GitHub star helps other people find the brain —`);
|
|
4475
4759
|
console.log(` ${c.bold('https://github.com/stuinfla/ruvnet-brain')} ${c.dim('· feedback in one command: npx ruvnet-brain --feedback')}`);
|
|
4476
4760
|
|
|
4477
|
-
console.log(`\n ${c.dim('
|
|
4761
|
+
console.log(`\n ${c.dim('The plugin is disable-able and installs no automatic lifecycle handlers.')}`);
|
|
4478
4762
|
console.log('');
|
|
4479
4763
|
}
|
|
4480
4764
|
|
|
@@ -4492,12 +4776,8 @@ Usage:
|
|
|
4492
4776
|
EXITS NON-ZERO when the install is genuinely broken, so it can gate a
|
|
4493
4777
|
script: npx ruvnet-brain --doctor && ./deploy.sh
|
|
4494
4778
|
npx ruvnet-brain --doctor --hooks
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
JSON, empty EOF, 1MB garbage, and stdin held open past budget), with an
|
|
4498
|
-
external process-group watchdog. Asserts each hook's declared exit
|
|
4499
|
-
codes, a 4KB stdout cap, its declared timeout with margin, and zero
|
|
4500
|
-
surviving descendants. Your own hooks are listed, never executed.
|
|
4779
|
+
Backward-compatible, read-only proof that both shipped Brain hook
|
|
4780
|
+
manifests contain zero automatic registrations. Executes no hook body.
|
|
4501
4781
|
npx ruvnet-brain --demo Guided walkthrough — 2 real questions, real cited answers
|
|
4502
4782
|
npx ruvnet-brain --feedback Tell us how it went — prefills a GitHub Discussion with your brain
|
|
4503
4783
|
version, platform, and a 3-line health summary (you see exactly
|
|
@@ -4505,7 +4785,7 @@ Usage:
|
|
|
4505
4785
|
npx ruvnet-brain --update One-shot: pull the latest Release bundle into your installed brain
|
|
4506
4786
|
(runs the bundle's own forge-update.mjs --apply: backup + re-verify)
|
|
4507
4787
|
npx ruvnet-brain --enable-nightly Schedule that update nightly at 03:47 — macOS LaunchAgent;
|
|
4508
|
-
|
|
4788
|
+
Linux uses cron; Windows uses Task Scheduler. OFF by default.
|
|
4509
4789
|
npx ruvnet-brain --disable-nightly Remove the nightly schedule (safe to run any time)
|
|
4510
4790
|
npx ruvnet-brain --what-changed Show exactly what RuvNet Brain has put on this machine,
|
|
4511
4791
|
with the undo command for each piece
|
|
@@ -4543,7 +4823,8 @@ Env:
|
|
|
4543
4823
|
download or an air-gapped machine is not a broken install). Only ever set
|
|
4544
4824
|
this for a locked-down environment where you want to know immediately.
|
|
4545
4825
|
|
|
4546
|
-
It is safe to re-run at any time. After
|
|
4826
|
+
It is safe to re-run at any time. After updating from a hook-bearing version, restart open hosts so
|
|
4827
|
+
their old in-memory hook registries unload.
|
|
4547
4828
|
`);
|
|
4548
4829
|
}
|
|
4549
4830
|
|
|
@@ -4733,6 +5014,7 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
|
|
|
4733
5014
|
`The knowledge base is present, but /rvbc would be broken. Re-run the installer from a complete package.`,
|
|
4734
5015
|
);
|
|
4735
5016
|
}
|
|
5017
|
+
retireManagedHookRegistrations();
|
|
4736
5018
|
const plugin = wirePlugin();
|
|
4737
5019
|
// Codex hosts got nothing before this (issue #42): shipped, never registered. Non-fatal like every
|
|
4738
5020
|
// other wiring step — a second host we cannot reach must never break the one we can.
|
|
@@ -4839,16 +5121,14 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
|
|
|
4839
5121
|
//
|
|
4840
5122
|
// "Never block a healthy install" is honoured literally: a healthy install still exits 0 and
|
|
4841
5123
|
// nothing above this point is undone. What changes is that a genuinely broken install can no
|
|
4842
|
-
// longer masquerade as a successful one to a script.
|
|
4843
|
-
// plugins are enumerated and reported but NEVER charged against them — only registrations this
|
|
4844
|
-
// package ships can make this fail.
|
|
5124
|
+
// longer masquerade as a successful one to a script. No Brain automatic hook is executed.
|
|
4845
5125
|
if (!FLAG_NO_SELFCHECK) {
|
|
4846
5126
|
const selfcheck = await runSelfCheck({
|
|
4847
5127
|
installState: verified ? { repos: verified.repos, reader: verified.reader, mcp: verified.mcp } : null,
|
|
4848
5128
|
quiet: true,
|
|
4849
5129
|
});
|
|
4850
5130
|
if (selfcheck.exitCode !== 0) {
|
|
4851
|
-
console.log(`\n ${c.dim(`Re-run ${c.bold('npx ruvnet-brain --doctor
|
|
5131
|
+
console.log(`\n ${c.dim(`Re-run ${c.bold('npx ruvnet-brain --doctor')} after fixing, to re-check.`)}`);
|
|
4852
5132
|
process.exitCode = selfcheck.exitCode;
|
|
4853
5133
|
}
|
|
4854
5134
|
}
|