ruvnet-brain 4.3.20 → 4.3.25
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 +5 -5
- package/bin/install.mjs +383 -78
- package/console/app.js +141 -9
- package/console/index.html +51 -24
- package/console/scope.css +137 -0
- package/console/scope.html +144 -0
- package/console/scope.js +209 -0
- package/console/tips.html +1 -0
- package/kb/corpus-release-identity.mjs +239 -0
- package/kb/update-storage-transaction.mjs +20 -3
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +61 -0
- package/plugin/hooks/codex-hooks.json +64 -1
- package/plugin/hooks/hook-contracts.json +299 -6
- package/plugin/hooks/hooks.json +81 -1
- package/plugin/mcp/server.mjs +23 -0
- package/plugin/scripts/advocacy-catalog.mjs +245 -0
- package/plugin/scripts/advocacy-route.mjs +460 -0
- package/plugin/scripts/continuation-gate.mjs +25 -2
- package/plugin/scripts/continuation-objective.mjs +7 -1
- package/plugin/scripts/continuity-hook-policy.mjs +190 -15
- package/plugin/scripts/coverage-integrity.mjs +7 -0
- package/plugin/scripts/gates.mjs +113 -10
- package/plugin/scripts/grounding-turn-gate.mjs +167 -0
- package/plugin/scripts/grounding-turn-mark.mjs +91 -0
- package/plugin/scripts/hook-shim.mjs +14 -0
- package/plugin/scripts/host-shell-boundary.mjs +43 -0
- package/plugin/scripts/nightly-scheduler.mjs +37 -4
- package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
- package/plugin/scripts/project-progression-contract.mjs +16 -0
- package/plugin/scripts/project-progression-hook.mjs +3 -0
- package/plugin/scripts/project-progression-producer.mjs +252 -0
- package/plugin/scripts/project-progression-reader.mjs +271 -0
- package/plugin/scripts/project-progression-session-start.mjs +93 -16
- package/plugin/scripts/project-progression-sources.mjs +220 -0
- package/plugin/scripts/project-progression-store.mjs +106 -13
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
- package/plugin/scripts/session-snapshot-hook.mjs +115 -7
- package/plugin/scripts/session-start-budget.mjs +59 -0
- package/plugin/scripts/session-start-core.mjs +234 -457
- package/plugin/scripts/session-start-fsutil.mjs +61 -0
- package/plugin/scripts/session-start-health.mjs +64 -0
- package/plugin/scripts/session-start-hook-description.mjs +45 -0
- package/plugin/scripts/session-start-issue-alert.mjs +77 -0
- package/plugin/scripts/session-start-repo-identity.mjs +54 -0
- package/plugin/scripts/session-start-signals.mjs +73 -0
- package/plugin/scripts/session-start-trace.mjs +86 -0
- package/plugin/scripts/session-start-update-plane.mjs +104 -0
- package/plugin/scripts/unprompted-runtime.mjs +32 -2
- package/plugin/scripts/update-apply.mjs +2 -32
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
- package/scripts/adr-072-completion.mjs +1 -1
- package/scripts/agentdb-fleet-doctor.mjs +5 -1
- package/scripts/approved-runtime.mjs +197 -0
- package/scripts/brain-novice-50.mjs +16 -1
- package/scripts/brain-score.mjs +23 -5
- package/scripts/build-bundle.mjs +971 -530
- package/scripts/build-concepts.mjs +36 -116
- package/scripts/console-engine.test.mjs +8 -7
- package/scripts/console-runtime-identity.mjs +4 -0
- package/scripts/corpus-aggregates.mjs +94 -77
- package/scripts/corpus-candidate.mjs +475 -222
- package/scripts/corpus-next-seed.mjs +225 -0
- package/scripts/corpus-promotion.mjs +58 -0
- package/scripts/corpus-reconcile.mjs +411 -105
- package/scripts/doc-currency.mjs +16 -1
- package/scripts/dual-host-deliberation.mjs +25 -2
- package/scripts/dual-host-suggest.mjs +17 -1
- package/scripts/falsify.mjs +13 -3
- package/scripts/gist-receipts.mjs +482 -87
- package/scripts/github-health-watch.mjs +12 -2
- package/scripts/handoff-asset.mjs +34 -0
- package/scripts/hook-retirement-check.mjs +8 -1
- package/scripts/host-registry.mjs +1 -1
- package/scripts/ingest-gists.mjs +74 -101
- package/scripts/job-heartbeat.sh +77 -14
- package/scripts/learning-replay-execution.mjs +10 -4
- package/scripts/nightly-gists.sh +27 -13
- package/scripts/nightly-two-run-proof.mjs +1 -1
- package/scripts/nightly-watchdog.mjs +61 -4
- package/scripts/onboarding-console.mjs +319 -27
- package/scripts/oracle/produce-questions.mjs +293 -0
- package/scripts/oracle/producer-hosts.mjs +235 -0
- package/scripts/oracle/repo-recall.mjs +448 -0
- package/scripts/oracle/retrieval-accuracy.mjs +818 -0
- package/scripts/oracle/source-tree.mjs +165 -0
- package/scripts/oracle/source-units.mjs +391 -0
- package/scripts/oracle/spike-run.mjs +98 -0
- package/scripts/oracle/unit-inventory.mjs +141 -0
- package/scripts/oracle/unit-sampling.mjs +128 -0
- package/scripts/oracle/validate-labels.mjs +250 -0
- package/scripts/private-overlay.mjs +248 -0
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/proxy/claude-proxied.sh +6 -0
- package/scripts/proxy/proxy-revert.sh +5 -0
- package/scripts/proxy/proxy-up.sh +6 -0
- package/scripts/proxy/proxy-verify.mjs +4 -0
- package/scripts/public-inputs.mjs +409 -0
- package/scripts/public-verification-inputs.mjs +112 -26
- package/scripts/public-verification-lane.mjs +1 -1
- package/scripts/published-surface-probe.mjs +34 -4
- package/scripts/qe/card-lane-gate.mjs +16 -1
- package/scripts/qe/session-start-gate.mjs +16 -1
- package/scripts/rebuild-gists-from-receipts.mjs +58 -78
- package/scripts/record-lesson.mjs +4 -1
- package/scripts/rehearse-corpus-pipeline.mjs +994 -0
- package/scripts/release-abort-stale.mjs +5 -1
- package/scripts/release-authority.mjs +104 -12
- package/scripts/release-channel-kind.mjs +86 -0
- package/scripts/release-convergence-watchdog.mjs +7 -2
- package/scripts/release-projection.mjs +177 -72
- package/scripts/release-transaction-provider.mjs +23 -6
- package/scripts/release.mjs +252 -17
- package/scripts/retrieval-canary.mjs +87 -0
- package/scripts/rvf-index-audit.mjs +573 -13
- package/scripts/rvf-wire.mjs +269 -0
- package/scripts/seal-gist-receipt.mjs +65 -0
- package/scripts/selfcheck.mjs +42 -21
- package/scripts/source-coverage.mjs +253 -24
- package/scripts/status-honesty.mjs +25 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-version.mjs +2 -0
- package/scripts/trismart.mjs +42 -0
- package/scripts/updater-manifest.mjs +162 -0
- package/scripts/verify-channels.mjs +17 -5
- package/scripts/wired-check.mjs +48 -10
- package/tri-smart-skill/QUICKSTART.md +37 -0
- package/tri-smart-skill/README.md +92 -0
- package/tri-smart-skill/install.cmd +14 -0
- package/tri-smart-skill/install.command +13 -0
- package/tri-smart-skill/install.mjs +51 -0
- package/tri-smart-skill/install.sh +9 -0
- package/tri-smart-skill/tri-smart/SKILL.md +90 -0
- package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
- package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
- package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
- package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
- package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
- package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
- package/scripts/corpus-seed-publish.mjs +0 -110
package/bin/install.mjs
CHANGED
|
@@ -32,8 +32,9 @@ import { applyManagedCatalogUpdate } from '../scripts/model-router-catalog.mjs';
|
|
|
32
32
|
import { cmpVersion } from '../scripts/stack-sync.mjs';
|
|
33
33
|
import { validateCoverageDirectory } from '../plugin/scripts/coverage-integrity.mjs';
|
|
34
34
|
import {
|
|
35
|
-
CONTINUITY_EVENTS,
|
|
36
35
|
continuityContractIds,
|
|
36
|
+
continuityHookId,
|
|
37
|
+
continuityRegistrations,
|
|
37
38
|
isAllowedContinuityRegistration,
|
|
38
39
|
} from '../plugin/scripts/continuity-hook-policy.mjs';
|
|
39
40
|
import {
|
|
@@ -67,6 +68,10 @@ const versionSatisfies = (installed, expected) => {
|
|
|
67
68
|
import {
|
|
68
69
|
CONSOLE_RUNTIME_SURFACE, CONSOLE_RUNTIME_IDENTITY_FILE, consoleRuntimeDigest,
|
|
69
70
|
} from '../scripts/console-runtime-identity.mjs';
|
|
71
|
+
import { shellDiff as pluginShellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
|
|
72
|
+
import {
|
|
73
|
+
writeInstalledRuntimeIdentity, recordCorpusTransportIdentity, isCorpusReleaseTag, rejectedReleasePath,
|
|
74
|
+
} from '../kb/corpus-release-identity.mjs';
|
|
70
75
|
|
|
71
76
|
// SEC-0010 #6 — the Ed25519 PUBLIC key is EMBEDDED here (not a separate file) so the installer's
|
|
72
77
|
// trust root travels with the installer code itself: an attacker who swaps the downloaded bundle
|
|
@@ -317,6 +322,32 @@ function fetchJson(url, redirects = 0) {
|
|
|
317
322
|
// --version <tag> forces a tag; --pin skips the network check and uses the bundled known-good tag.
|
|
318
323
|
// Any failure (offline / rate-limited / no releases) FALLS BACK to the pinned known-good Release,
|
|
319
324
|
// narrated clearly so the user knows exactly what happened.
|
|
325
|
+
/**
|
|
326
|
+
* Which asset of a Release actually holds the brain bundle.
|
|
327
|
+
*
|
|
328
|
+
* THE UNAMBIGUOUS SINGLE ZIP — the lesson kb/forge-update.mjs's resolveBundleUrl() already learned
|
|
329
|
+
* (issue #35) and this installer had not. Matching ONLY the conventional `ruvnet-brain.zip` means a
|
|
330
|
+
* release whose bundle asset is named anything else falls through to `fallbackUrl(tag)`, a URL
|
|
331
|
+
* assembled from that same conventional name — so the "fallback" is a guaranteed 404, not a
|
|
332
|
+
* download. The path that reaches it is a FRESH install: the one case with no brain already on disk
|
|
333
|
+
* to keep working. ADR-086 step 16 makes this reachable in practice, because `releases/latest` can
|
|
334
|
+
* now be a corpus release.
|
|
335
|
+
*
|
|
336
|
+
* A release carrying exactly one .zip is not ambiguous about which zip is the bundle. Two or more
|
|
337
|
+
* and guessing would be worse than the honest warning, so it falls through as before.
|
|
338
|
+
*
|
|
339
|
+
* Pure and exported so the choice is testable without a network round-trip.
|
|
340
|
+
* @returns {{url: string, origin: 'exact-name'|'single-zip'|'conventional-url', assetName: string|null}}
|
|
341
|
+
*/
|
|
342
|
+
export function resolveReleaseAsset({ tag, assets, assetName = ASSET_NAME }) {
|
|
343
|
+
const list = Array.isArray(assets) ? assets : [];
|
|
344
|
+
const exact = list.find((a) => a && a.name === assetName && a.browser_download_url);
|
|
345
|
+
if (exact) return { url: exact.browser_download_url, origin: 'exact-name', assetName: exact.name };
|
|
346
|
+
const zips = list.filter((a) => a && typeof a.name === 'string' && a.name.endsWith('.zip') && a.browser_download_url);
|
|
347
|
+
if (zips.length === 1) return { url: zips[0].browser_download_url, origin: 'single-zip', assetName: zips[0].name };
|
|
348
|
+
return { url: fallbackUrl(tag), origin: 'conventional-url', assetName: null };
|
|
349
|
+
}
|
|
350
|
+
|
|
320
351
|
async function resolveRelease() {
|
|
321
352
|
step(
|
|
322
353
|
'Finding the latest brain to install',
|
|
@@ -346,9 +377,10 @@ async function resolveRelease() {
|
|
|
346
377
|
const rel = await fetchJson(RELEASE_API);
|
|
347
378
|
const tag = rel && rel.tag_name;
|
|
348
379
|
if (!tag) throw new Error('latest Release has no tag_name');
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
380
|
+
const { url, origin, assetName } = resolveReleaseAsset({ tag, assets: rel.assets });
|
|
381
|
+
if (origin === 'single-zip') {
|
|
382
|
+
warn(`latest Release ${tag} has no ${ASSET_NAME} — using its only .zip asset, ${c.bold(assetName)}`);
|
|
383
|
+
} else if (origin === 'conventional-url') {
|
|
352
384
|
warn(`latest Release ${tag} has no ${ASSET_NAME} asset listed — using the conventional download URL`);
|
|
353
385
|
}
|
|
354
386
|
ok(`latest Release is ${c.bold(tag)}`);
|
|
@@ -484,7 +516,51 @@ export function copyLocalBundleInto(sourceDir, cacheDir) {
|
|
|
484
516
|
return copied;
|
|
485
517
|
}
|
|
486
518
|
|
|
487
|
-
|
|
519
|
+
// ── the trusted coverage validator lives beside the updater, and only the INSTALLER puts it there ──
|
|
520
|
+
// kb/forge-update.mjs (`loadTrustedCoverageValidator`) judges every downloaded bundle with
|
|
521
|
+
// `KB_DIR/coverage-integrity.mjs` and dies — "installed coverage validator is missing; re-run the
|
|
522
|
+
// current installer before self-update" — when it is absent. The bundle cannot be the source: the
|
|
523
|
+
// validator vets the bundle, so it must come from the signed npm package. Measured 2026-09-12: no
|
|
524
|
+
// production path had ever placed it (build-bundle's import walk cannot see the updater's dynamic
|
|
525
|
+
// load; this installer imported the module for its own checks and never copied it), so every
|
|
526
|
+
// 4.3.21 `--update` — the nightly included — died in the updater and fell back to a fresh install,
|
|
527
|
+
// which a private-overlay brain refuses. Idempotent, byte-compared, atomic; a symlink is replaced by
|
|
528
|
+
// a real file because a link is not a trusted regular file.
|
|
529
|
+
const TRUSTED_VALIDATOR_SOURCE = path.join(REPO_ROOT, 'plugin', 'scripts', 'coverage-integrity.mjs');
|
|
530
|
+
export function placeTrustedCoverageValidator(kbDir, { source = TRUSTED_VALIDATOR_SOURCE,
|
|
531
|
+
brainVersion = PACKAGE_VERSION } = {}) {
|
|
532
|
+
let bytes;
|
|
533
|
+
try { bytes = fs.readFileSync(source); }
|
|
534
|
+
catch (error) { throw new Error(`trusted coverage validator is missing from this package (${source}): ${error.message}`); }
|
|
535
|
+
const target = path.join(kbDir, 'coverage-integrity.mjs');
|
|
536
|
+
let existing = null;
|
|
537
|
+
try { existing = fs.lstatSync(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
538
|
+
const unchanged = existing && existing.isFile() && !existing.isSymbolicLink() && fs.readFileSync(target).equals(bytes);
|
|
539
|
+
if (!unchanged) {
|
|
540
|
+
const staged = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
541
|
+
fs.writeFileSync(staged, bytes, { mode: 0o644 });
|
|
542
|
+
if (existing && existing.isSymbolicLink()) fs.unlinkSync(target); // never write through a link
|
|
543
|
+
fs.renameSync(staged, target);
|
|
544
|
+
}
|
|
545
|
+
// STAMP THE APPROVED RUNTIME IN THE SAME BREATH AS PLACING ITS EXECUTABLES (ADR-086 step 16).
|
|
546
|
+
//
|
|
547
|
+
// This is the one moment where "which runtime is this brain running" is a measured fact rather
|
|
548
|
+
// than an assertion: the bytes were just written from THIS package, so the version and the hashes
|
|
549
|
+
// are recorded together and cannot drift. kb/forge-update.mjs re-hashes them before it will accept
|
|
550
|
+
// a corpus release — Dual: "Pinning survives only through enforced equality to the approved
|
|
551
|
+
// shipped runtime and its executable hashes. Copying current-main package.json or preserving a
|
|
552
|
+
// version string alone is insufficient." Re-stamped on every placement (install AND `--update`
|
|
553
|
+
// preflight), so an upgraded runtime immediately supersedes the previous pin.
|
|
554
|
+
const runtime = writeInstalledRuntimeIdentity(kbDir, { brainVersion });
|
|
555
|
+
return { action: unchanged ? 'unchanged' : (existing ? 'replaced' : 'placed'), path: target, runtimeIdentity: runtime };
|
|
556
|
+
}
|
|
557
|
+
/** `--update` preflight: place the validator only where an updater exists to consume it. */
|
|
558
|
+
export function ensureUpdaterPrerequisites(kbDir) {
|
|
559
|
+
if (!fs.existsSync(path.join(kbDir, 'forge-update.mjs'))) return { updater: false, validator: null };
|
|
560
|
+
return { updater: true, validator: placeTrustedCoverageValidator(kbDir) };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTag = null } = {}) {
|
|
488
564
|
step(
|
|
489
565
|
'Unpacking the brain into place',
|
|
490
566
|
'so the plugin finds forge-mcp-all.mjs and the vector stores right where it looks',
|
|
@@ -565,6 +641,25 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null) {
|
|
|
565
641
|
`The archive layout may have changed. Re-run, or report this at https://github.com/stuinfla/ruvnet-brain/issues`,
|
|
566
642
|
);
|
|
567
643
|
}
|
|
644
|
+
// A freshly installed brain must be able to self-update on its first night: the bundle never
|
|
645
|
+
// carries the trusted validator its own updater demands, so the installer lays it into the stage.
|
|
646
|
+
placeTrustedCoverageValidator(stageDir);
|
|
647
|
+
// THE TRANSPORT IDENTITY LANDS WITH THE BYTES. When `releases/latest` is a corpus release, the
|
|
648
|
+
// tag that authenticated these bytes (`corpus-sha256-<64 hex>`) is recorded in the STAGE, so the
|
|
649
|
+
// single rename below promotes the tree and its provenance together or promotes neither. Writing
|
|
650
|
+
// it after activation would leave a window where a crash yields an installed corpus this brain
|
|
651
|
+
// cannot name — and an unnamed corpus is a corpus the updater re-downloads every night.
|
|
652
|
+
// `brainVersion` / `releaseTag` are untouched: they are the runtime the bundle was built by, and
|
|
653
|
+
// a content address is not a version of that runtime (ADR-086 step 16, "keep runtime version
|
|
654
|
+
// distinct"). A non-corpus tag CLEARS any stale corpus tag — see recordCorpusTransportIdentity.
|
|
655
|
+
if (releaseTag) {
|
|
656
|
+
try { recordCorpusTransportIdentity(stageDir, { releaseTag }); }
|
|
657
|
+
catch (error) {
|
|
658
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
659
|
+
die(`could not record the release identity into the staged brain (${error.message})`,
|
|
660
|
+
'The live brain was not touched. Re-run the installer.');
|
|
661
|
+
}
|
|
662
|
+
}
|
|
568
663
|
const stagedCoverage = validateCoverageDirectory(stageDir, { expectedVersion: PACKAGE_VERSION });
|
|
569
664
|
if (!stagedCoverage.valid) {
|
|
570
665
|
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
@@ -646,7 +741,8 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null) {
|
|
|
646
741
|
`Candidate retained for inspection at ${stageDir}.`);
|
|
647
742
|
}
|
|
648
743
|
if (hadPrior) warn(`PRESERVED_UNCLASSIFIED: prior generation retained at ${preservedDir}. ` +
|
|
649
|
-
'
|
|
744
|
+
'The updater (kb/forge-update.mjs) releases it only once every byte is proven to survive in the live brain; ' +
|
|
745
|
+
'until then it stays, and repeated installs can grow disk usage.');
|
|
650
746
|
ok(`brain unpacked to ${cacheDir}`);
|
|
651
747
|
return { status: 'ACTIVATED', priorGeneration: hadPrior
|
|
652
748
|
? { status: 'PRESERVED_UNCLASSIFIED', path: preservedDir, automaticCleanupEligible: false }
|
|
@@ -1182,6 +1278,55 @@ export function claudePluginStatus({ home = os.homedir() } = {}) {
|
|
|
1182
1278
|
}
|
|
1183
1279
|
}
|
|
1184
1280
|
|
|
1281
|
+
// A plugin version can change without changing anything a running host freezes at boot. Compare
|
|
1282
|
+
// the actual installed payload with this candidate before updating it so body-only releases do not
|
|
1283
|
+
// manufacture a restart requirement. Unknown paths fail closed: if we cannot prove the boundary,
|
|
1284
|
+
// the caller must retain the restart notice rather than silently promise hot loading.
|
|
1285
|
+
function inspectPluginShellBoundary(installedRoot, candidateRoot = path.join(REPO_ROOT, 'plugin')) {
|
|
1286
|
+
if (!installedRoot || !candidateRoot) {
|
|
1287
|
+
return { known: false, changed: true, paths: [], restartRequired: true, reason: 'plugin boot surface could not be located' };
|
|
1288
|
+
}
|
|
1289
|
+
try {
|
|
1290
|
+
const paths = pluginShellDiff(installedRoot, candidateRoot);
|
|
1291
|
+
return {
|
|
1292
|
+
known: true,
|
|
1293
|
+
changed: paths.length > 0,
|
|
1294
|
+
paths,
|
|
1295
|
+
restartRequired: paths.length > 0,
|
|
1296
|
+
reason: paths.length ? `boot-level declarations changed: ${paths.join(', ')}` : 'body-only plugin update',
|
|
1297
|
+
};
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
return { known: false, changed: true, paths: [], restartRequired: true,
|
|
1300
|
+
reason: `plugin boot surface comparison failed: ${error.message}` };
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function codexInstalledPluginRoot({ codexHome = codexHomeDir(), status } = {}) {
|
|
1305
|
+
const row = status?.row || {};
|
|
1306
|
+
for (const value of [status?.installPath, status?.path, row.installPath, row.path]) {
|
|
1307
|
+
if (typeof value === 'string' && value) {
|
|
1308
|
+
try {
|
|
1309
|
+
const real = fs.realpathSync(value);
|
|
1310
|
+
if (fs.statSync(real).isDirectory()) return real;
|
|
1311
|
+
} catch { /* try the cache scan below */ }
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
const cacheRoot = path.join(codexHome, 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain');
|
|
1315
|
+
let candidates = [];
|
|
1316
|
+
try {
|
|
1317
|
+
candidates = fs.readdirSync(cacheRoot, { withFileTypes: true })
|
|
1318
|
+
.filter((entry) => entry.isDirectory())
|
|
1319
|
+
.map((entry) => path.join(cacheRoot, entry.name))
|
|
1320
|
+
.filter((dir) => {
|
|
1321
|
+
try {
|
|
1322
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, '.codex-plugin', 'plugin.json'), 'utf8'));
|
|
1323
|
+
return manifest?.name === 'ruvnet-brain' && (!status?.version || manifest.version === status.version);
|
|
1324
|
+
} catch { return false; }
|
|
1325
|
+
});
|
|
1326
|
+
} catch { /* absent cache is handled as unknown below */ }
|
|
1327
|
+
return candidates.sort().at(-1) || null;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1185
1330
|
function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false } = {}) {
|
|
1186
1331
|
step(
|
|
1187
1332
|
'Wiring the Claude Code plugin',
|
|
@@ -1204,6 +1349,9 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1204
1349
|
|
|
1205
1350
|
const before = claudePluginStatus();
|
|
1206
1351
|
if (requireManaged && !before.managed) return { host: false, wired: false, action: 'unmanaged' };
|
|
1352
|
+
const shellBoundary = before.installed
|
|
1353
|
+
? inspectPluginShellBoundary(before.installPath)
|
|
1354
|
+
: { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
|
|
1207
1355
|
const addedMarket = before.managed
|
|
1208
1356
|
? tryRun('claude', ['plugin', 'marketplace', 'update', 'ruvnet-brain'])
|
|
1209
1357
|
: tryRun('claude', ['plugin', 'marketplace', 'add', marketplaceSource]);
|
|
@@ -1224,8 +1372,18 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1224
1372
|
const installedHookRetirement = claudeInstalledHookRetirementStatus({ plugin: installed });
|
|
1225
1373
|
if (installed.installed && versionSatisfies(installed.version, expectedVersion) && installedHookRetirement.ok) {
|
|
1226
1374
|
ok(`plugin installed at user scope (global, alongside Ruflo / RuVector) — exact version ${installed.version}`);
|
|
1227
|
-
|
|
1228
|
-
|
|
1375
|
+
if (shellBoundary.restartRequired) {
|
|
1376
|
+
warn(`boot-level plugin declarations changed; restart Claude Code once to load them (${shellBoundary.paths.join(', ') || shellBoundary.reason}).`);
|
|
1377
|
+
} else if (before.installed && before.version !== installed.version) {
|
|
1378
|
+
info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
|
|
1379
|
+
}
|
|
1380
|
+
info(` commands available${shellBoundary.restartRequired ? ' after a restart' : ' immediately'}: ${c.bold('/rvbc')}, ${c.bold('/ruvnet-brain:configure')}`);
|
|
1381
|
+
return {
|
|
1382
|
+
host: true, wired: true, version: installed.version, manualMarketplace, manualInstall,
|
|
1383
|
+
shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
|
|
1384
|
+
restartRequired: shellBoundary.restartRequired,
|
|
1385
|
+
...(shellBoundary.restartRequired ? { sessionSafety: 'restart-required', sessionSafetyReason: shellBoundary.reason } : {}),
|
|
1386
|
+
};
|
|
1229
1387
|
}
|
|
1230
1388
|
|
|
1231
1389
|
// The honest failure. The brain still WORKS — this is the difference between a broken install and
|
|
@@ -1654,6 +1812,7 @@ export function codexPluginStatus(options = {}) {
|
|
|
1654
1812
|
installed: Boolean(row?.installed),
|
|
1655
1813
|
enabled: Boolean(row?.enabled),
|
|
1656
1814
|
version: row?.version || null,
|
|
1815
|
+
installPath: row?.installPath || row?.path || null,
|
|
1657
1816
|
row: row || null,
|
|
1658
1817
|
};
|
|
1659
1818
|
}
|
|
@@ -1691,9 +1850,22 @@ export function wireCodexPlugin({
|
|
|
1691
1850
|
if (announce) warn(`Codex Brain plugin is installed but disabled by user or policy — left disabled (${CODEX_PLUGIN_ID}).`);
|
|
1692
1851
|
return { host: true, action: 'disabled', ...before };
|
|
1693
1852
|
}
|
|
1853
|
+
// An existing Codex session can keep the plugin generation it loaded at boot. Compare the
|
|
1854
|
+
// installed bytes with the source candidate before mutating the marketplace so body-only updates
|
|
1855
|
+
// remain live while a changed/unknown boot surface gets one explicit restart request.
|
|
1856
|
+
const shellBoundary = before.installed
|
|
1857
|
+
? inspectPluginShellBoundary(
|
|
1858
|
+
codexInstalledPluginRoot({ codexHome, status: before }),
|
|
1859
|
+
path.join(REPO_ROOT, 'plugin'),
|
|
1860
|
+
)
|
|
1861
|
+
: { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
|
|
1694
1862
|
if (before.installed && before.enabled && versionSatisfies(before.version, expectedVersion)) {
|
|
1695
1863
|
if (announce) ok(`Codex Brain plugin already installed and enabled (${before.version || 'version unknown'}) — no changes.`);
|
|
1696
|
-
return {
|
|
1864
|
+
return {
|
|
1865
|
+
host: true, action: 'unchanged', ...before,
|
|
1866
|
+
shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
|
|
1867
|
+
restartRequired: false,
|
|
1868
|
+
};
|
|
1697
1869
|
}
|
|
1698
1870
|
|
|
1699
1871
|
if (runJson === runCodexJson && !localMarketplace) {
|
|
@@ -1735,17 +1907,23 @@ export function wireCodexPlugin({
|
|
|
1735
1907
|
}
|
|
1736
1908
|
if (announce) {
|
|
1737
1909
|
ok(`Codex Brain plugin installed and enabled (${after.version || 'version unknown'}).`);
|
|
1738
|
-
|
|
1910
|
+
if (shellBoundary.restartRequired) {
|
|
1911
|
+
warn(`boot-level plugin declarations changed; restart Codex once to load them (${shellBoundary.paths.join(', ') || shellBoundary.reason}).`);
|
|
1912
|
+
} else if (before.installed && before.version !== after.version) {
|
|
1913
|
+
info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
|
|
1914
|
+
}
|
|
1739
1915
|
}
|
|
1740
1916
|
return {
|
|
1741
1917
|
host: true,
|
|
1742
1918
|
action: before.installed ? 'updated' : 'installed',
|
|
1743
1919
|
...after,
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
restartRequired
|
|
1748
|
-
|
|
1920
|
+
shellChanged: shellBoundary.changed,
|
|
1921
|
+
shellChangedPaths: shellBoundary.paths,
|
|
1922
|
+
restartRequired: shellBoundary.restartRequired,
|
|
1923
|
+
...(shellBoundary.restartRequired ? {
|
|
1924
|
+
sessionSafety: 'restart-required',
|
|
1925
|
+
sessionSafetyReason: shellBoundary.reason,
|
|
1926
|
+
} : {}),
|
|
1749
1927
|
};
|
|
1750
1928
|
}
|
|
1751
1929
|
|
|
@@ -1818,14 +1996,25 @@ export function classifyCodexLifecycle(plugin, listed = null) {
|
|
|
1818
1996
|
if (!plugin.enabled) return { state: 'disabled', plugin, hooks: [] };
|
|
1819
1997
|
if (!listed.ok) return { state: 'probe-failed', plugin, hooks: [], error: listed.error };
|
|
1820
1998
|
const groups = Array.isArray(listed.value?.data) ? listed.value.data : [];
|
|
1821
|
-
const hooks = groups.flatMap((group) => Array.isArray(group?.hooks) ? group.hooks : [])
|
|
1999
|
+
const hooks = groups.flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : [])
|
|
2000
|
+
.map((hook) => ({ ...hook, event: hook?.event ?? group?.event ?? null })))
|
|
1822
2001
|
.filter((hook) => hook?.pluginId === CODEX_PLUGIN_ID);
|
|
1823
2002
|
const errors = groups.flatMap((group) => Array.isArray(group?.errors) ? group.errors : []);
|
|
1824
2003
|
if (errors.length) {
|
|
1825
2004
|
return { state: 'missing-runtime-hooks', plugin, hooks, errors };
|
|
1826
2005
|
}
|
|
1827
|
-
|
|
1828
|
-
|
|
2006
|
+
// A REGISTERED CONTINUITY HOOK IS NOT A RETIRED ONE.
|
|
2007
|
+
//
|
|
2008
|
+
// This used to treat EVERY Brain-owned runtime registration as stale, because at the time the
|
|
2009
|
+
// policy permitted none on Codex. The doctor therefore reported the SessionStart restore and the
|
|
2010
|
+
// Stop continuation gate — the two handlers the policy itself requires — as "retired Brain
|
|
2011
|
+
// lifecycle hooks", told the user to upgrade, and returned a failing exit code for a correctly
|
|
2012
|
+
// wired machine. The authority on what belongs is the policy, so ask it instead of assuming zero.
|
|
2013
|
+
const conforming = hooks.filter((hook) => continuityHookId(hook?.command, hook?.event));
|
|
2014
|
+
const unexpected = hooks.filter((hook) => !continuityHookId(hook?.command, hook?.event));
|
|
2015
|
+
if (unexpected.length) return { state: 'unexpected-runtime-hooks', plugin, hooks: unexpected, errors };
|
|
2016
|
+
if (conforming.length === 0) return { state: 'inactive-by-design', plugin, hooks, errors };
|
|
2017
|
+
return { state: 'continuity-registered', plugin, hooks: conforming, errors };
|
|
1829
2018
|
}
|
|
1830
2019
|
|
|
1831
2020
|
export async function codexLifecycleStatus(options = {}) {
|
|
@@ -1847,6 +2036,16 @@ export function codexLifecycleGuidance(status) {
|
|
|
1847
2036
|
detail: 'Any Brain-owned runtime registration is stale and must not be trusted or executed.',
|
|
1848
2037
|
action: `Upgrade ${CODEX_PLUGIN_ID}, then start a fresh Codex session and re-run --doctor.`,
|
|
1849
2038
|
};
|
|
2039
|
+
case 'continuity-registered':
|
|
2040
|
+
return {
|
|
2041
|
+
healthy: true,
|
|
2042
|
+
intentional: true,
|
|
2043
|
+
summary: `Codex carries ${hookCount} declared Brain continuity hook${hookCount === 1 ? '' : 's'}.`,
|
|
2044
|
+
detail: 'Each one is named in plugin/hooks/hook-contracts.json. Codex capture is SessionEnd only:'
|
|
2045
|
+
+ ' a 2026-09-11 probe observed SessionStart, UserPromptSubmit and SessionEnd firing, and did not'
|
|
2046
|
+
+ ' observe Stop or PreCompact, so no capture handler was registered on those.',
|
|
2047
|
+
action: null,
|
|
2048
|
+
};
|
|
1850
2049
|
case 'inactive-by-design':
|
|
1851
2050
|
return {
|
|
1852
2051
|
healthy: true,
|
|
@@ -1971,22 +2170,31 @@ export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'sourc
|
|
|
1971
2170
|
// Project-local host settings must remain empty. The package registries may carry only
|
|
1972
2171
|
// the two continuity handlers; every former gate is still retired.
|
|
1973
2172
|
const packageRegistry = relative === 'plugin/hooks/hooks.json' || relative === 'plugin/hooks/codex-hooks.json';
|
|
1974
|
-
|
|
2173
|
+
const host = relative === 'plugin/hooks/codex-hooks.json' ? 'codex' : 'claude';
|
|
2174
|
+
if (!packageRegistry || !isAllowedContinuityRegistration({ ...row, host })) registrations.push(row);
|
|
1975
2175
|
}
|
|
1976
2176
|
}
|
|
1977
2177
|
}
|
|
1978
2178
|
if (relative === 'plugin/hooks/hooks.json' || relative === 'plugin/hooks/codex-hooks.json') {
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
2179
|
+
// PAIRS, NOT IDS. `session-snapshot` is legitimately registered at Stop, PreCompact and
|
|
2180
|
+
// SessionEnd, so "exactly one registration per id" is the wrong invariant; "exactly one per
|
|
2181
|
+
// (event, id), on the hosts that are proven to deliver that event" is the right one.
|
|
2182
|
+
const host = relative === 'plugin/hooks/codex-hooks.json' ? 'codex' : 'claude';
|
|
2183
|
+
const expected = continuityRegistrations(host);
|
|
2184
|
+
for (const spec of expected) {
|
|
2185
|
+
const groups = doc.hooks[spec.event] ?? [];
|
|
2186
|
+
const count = groups.flatMap((group) => (group?.hooks ?? []).map((hook) => ({ group, hook })))
|
|
2187
|
+
.filter(({ group, hook }) => String(group?.matcher ?? '') === spec.matcher
|
|
2188
|
+
&& isAllowedContinuityRegistration({ event: spec.event, matcher: group?.matcher, command: hook?.command, host })
|
|
2189
|
+
&& continuityHookId(hook?.command, spec.event)?.id === spec.id).length;
|
|
2190
|
+
if (count !== 1) {
|
|
2191
|
+
errors.push(`${relative}: continuity ${spec.event}:${spec.id} must have exactly one registration (found ${count})`);
|
|
2192
|
+
}
|
|
1987
2193
|
}
|
|
1988
2194
|
for (const event of Object.keys(doc.hooks)) {
|
|
1989
|
-
if (!
|
|
2195
|
+
if (!expected.some((spec) => spec.event === event)) {
|
|
2196
|
+
errors.push(`${relative}: legacy automatic event ${event} remains registered`);
|
|
2197
|
+
}
|
|
1990
2198
|
}
|
|
1991
2199
|
}
|
|
1992
2200
|
} catch (error) {
|
|
@@ -1997,12 +2205,19 @@ export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'sourc
|
|
|
1997
2205
|
const contractsFile = 'plugin/hooks/hook-contracts.json';
|
|
1998
2206
|
const contracts = JSON.parse(fs.readFileSync(path.join(root, contractsFile), 'utf8'));
|
|
1999
2207
|
checkedFiles.push(contractsFile);
|
|
2000
|
-
const
|
|
2001
|
-
|
|
2002
|
-
|
|
2208
|
+
const pairs = Array.isArray(contracts.contracts) ? contracts.contracts.map((c) => `${c?.event}:${c?.id}`) : [];
|
|
2209
|
+
const expectedPairs = continuityContractIds();
|
|
2210
|
+
if (pairs.length !== expectedPairs.length || expectedPairs.some((pair) => !pairs.includes(pair))) {
|
|
2211
|
+
errors.push(`${contractsFile}: contracts must list exactly the continuity handlers (${expectedPairs.join(', ')})`);
|
|
2003
2212
|
}
|
|
2004
|
-
|
|
2005
|
-
|
|
2213
|
+
// One allowlist entry per DISTINCT (event, matcher): the matcher is a property of the event, so
|
|
2214
|
+
// three snapshot registrations on three events need three entries, not three copies of one.
|
|
2215
|
+
const expectedMatchers = [...new Set(continuityRegistrations().map((spec) => `${spec.event}:${spec.matcher}`))];
|
|
2216
|
+
const declaredMatchers = Array.isArray(contracts.matcherAllowlist)
|
|
2217
|
+
? contracts.matcherAllowlist.map((row) => `${row?.event}:${row?.matcher}`) : [];
|
|
2218
|
+
if (declaredMatchers.length !== expectedMatchers.length
|
|
2219
|
+
|| expectedMatchers.some((entry) => !declaredMatchers.includes(entry))) {
|
|
2220
|
+
errors.push(`${contractsFile}: matcherAllowlist must list exactly the continuity matchers (${expectedMatchers.join(', ')})`);
|
|
2006
2221
|
}
|
|
2007
2222
|
} catch (error) {
|
|
2008
2223
|
errors.push(`plugin/hooks/hook-contracts.json: ${error.message}`);
|
|
@@ -2058,17 +2273,21 @@ export function claudeInstalledHookRetirementStatus({ home = os.homedir(), plugi
|
|
|
2058
2273
|
}
|
|
2059
2274
|
}
|
|
2060
2275
|
}
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
command: hook?.command,
|
|
2067
|
-
|
|
2068
|
-
if (count !== 1)
|
|
2276
|
+
const expected = continuityRegistrations('claude');
|
|
2277
|
+
for (const spec of expected) {
|
|
2278
|
+
const count = (doc.hooks[spec.event] ?? [])
|
|
2279
|
+
.flatMap((group) => (group?.hooks ?? []).map((hook) => ({ group, hook })))
|
|
2280
|
+
.filter(({ group, hook }) => String(group?.matcher ?? '') === spec.matcher
|
|
2281
|
+
&& isAllowedContinuityRegistration({ event: spec.event, matcher: group?.matcher, command: hook?.command, host: 'claude' })
|
|
2282
|
+
&& continuityHookId(hook?.command, spec.event)?.id === spec.id).length;
|
|
2283
|
+
if (count !== 1) {
|
|
2284
|
+
errors.push(`installed Claude continuity ${spec.event}:${spec.id} must have exactly one registration (found ${count})`);
|
|
2285
|
+
}
|
|
2069
2286
|
}
|
|
2070
2287
|
for (const event of Object.keys(doc.hooks)) {
|
|
2071
|
-
if (!
|
|
2288
|
+
if (!expected.some((spec) => spec.event === event)) {
|
|
2289
|
+
errors.push(`installed Claude legacy automatic event ${event} remains registered`);
|
|
2290
|
+
}
|
|
2072
2291
|
}
|
|
2073
2292
|
}
|
|
2074
2293
|
} catch (error) {
|
|
@@ -2212,7 +2431,19 @@ async function smokeQuery(cacheDir) {
|
|
|
2212
2431
|
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
2213
2432
|
const out = `${r.stdout || ''}`;
|
|
2214
2433
|
if (r.status !== 0 || !out.trim()) {
|
|
2215
|
-
|
|
2434
|
+
// NAME THE ACTUAL CAUSE, DO NOT GUESS A REASSURING ONE.
|
|
2435
|
+
//
|
|
2436
|
+
// This said "first-run model download or offline" unconditionally. It is one plausible cause
|
|
2437
|
+
// among several, asserted as though it had been checked — and it is the reassuring one, so a
|
|
2438
|
+
// crash, a timeout, and a missing module all read as "nothing is wrong, it will warm up".
|
|
2439
|
+
// Observed on this machine: a smoke query that produced no answer in 240s was reported as a
|
|
2440
|
+
// first-run download. spawnSync already tells us which it was; say that instead.
|
|
2441
|
+
const cause = r.error ? `could not launch the reader: ${r.error.message}`
|
|
2442
|
+
: r.signal === 'SIGTERM' ? `timed out after ${secs}s (240s limit) with no answer`
|
|
2443
|
+
: r.signal ? `the reader was killed by ${r.signal} after ${secs}s`
|
|
2444
|
+
: r.status !== 0 ? `the reader exited ${r.status} after ${secs}s`
|
|
2445
|
+
: `the reader exited 0 after ${secs}s but printed nothing`;
|
|
2446
|
+
warn(`no answer came back — ${cause}`);
|
|
2216
2447
|
// SHOW THE ACTUAL ERROR (issue #37 bug 2, Agentist-Elder, 2026-07-21).
|
|
2217
2448
|
//
|
|
2218
2449
|
// This captured stderr and then threw it away, so every hard failure — a crash, a missing
|
|
@@ -2232,7 +2463,9 @@ async function smokeQuery(cacheDir) {
|
|
|
2232
2463
|
for (const line of lines.slice(0, 12)) info(c.dim(` ${line.slice(0, 200)}`));
|
|
2233
2464
|
if (lines.length > 12) info(c.dim(` … ${lines.length - 12} more line(s)`));
|
|
2234
2465
|
}
|
|
2235
|
-
|
|
2466
|
+
// The reason travels with the verdict so the doctor's "Grounding NOT proven (<reason>)" line
|
|
2467
|
+
// names the real cause too, instead of the generic token.
|
|
2468
|
+
return { ran: true, grounded: false, reason: `no-answer: ${cause}`, secs, stderr: err.slice(0, 4000) };
|
|
2236
2469
|
}
|
|
2237
2470
|
|
|
2238
2471
|
const verifier = await loadCitationVerifier(cacheDir);
|
|
@@ -2530,11 +2763,16 @@ async function doctor() {
|
|
|
2530
2763
|
const v = verifyInstall(cacheDir);
|
|
2531
2764
|
const smoke = await smokeQuery(cacheDir);
|
|
2532
2765
|
const allGreen = v.repos > 0 && v.reader && v.mcp;
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2766
|
+
// ONE VERDICT, AND IT COMES AFTER ITS EVIDENCE.
|
|
2767
|
+
//
|
|
2768
|
+
// This line used to print "✓ Healthy." here, from `allGreen` — which reads only repos/reader/mcp —
|
|
2769
|
+
// and then a SECOND verdict, "✗ FAILING", printed ~120 lines later from the much wider `failed`.
|
|
2770
|
+
// A --doctor run genuinely emitted both, and exited 0. Two verdicts is not a cosmetic problem: a
|
|
2771
|
+
// reader stops at the first one, so the tool told people they were healthy while its own exit-code
|
|
2772
|
+
// logic had already decided otherwise. The single verdict is now emitted at the end, where every
|
|
2773
|
+
// input to `failed` exists; this position keeps only the installed-and-reachable READING.
|
|
2774
|
+
console.log(`\n ${allGreen ? c.green('Install: present and reachable.')
|
|
2775
|
+
: c.yellow('Install: incomplete — see the warnings above.')}`);
|
|
2538
2776
|
// Installed-and-reachable and actually-grounded are different claims. Keep them separate, so a
|
|
2539
2777
|
// healthy install can never be mistaken for proven grounding.
|
|
2540
2778
|
if (smoke.grounded === true) {
|
|
@@ -2653,8 +2891,11 @@ async function doctor() {
|
|
|
2653
2891
|
|| codexReadinessFailed
|
|
2654
2892
|
|| !hostConvergence.healthy
|
|
2655
2893
|
|| Boolean(rufloOperational && !rufloOperational.healthy);
|
|
2656
|
-
|
|
2657
|
-
|
|
2894
|
+
// THE ONE VERDICT. Always printed, always consistent with the exit code, never alongside another.
|
|
2895
|
+
if (failed) {
|
|
2896
|
+
console.log(`\n ${c.red('✗ FAILING')} — the warnings above are real. Re-run ${c.bold('npx ruvnet-brain')} to repair.`);
|
|
2897
|
+
} else {
|
|
2898
|
+
console.log(`\n ${c.green('✓ Healthy.')} The brain is installed, reachable, and its checks pass.`);
|
|
2658
2899
|
}
|
|
2659
2900
|
return failed ? 1 : 0;
|
|
2660
2901
|
}
|
|
@@ -2722,6 +2963,20 @@ function cmpTag(a, b) {
|
|
|
2722
2963
|
return A.pre > B.pre ? 1 : -1;
|
|
2723
2964
|
}
|
|
2724
2965
|
|
|
2966
|
+
/**
|
|
2967
|
+
* The CORPUS transport tag this brain last received, or null.
|
|
2968
|
+
*
|
|
2969
|
+
* Deliberately separate from installedBrainVersion(): that one answers "which runtime built the KB
|
|
2970
|
+
* on disk" (a semver), this one answers "which published corpus archive is on disk" (a content
|
|
2971
|
+
* address). Conflating them is the defect ADR-086 step 16 exists to fix.
|
|
2972
|
+
*/
|
|
2973
|
+
function installedCorpusTag(cacheDir) {
|
|
2974
|
+
try {
|
|
2975
|
+
const tag = JSON.parse(fs.readFileSync(path.join(cacheDir, 'SOURCE.json'), 'utf8')).corpusReleaseTag;
|
|
2976
|
+
return isCorpusReleaseTag(tag) ? tag : null;
|
|
2977
|
+
} catch { return null; }
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2725
2980
|
function installedBrainVersion(cacheDir) {
|
|
2726
2981
|
// Same read the telemetry ping uses: the bundle stamps its Release tag into SOURCE.json.
|
|
2727
2982
|
// "unknown" is honest for a locally-built or pre-stamping bundle — never guess a tag.
|
|
@@ -2802,7 +3057,7 @@ function reportVersionDrift(cacheDir) {
|
|
|
2802
3057
|
if (!state.drift) return state;
|
|
2803
3058
|
warn(`the brain (${c.bold(state.kb)}) and the Claude Code plugin (${c.bold(state.wrapper)}) have drifted apart —`);
|
|
2804
3059
|
info(`that's normal (they update on separate schedules) and neither one is broken. To bring the`);
|
|
2805
|
-
info(`plugin up to date: ${c.bold('claude plugin marketplace update ruvnet-brain')} ${c.dim('(
|
|
3060
|
+
info(`plugin up to date: ${c.bold('claude plugin marketplace update ruvnet-brain')} ${c.dim('(body updates go live without a restart; boot-surface changes are called out)')}`);
|
|
2806
3061
|
return state;
|
|
2807
3062
|
}
|
|
2808
3063
|
|
|
@@ -2826,7 +3081,11 @@ function feedbackHealthLines(cacheDir) {
|
|
|
2826
3081
|
return [
|
|
2827
3082
|
`${s.repos} repo stores on disk · reader ${s.reader ? 'ok' : 'MISSING'} · search_ruvnet ${s.mcp ? 'ok' : 'MISSING'} · plugin ${s.plugin ? 'ok' : 'NOT INSTALLED (no /rvbc)'}`,
|
|
2828
3083
|
`toolkit: Ruflo ${env.ruflo ? 'present' : 'not found'} · RuVector ${env.ruvector ? 'present' : 'not found'} · claude CLI ${env.claude ? 'present' : 'not found'}`,
|
|
2829
|
-
|
|
3084
|
+
// NOT called a "verdict": this reads only repos/reader/mcp, while --doctor's verdict also weighs
|
|
3085
|
+
// grounding, Codex wiring, nightly health, host convergence and the hook policy. Two lines both
|
|
3086
|
+
// labelled "verdict" that answer different questions can disagree in public, which is the exact
|
|
3087
|
+
// Healthy-and-FAILING confusion the doctor's single verdict was collapsed to remove.
|
|
3088
|
+
allGreen ? 'install reading: present and reachable' : 'install reading: incomplete — re-run npx ruvnet-brain',
|
|
2830
3089
|
];
|
|
2831
3090
|
}
|
|
2832
3091
|
|
|
@@ -3061,6 +3320,15 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3061
3320
|
codexReceipt.sessionSafety = results.codex.sessionSafety || null;
|
|
3062
3321
|
codexReceipt.sessionSafetyReason = results.codex.sessionSafetyReason || null;
|
|
3063
3322
|
}
|
|
3323
|
+
const claudeReceipt = {
|
|
3324
|
+
state: results.claude.host ? 'ready' : 'absent',
|
|
3325
|
+
version: results.claude.version || null,
|
|
3326
|
+
};
|
|
3327
|
+
if (results.claude?.restartRequired === true) {
|
|
3328
|
+
claudeReceipt.restartRequired = true;
|
|
3329
|
+
claudeReceipt.sessionSafety = results.claude.sessionSafety || null;
|
|
3330
|
+
claudeReceipt.sessionSafetyReason = results.claude.sessionSafetyReason || null;
|
|
3331
|
+
}
|
|
3064
3332
|
if (okApplied) {
|
|
3065
3333
|
// ISSUE #153 — a running host may freeze an old plugin root. Reclaim only generations whose
|
|
3066
3334
|
// modern leases prove no live consumer; legacy roots without that proof stay on disk.
|
|
@@ -3093,7 +3361,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3093
3361
|
desiredVersion: PACKAGE_VERSION,
|
|
3094
3362
|
verifiedAt: new Date().toISOString(),
|
|
3095
3363
|
hosts: {
|
|
3096
|
-
claude:
|
|
3364
|
+
claude: claudeReceipt,
|
|
3097
3365
|
codex: codexReceipt,
|
|
3098
3366
|
},
|
|
3099
3367
|
consoleRuntime: results.consoleRuntime,
|
|
@@ -3108,7 +3376,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3108
3376
|
const convergence = classifyHostConvergence({
|
|
3109
3377
|
desiredVersion: PACKAGE_VERSION,
|
|
3110
3378
|
hosts: {
|
|
3111
|
-
claude:
|
|
3379
|
+
claude: claudeReceipt,
|
|
3112
3380
|
codex: codexReceipt,
|
|
3113
3381
|
},
|
|
3114
3382
|
consoleRuntime: results.consoleRuntime,
|
|
@@ -3229,6 +3497,17 @@ function runUpdate() {
|
|
|
3229
3497
|
settleRefresh(1, { phase: 'source-enumeration' });
|
|
3230
3498
|
return;
|
|
3231
3499
|
}
|
|
3500
|
+
// The updater exists; make sure the trusted validator it will load exists too (package copy,
|
|
3501
|
+
// byte-compared). A failure here is reported, not fatal — the updater states the same absence
|
|
3502
|
+
// in its own words and the run then takes the documented fallback.
|
|
3503
|
+
try {
|
|
3504
|
+
const prerequisites = ensureUpdaterPrerequisites(kbDir);
|
|
3505
|
+
if (prerequisites.validator?.action !== 'unchanged') {
|
|
3506
|
+
info(c.dim(`trusted coverage validator ${prerequisites.validator.action} beside the updater`));
|
|
3507
|
+
}
|
|
3508
|
+
} catch (error) {
|
|
3509
|
+
warn(`could not place the trusted coverage validator (${error.message}); the updater will report what it finds`);
|
|
3510
|
+
}
|
|
3232
3511
|
info(c.dim("running the bundle's own self-updater (backs up first, re-verifies, never half-applies)…\n"));
|
|
3233
3512
|
// Relative filename + matching cwd — same launch convention as smokeQuery(); stdio:'inherit'
|
|
3234
3513
|
// streams the updater's narration live and unedited.
|
|
@@ -3575,6 +3854,12 @@ export function machineFootprint() {
|
|
|
3575
3854
|
const artifact = nightlyArtifact({ platform: process.platform, env: process.env });
|
|
3576
3855
|
if (artifact.kind === 'launchd') add('Nightly updater (LaunchAgent)', artifact.path, 'npx ruvnet-brain --disable-nightly');
|
|
3577
3856
|
add('Nightly scheduler registration', path.join(process.env.RUVNET_BRAIN_HOME || path.dirname(resolvedKbDir()), 'scheduler', 'registration.json'), 'npx ruvnet-brain --disable-nightly');
|
|
3857
|
+
// Only ever present after the updater refused an incompatible corpus release (ADR-086 step 16).
|
|
3858
|
+
// It lives BESIDE the KB rather than inside it so an exact-tree update cannot erase the memory of
|
|
3859
|
+
// the refusal — which is what keeps a refusal from being rediscovered, and re-downloaded, nightly.
|
|
3860
|
+
// Declared here because a file we write is a file we own and must be able to take back.
|
|
3861
|
+
add('Rejected-release memo (only after an incompatible corpus release)',
|
|
3862
|
+
rejectedReleasePath(resolvedKbDir()), 'npx ruvnet-brain --uninstall');
|
|
3578
3863
|
}
|
|
3579
3864
|
if (process.platform === 'darwin') {
|
|
3580
3865
|
add('Spend watchdog (LaunchAgent)', spendGuardPlistPath(), 'npx ruvnet-brain --disable-spend-guard');
|
|
@@ -3755,6 +4040,7 @@ function uninstallAll() {
|
|
|
3755
4040
|
// owns (settings.json entries, the MCP registration) and the Claude Code plugin itself are not
|
|
3756
4041
|
// ours to delete, so they are handed over as commands.
|
|
3757
4042
|
const AUTO = new Set(['Brain bundle (knowledge base)', 'Nightly updater (LaunchAgent)', 'Nightly scheduler registration',
|
|
4043
|
+
'Rejected-release memo (only after an incompatible corpus release)',
|
|
3758
4044
|
'Spend watchdog (LaunchAgent)', 'Spend watchdog script', 'CLAUDE.md block (6 lines, between markers)',
|
|
3759
4045
|
'Model-router files', 'Status-bar version script', 'Status-bar preference', 'Usage-counts preference',
|
|
3760
4046
|
// Two gaps closed here: the statusLine KEY is now removable in place (we know exactly what we
|
|
@@ -4379,7 +4665,11 @@ export function classifyRufloOperationalHealth({ status = '', memory = '', metri
|
|
|
4379
4665
|
|
|
4380
4666
|
function probeRufloOperationalHealth() {
|
|
4381
4667
|
const run = (args) => {
|
|
4382
|
-
|
|
4668
|
+
// Every `ruflo` invocation auto-starts a project background daemon unless this is set
|
|
4669
|
+
// (verified live: ~/.npm-global/lib/node_modules/ruflo/node_modules/@claude-flow/cli/dist/src/
|
|
4670
|
+
// services/daemon-autostart.js:85) — a read-only health probe must not leave one running.
|
|
4671
|
+
const result = spawnSync('ruflo', args, { cwd: process.cwd(), encoding: 'utf8', timeout: 10_000,
|
|
4672
|
+
env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
|
|
4383
4673
|
return `${result.stdout || ''}\n${result.stderr || ''}`;
|
|
4384
4674
|
};
|
|
4385
4675
|
return classifyRufloOperationalHealth({
|
|
@@ -4871,10 +5161,8 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
|
|
|
4871
5161
|
console.log(`\n ${c.bold('Keep it fresh:')} re-run ${c.bold('npx ruvnet-brain')} any time — the brain itself always pulls the latest`);
|
|
4872
5162
|
console.log(` Release regardless (that part isn't cached). For the bleeding-edge installer too, use ${c.bold('npx github:stuinfla/ruvnet-brain')}.`);
|
|
4873
5163
|
|
|
4874
|
-
|
|
4875
|
-
|
|
4876
|
-
console.log(`\n ${c.yellow(c.bold('One required cleanup step:'))} restart any ${hostLabel} window that was open before this update.`);
|
|
4877
|
-
console.log(` ${c.dim('That unloads the old in-memory hook registry; new sessions load the intentional empty registry.')}`);
|
|
5164
|
+
console.log(`\n ${c.bold('Updates take effect:')} body-only releases go live on the next hook/MCP call without a restart.`);
|
|
5165
|
+
console.log(` ${c.dim('If boot-level declarations change, the installer names the affected host and requests one restart.')}`);
|
|
4878
5166
|
|
|
4879
5167
|
// ── what to do now ──
|
|
4880
5168
|
console.log(`\n ${c.bold('What to do now:')}`);
|
|
@@ -4982,8 +5270,8 @@ Env:
|
|
|
4982
5270
|
download or an air-gapped machine is not a broken install). Only ever set
|
|
4983
5271
|
this for a locked-down environment where you want to know immediately.
|
|
4984
5272
|
|
|
4985
|
-
It is safe to re-run at any time.
|
|
4986
|
-
|
|
5273
|
+
It is safe to re-run at any time. Body updates go live on the next hook/MCP call. Restart only when
|
|
5274
|
+
the installer reports that boot-level declarations changed.
|
|
4987
5275
|
`);
|
|
4988
5276
|
}
|
|
4989
5277
|
|
|
@@ -5079,21 +5367,33 @@ their old in-memory hook registries unload.
|
|
|
5079
5367
|
? (resolvedRelease.tag_name || resolvedRelease.tag || null)
|
|
5080
5368
|
: null;
|
|
5081
5369
|
} catch { latestTag = null; }
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
//
|
|
5085
|
-
//
|
|
5086
|
-
//
|
|
5087
|
-
//
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5370
|
+
// A CORPUS RELEASE IS NOT ORDERABLE AGAINST A SEMVER (ADR-086 step 16). `corpus-sha256-<64 hex>`
|
|
5371
|
+
// is a content address; cmpTag() parses its leading segment with parseInt, gets NaN, floors it
|
|
5372
|
+
// to 0, and concludes any installed version is NEWER — so a corpus release would be reported as
|
|
5373
|
+
// "installed is NEWER than the latest release" and silently skipped, forever. Compare the
|
|
5374
|
+
// installed corpus tag against the offered corpus tag instead: equal = current, different =
|
|
5375
|
+
// behind, never "ahead", because content addresses have no order.
|
|
5376
|
+
if (isCorpusReleaseTag(latestTag)) {
|
|
5377
|
+
const installedCorpus = installedCorpusTag(cacheDir);
|
|
5378
|
+
ahead = false;
|
|
5379
|
+
staleSkip = installedCorpus !== latestTag;
|
|
5380
|
+
} else {
|
|
5381
|
+
const norm = (v) => (v == null || v === 'unknown' ? null : String(v).replace(/^v/, ''));
|
|
5382
|
+
const a = norm(installedTag), b = norm(latestTag);
|
|
5383
|
+
// BEHIND => download. SAME => skip. AHEAD => skip, and say so honestly.
|
|
5384
|
+
//
|
|
5385
|
+
// This was a bare `a !== b`, which treats "newer than the latest release" as staleness. Anyone
|
|
5386
|
+
// running a pre-release or dev build — or who simply updated in the window before a release was
|
|
5387
|
+
// cut — was told "Brain is out of date" and pushed through a 2 GB download that would DOWNGRADE
|
|
5388
|
+
// them. Found 2026-07-22 the moment this repo's own version moved to 3.5.0-dev ahead of the
|
|
5389
|
+
// 3.4.22-dev release: the installer immediately declared its own newest brain stale.
|
|
5390
|
+
//
|
|
5391
|
+
// stack-sync.mjs has modelled AHEAD as legal from the start ("AHEAD is legal and produces NO
|
|
5392
|
+
// recommendation — that modelling choice is what makes the alpha-vs-latest downgrade war
|
|
5393
|
+
// structurally impossible"). The installer never learned the same lesson. It has now.
|
|
5394
|
+
ahead = Boolean(a && b && cmpTag(a, b) > 0);
|
|
5395
|
+
staleSkip = Boolean(b && (a === null || (a !== b && !ahead)));
|
|
5396
|
+
}
|
|
5097
5397
|
}
|
|
5098
5398
|
|
|
5099
5399
|
if (alreadyInstalled && !FLAG_FORCE && !staleSkip) {
|
|
@@ -5152,7 +5452,12 @@ their old in-memory hook registries unload.
|
|
|
5152
5452
|
ok(reason);
|
|
5153
5453
|
}
|
|
5154
5454
|
}
|
|
5155
|
-
|
|
5455
|
+
// The tag is only carried when it came from a genuine `latest` resolution — a pinned/offline
|
|
5456
|
+
// fallback is not evidence of which release these bytes are, and recording a guess would be
|
|
5457
|
+
// worse than recording nothing.
|
|
5458
|
+
await unzipInto(zipPath, cacheDir, sourceDir, {
|
|
5459
|
+
releaseTag: release && release.source === 'latest' ? (release.tag_name || release.tag || null) : null,
|
|
5460
|
+
});
|
|
5156
5461
|
const brainProfile = readBrainProfile();
|
|
5157
5462
|
if (brainProfile !== 'complete') {
|
|
5158
5463
|
const scoped = applyBrainProfile(cacheDir, brainProfile);
|