ruvnet-brain 4.4.0 → 4.5.0
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 +3 -3
- package/bin/install.mjs +679 -121
- package/console/app.js +178 -81
- package/console/index.html +1 -1
- package/console/install-architecture.html +1 -0
- package/console/scope.css +4 -1
- package/console/style.css +13 -0
- package/console/tips.html +4 -4
- package/kb/brain-profile.mjs +1 -0
- package/kb/corpus-release-identity.mjs +1 -1
- package/kb/forge-update.mjs +41 -17
- package/kb/model-requirements.mjs +4 -1
- package/kb/update-storage-transaction.mjs +79 -0
- package/kb/zip-extract.mjs +22 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +5 -4
- package/plugin/commands/configure.md +5 -4
- package/plugin/commands/rnb-brief.md +41 -0
- package/plugin/commands/rnb.md +80 -0
- package/plugin/commands/rnbc.md +80 -0
- package/plugin/commands/rvbc.md +5 -4
- package/plugin/commands/rvcb.md +5 -4
- package/plugin/commands/whats-new.md +4 -4
- package/plugin/mcp/server.mjs +10 -2
- package/plugin/scripts/advocacy-route.mjs +59 -23
- package/plugin/scripts/anticipate.sh +4 -0
- package/plugin/scripts/brain-confirmation.mjs +258 -0
- package/plugin/scripts/brain-footprint.mjs +494 -0
- package/plugin/scripts/brain-location.mjs +47 -0
- package/plugin/scripts/capability-registry.mjs +11 -1
- package/plugin/scripts/continuity-brief.mjs +324 -0
- package/plugin/scripts/continuity-events.mjs +327 -0
- package/plugin/scripts/continuity-journal.mjs +500 -0
- package/plugin/scripts/decision-gate.mjs +56 -3
- package/plugin/scripts/footprint-io.mjs +186 -0
- package/plugin/scripts/ground-before-write.sh +8 -1
- package/plugin/scripts/ground-ruvnet.sh +103 -12
- package/plugin/scripts/grounding-answer.mjs +2 -1
- package/plugin/scripts/grounding-stamp.sh +3 -0
- package/plugin/scripts/grounding-substance.mjs +1 -1
- package/plugin/scripts/grounding-turn-evidence.mjs +68 -6
- package/plugin/scripts/hook-input.mjs +78 -4
- package/plugin/scripts/kb-copy-proof.mjs +148 -0
- package/plugin/scripts/lesson-bridge.mjs +6 -2
- package/plugin/scripts/nightly-controller.mjs +8 -1
- package/plugin/scripts/node-sqlite.mjs +41 -0
- package/plugin/scripts/package-cards.json +797 -0
- package/plugin/scripts/package-cards.rvf +0 -0
- package/plugin/scripts/package-cards.rvf.idmap.json +1 -0
- package/plugin/scripts/package-cards.rvf.meta.json +1 -0
- package/plugin/scripts/package-recommender-client.mjs +138 -0
- package/plugin/scripts/package-recommender-flag.mjs +30 -0
- package/plugin/scripts/package-recommender.mjs +391 -0
- package/plugin/scripts/project-progression-outbox.mjs +26 -8
- package/plugin/scripts/project-progression-reader.mjs +14 -2
- package/plugin/scripts/project-progression-store.mjs +153 -6
- package/plugin/scripts/protect-brain-state.sh +4 -1
- package/plugin/scripts/session-snapshot-hook.mjs +225 -41
- package/plugin/scripts/session-start-budget.mjs +1 -0
- package/plugin/scripts/session-start-core.mjs +34 -4
- package/plugin/scripts/session-start-health.mjs +7 -1
- package/plugin/scripts/session-start-update-plane.mjs +35 -0
- package/plugin/scripts/turn-outcome-capture.mjs +12 -1
- package/plugin/scripts/unprompted-runtime.mjs +2 -2
- package/plugin/skills/brain-console/SKILL.md +3 -3
- package/plugin/skills/rnbc/SKILL.md +24 -0
- package/plugin/skills/rvbc/SKILL.md +2 -2
- package/scripts/approved-runtime.mjs +2 -2
- package/scripts/ci/warm-brain-models.mjs +28 -0
- package/scripts/codex-hook-trust.mjs +94 -0
- package/scripts/console-instances.mjs +70 -12
- package/scripts/console-runtime-identity.mjs +5 -0
- package/scripts/corpus-canary.mjs +46 -6
- package/scripts/corpus-dispatch-decision.mjs +2 -2
- package/scripts/corpus-promotion.mjs +1 -1
- package/scripts/full-suite-gate.mjs +9 -2
- package/scripts/hook-qualify-hosts.mjs +15 -3
- package/scripts/host-install-matrix.mjs +63 -2
- package/scripts/human-approval-phrases.mjs +46 -0
- package/scripts/installed-brain-health.mjs +53 -0
- package/scripts/move-brain.mjs +310 -0
- package/scripts/onboarding-console.mjs +93 -10
- package/scripts/oracle/abstain-threshold-sweep.mjs +62 -0
- package/scripts/oracle/abstain-trace.mjs +139 -0
- package/scripts/oracle/doc2query-generate.mjs +162 -0
- package/scripts/oracle/doc2query-reach.mjs +110 -0
- package/scripts/oracle/judge-train.mjs +158 -0
- package/scripts/oracle/need-set-split.mjs +48 -0
- package/scripts/oracle/sona-query-adapter-eval.mjs +139 -0
- package/scripts/package-cards.mjs +374 -0
- package/scripts/publication-receipt.mjs +37 -9
- package/scripts/recommendation-e2e.mjs +110 -0
- package/scripts/recommendation-eval.mjs +105 -0
- package/scripts/recommendation-floor.mjs +56 -0
- package/scripts/recommendation-judge-score.mjs +74 -0
- package/scripts/recommendation-latency.mjs +95 -0
- package/scripts/recommendation-real-host-score.mjs +76 -0
- package/scripts/recommendation-real-host.mjs +137 -0
- package/scripts/release-channel-kind.mjs +1 -1
- package/scripts/release-environment-policy.mjs +33 -0
- package/scripts/single-source-check.mjs +15 -10
- package/scripts/sync-commands.mjs +5 -2
- package/scripts/wired-check.mjs +17 -2
package/bin/install.mjs
CHANGED
|
@@ -23,15 +23,22 @@ import crypto from 'node:crypto';
|
|
|
23
23
|
import { applyBrainProfile, readBrainProfile } from '../kb/brain-profile.mjs';
|
|
24
24
|
import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, physicalPath, recordRefreshAdvisory,
|
|
25
25
|
recordRefreshPhase, settleRefreshRun, UPDATE_REFRESH_PHASES } from '../kb/refresh-run.mjs';
|
|
26
|
-
import { pruneLifecycleEvidence } from '../kb/lifecycle-evidence-retention.mjs';
|
|
27
|
-
import { recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
|
|
26
|
+
import { assessLifecycleEvidence, pruneLifecycleEvidence } from '../kb/lifecycle-evidence-retention.mjs';
|
|
27
|
+
import { checkDiskSpace, recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
|
|
28
|
+
import { footprintRoots, inventoryFootprint, sweepFootprint } from '../plugin/scripts/brain-footprint.mjs';
|
|
29
|
+
import { kbCopyProof } from '../plugin/scripts/kb-copy-proof.mjs';
|
|
30
|
+
import { isVolumeMetadata } from '../plugin/scripts/footprint-io.mjs';
|
|
31
|
+
import { confirm, doctorVerdict, formatBytes, formatConfirmation, signatureEvidenceFromReceipts, signatureRecordValid, writeSignatureRecord } from '../plugin/scripts/brain-confirmation.mjs';
|
|
28
32
|
import {
|
|
29
33
|
requiredEmbedderModels,
|
|
30
34
|
missingEmbedderModels,
|
|
31
35
|
} from '../kb/model-requirements.mjs';
|
|
32
36
|
import { applyManagedCatalogUpdate } from '../scripts/model-router-catalog.mjs';
|
|
33
37
|
import { cmpVersion } from '../scripts/stack-sync.mjs';
|
|
34
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
inspectInstalledBrain, classifySmokeEvidence, classifySmokeFailure, classifyWarmupFailure, coldModels, DOCTOR_SMOKE_QUERY, doctorSmokeArgs,
|
|
40
|
+
MODEL_WARMUP_SCRIPT, MODEL_WARMUP_TIMEOUT_MS,
|
|
41
|
+
} from '../scripts/installed-brain-health.mjs';
|
|
35
42
|
import { validateCoverageDirectory } from '../plugin/scripts/coverage-integrity.mjs';
|
|
36
43
|
import {
|
|
37
44
|
continuityContractIds,
|
|
@@ -71,7 +78,12 @@ import {
|
|
|
71
78
|
CONSOLE_RUNTIME_SURFACE, CONSOLE_RUNTIME_IDENTITY_FILE, consoleRuntimeDigest,
|
|
72
79
|
} from '../scripts/console-runtime-identity.mjs';
|
|
73
80
|
import { shellDiff as pluginShellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
|
|
81
|
+
import { codexTrustChanges, CODEX_TRUST_ACTION } from '../scripts/codex-hook-trust.mjs';
|
|
74
82
|
import { readConsoleReceipts, replaceStaleConsoles } from '../scripts/console-instances.mjs';
|
|
83
|
+
import { moveBrain, MoveRefused } from '../scripts/move-brain.mjs';
|
|
84
|
+
import { brainLocation } from '../plugin/scripts/brain-location.mjs';
|
|
85
|
+
import { cleanLegacyRufloDebris } from '../plugin/scripts/project-progression-store.mjs';
|
|
86
|
+
import { resolveProjectStore } from '../plugin/scripts/project-store-resolver.mjs';
|
|
75
87
|
import { runHostCli, waitForHostCli } from '../scripts/host-cli.mjs';
|
|
76
88
|
import {
|
|
77
89
|
writeInstalledRuntimeIdentity, recordCorpusTransportIdentity, isCorpusReleaseTag, rejectedReleasePath,
|
|
@@ -105,17 +117,6 @@ const PACKAGE_VERSION = (() => {
|
|
|
105
117
|
const REPO = 'stuinfla/ruvnet-brain';
|
|
106
118
|
const RELEASE_API = `https://api.github.com/repos/${REPO}/releases/latest`;
|
|
107
119
|
const ASSET_NAME = 'ruvnet-brain.zip';
|
|
108
|
-
// Known-good BUNDLE tag, used ONLY by --pin. It is no longer a silent fallback for a failed
|
|
109
|
-
// latest-release lookup: that bundle predates ReleaseCoverage and cannot pass validation, so a lookup
|
|
110
|
-
// failure now stops with its real cause (resolveRelease / releaseLookupFailure).
|
|
111
|
-
//
|
|
112
|
-
// This MUST NOT be derived from this package's own version. The installer and the brain bundle are
|
|
113
|
-
// two independent version streams (README: "Three independent things version separately here — by
|
|
114
|
-
// design"). Reading it from package.json produced a tag that has never existed — installer 1.14.0-dev
|
|
115
|
-
// asking for releases/download/v1.14.0-dev/ruvnet-brain.zip, which 404s, while the newest bundle
|
|
116
|
-
// Release is v0.5.0-dev. Verified live: v1.14.0-dev → HTTP 404, v0.5.0-dev → HTTP 200. The safety net
|
|
117
|
-
// was broken in exactly the situation it exists for. Bump this by hand when a new bundle ships.
|
|
118
|
-
const RELEASE_VERSION = 'v2.9.0'; // sync-version-ignore: the BUNDLE Release tag, not this package's version
|
|
119
120
|
const fallbackUrl = (tag) => `https://github.com/${REPO}/releases/download/${tag}/${ASSET_NAME}`;
|
|
120
121
|
const APPROX_SIZE = '~736MB';
|
|
121
122
|
|
|
@@ -130,7 +131,6 @@ const FLAG_HOOKS = argv.includes('--hooks');
|
|
|
130
131
|
const FLAG_NO_VERIFY = argv.includes('--no-verify');
|
|
131
132
|
// Escape hatch for the installer's closing self-check ONLY (it never disables --doctor's verdict).
|
|
132
133
|
const FLAG_NO_SELFCHECK = argv.includes('--no-selfcheck');
|
|
133
|
-
const FLAG_PIN = argv.includes('--pin'); // skip the latest-check, use the bundled default
|
|
134
134
|
const FLAG_DEMO = argv.includes('--demo'); // guided, real (non-fabricated) walkthrough of the brain in action
|
|
135
135
|
const FLAG_FEEDBACK = argv.includes('--feedback'); // prefill a GitHub Discussion (version + health, nothing private) and open it
|
|
136
136
|
// ── freshness flags — invoke/schedule the SELF-UPDATER the bundle already ships (kb/forge-update.mjs) ──
|
|
@@ -147,6 +147,12 @@ const FLAG_DISABLE_SPEND_GUARD = argv.includes('--disable-spend-guard'); // the
|
|
|
147
147
|
const FLAG_UNINSTALL = argv.includes('--uninstall'); // reverse everything, in one command
|
|
148
148
|
const FLAG_WHAT_CHANGED = argv.includes('--what-changed'); // show our footprint on this machine
|
|
149
149
|
const FLAG_WHATS_NEW = argv.includes('--whats-new'); // show curated major-release highlights
|
|
150
|
+
// --move-brain <dir> moves the whole Brain to another disk, leaving ~/.cache/ruvnet-brain as a link to it;
|
|
151
|
+
// --move-brain --back brings it home (scripts/move-brain.mjs).
|
|
152
|
+
const FLAG_MOVE_BRAIN = argv.includes('--move-brain');
|
|
153
|
+
const MOVE_BRAIN_TO = (() => { const i = argv.indexOf('--move-brain'); const v = i === -1 ? null : argv[i + 1]; return v && !v.startsWith('-') ? v : null; })();
|
|
154
|
+
const FLAG_CLEAN = argv.includes('--clean'); // enforce the footprint guarantee now (ADR-0098), then confirm
|
|
155
|
+
const FLAG_JSON = argv.includes('--json'); // with --doctor / --clean: machine-readable confirmation only
|
|
150
156
|
// ── onboarding-experience flags (all optional; every offer is safe to decline) ──
|
|
151
157
|
const FLAG_YES = argv.includes('--yes') || argv.includes('-y'); // accept every optional offer non-interactively
|
|
152
158
|
const FLAG_PLAN = argv.includes('--plan') || argv.includes('--dry-run'); // show the interactive checklist, then exit — install NOTHING
|
|
@@ -156,12 +162,32 @@ const FLAG_ENHANCE_CLAUDE_MD = argv.includes('--enhance-claude-md'); // add the
|
|
|
156
162
|
const FLAG_NO_ENHANCE = argv.includes('--no-enhance'); // skip the CLAUDE.md offer entirely
|
|
157
163
|
const FLAG_STATUSLINE = argv.includes('--statusline'); // opt in to the status-bar version segment, non-interactively
|
|
158
164
|
const FLAG_NO_STATUSLINE = argv.includes('--no-statusline'); // decline the status-bar offer without prompting
|
|
159
|
-
// --version <tag> forces a specific Release tag (e.g. --version v0.5.0-dev)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
+
// --version <tag> forces a specific Release tag (e.g. --version v0.5.0-dev); --pin <tag> is the same.
|
|
166
|
+
/**
|
|
167
|
+
* Which release the operator named, if any. Pure, for testing.
|
|
168
|
+
* --pin used to mean "install the bundled known-good v2.9.0" — a bundle that predates COVERAGE.json,
|
|
169
|
+
* so every --pin install failed validation two steps later. A pin now names its version, or stops.
|
|
170
|
+
* @returns {{ tag: string, source: 'pinned'|'forced' } | { error: string, hint: string } | null}
|
|
171
|
+
*/
|
|
172
|
+
export function namedReleaseFromArgs(args) {
|
|
173
|
+
const valueAfter = (flag) => {
|
|
174
|
+
const i = args.indexOf(flag);
|
|
175
|
+
if (i === -1) return undefined;
|
|
176
|
+
const next = args[i + 1];
|
|
177
|
+
return next && !next.startsWith('-') ? next : null;
|
|
178
|
+
};
|
|
179
|
+
const pin = valueAfter('--pin');
|
|
180
|
+
const version = valueAfter('--version');
|
|
181
|
+
if (pin === null) {
|
|
182
|
+
return { error: '--pin needs the release to install, e.g. --pin vX.Y.Z', hint: `It no longer falls back to a built-in release (that bundle could not pass validation). Pick one from https://github.com/${REPO}/releases, or omit --pin to install the latest.` };
|
|
183
|
+
}
|
|
184
|
+
if (pin && version && pin !== version) return { error: `--pin ${pin} and --version ${version} disagree`, hint: 'Name one release.' };
|
|
185
|
+
if (pin) return { tag: pin, source: 'pinned' };
|
|
186
|
+
if (version) return { tag: version, source: 'forced' };
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const NAMED_RELEASE = namedReleaseFromArgs(argv);
|
|
190
|
+
const FORCED_VERSION = NAMED_RELEASE?.tag || null;
|
|
165
191
|
|
|
166
192
|
// ── tiny narrating logger — every step says WHAT and WHY ─────────────────────────────────────────
|
|
167
193
|
const c = {
|
|
@@ -333,7 +359,7 @@ function fetchJson(url, redirects = 0) {
|
|
|
333
359
|
|
|
334
360
|
// ── step: resolve which Release to download (latest by default; safe fallback) ───────────────────
|
|
335
361
|
// Default behavior: ask GitHub for the LATEST Release and use its ruvnet-brain.zip asset.
|
|
336
|
-
// --version <tag>
|
|
362
|
+
// --version <tag> / --pin <tag> install exactly that tag; there is no built-in fallback release.
|
|
337
363
|
// Any failure (offline / rate-limited / no releases) THROWS with the HTTP status or network error and
|
|
338
364
|
// a retry hint; callers that must download stop on it, the staleness check reports "could not check".
|
|
339
365
|
/**
|
|
@@ -368,14 +394,10 @@ async function resolveRelease() {
|
|
|
368
394
|
'so a stranger always gets the most current brain — not whatever was hardcoded when this script shipped',
|
|
369
395
|
);
|
|
370
396
|
|
|
371
|
-
if (
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if (FORCED_VERSION) {
|
|
377
|
-
info(`--version set: forcing Release ${c.bold(FORCED_VERSION)} (no latest-check)`);
|
|
378
|
-
return { tag: FORCED_VERSION, url: fallbackUrl(FORCED_VERSION), source: 'forced' };
|
|
397
|
+
if (NAMED_RELEASE?.error) throw Object.assign(new Error(NAMED_RELEASE.error), { hint: NAMED_RELEASE.hint });
|
|
398
|
+
if (NAMED_RELEASE) {
|
|
399
|
+
info(`${NAMED_RELEASE.source === 'pinned' ? '--pin' : '--version'} set: installing Release ${c.bold(NAMED_RELEASE.tag)} (no latest-check)`);
|
|
400
|
+
return { tag: NAMED_RELEASE.tag, url: fallbackUrl(NAMED_RELEASE.tag), source: NAMED_RELEASE.source };
|
|
379
401
|
}
|
|
380
402
|
|
|
381
403
|
// Deterministic integration seam: stale/current behavior must not depend on GitHub API quota.
|
|
@@ -474,12 +496,13 @@ async function obtainBundle(release) {
|
|
|
474
496
|
return { zipPath: localZip, downloaded: false };
|
|
475
497
|
}
|
|
476
498
|
|
|
477
|
-
|
|
499
|
+
if (!release || !release.url) die('no release was resolved to download.', 'Re-run to look up the latest release, or name one with --version <tag>.');
|
|
500
|
+
const downloadUrl = release.url;
|
|
478
501
|
step(
|
|
479
502
|
`Downloading the brain (${APPROX_SIZE})`,
|
|
480
503
|
'the brain embeds source from dozens of RuvNet repos — too big for git, so it ships as a Release',
|
|
481
504
|
);
|
|
482
|
-
info(`version: ${c.bold(
|
|
505
|
+
info(`version: ${c.bold(release.tag)}`);
|
|
483
506
|
info(`from: ${downloadUrl}`);
|
|
484
507
|
// Download into a PRIVATE, per-run temp DIR — never a predictable os.tmpdir()/ruvnet-brain-<pid>.zip
|
|
485
508
|
// filename (CWE-377: a guessable path invites a pre-created or symlinked file at that location to be
|
|
@@ -621,6 +644,18 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
|
|
|
621
644
|
);
|
|
622
645
|
|
|
623
646
|
fs.mkdirSync(path.dirname(cacheDir), { recursive: true });
|
|
647
|
+
// DISK-SPACE PREFLIGHT before staging: the whole unpacked bundle lands beside the brain (the prior
|
|
648
|
+
// generation is renamed, never copied). Refuse cleanly with the exact shortfall rather than ENOSPC
|
|
649
|
+
// half-way through an extraction.
|
|
650
|
+
if (zipPath && !sourceDir) {
|
|
651
|
+
let space;
|
|
652
|
+
try {
|
|
653
|
+
const { zipDeclaredBytes } = await import(new URL('../kb/zip-extract.mjs', import.meta.url).href);
|
|
654
|
+
space = checkDiskSpace([{ dir: path.dirname(cacheDir), bytes: zipDeclaredBytes(zipPath), purpose: 'unpacked brain' }],
|
|
655
|
+
{ what: 'install the brain' });
|
|
656
|
+
} catch { space = { ok: true }; } // unmeasurable: extraction's own limits still apply
|
|
657
|
+
if (!space.ok) die(space.message.split('\n')[0], 'Nothing was installed and nothing was changed.');
|
|
658
|
+
}
|
|
624
659
|
const stageDir = fs.mkdtempSync(path.join(path.dirname(cacheDir), `.${path.basename(cacheDir)}.install-stage-`));
|
|
625
660
|
const localCopy = async () => `local directory copy — ${copyLocalBundleInto(sourceDir, stageDir)} top-level entries`;
|
|
626
661
|
const nodeExtract = async () => {
|
|
@@ -747,6 +782,15 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
|
|
|
747
782
|
}
|
|
748
783
|
}
|
|
749
784
|
const parent = path.dirname(cacheDir);
|
|
785
|
+
// THE ACTIVATION IS IN PROGRESS from here until the prior generation is proven and released (review S6).
|
|
786
|
+
// A plain install holds no refresh lock, so this marker (with our pid) is what tells the detached
|
|
787
|
+
// SessionStart sweep (plugin/scripts/brain-footprint.mjs) to touch NOTHING beside the KB meanwhile — it
|
|
788
|
+
// used to be able to delete kb.install-prior-* between the two renames below, stranding the rollback.
|
|
789
|
+
// A die() leaves it naming a dead pid, which the sweep treats as finished.
|
|
790
|
+
const activationMarker = path.join(parent, `.${path.basename(cacheDir)}.install-activation.lock`);
|
|
791
|
+
const clearActivationMarker = () => { try { fs.rmSync(activationMarker, { force: true }); } catch { /* a dead pid reads as finished */ } };
|
|
792
|
+
fs.writeFileSync(activationMarker, `${JSON.stringify({ pid: process.pid, at: Date.now() })}\n`, { mode: 0o600 });
|
|
793
|
+
process.once('exit', clearActivationMarker);
|
|
750
794
|
const priorPrefix = `${path.basename(cacheDir)}.install-prior-`;
|
|
751
795
|
const unresolved = fs.readdirSync(parent).filter((name) => name.startsWith(priorPrefix));
|
|
752
796
|
if (unresolved.length) {
|
|
@@ -801,13 +845,33 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
|
|
|
801
845
|
hadPrior ? 'the prior brain generation was not moved' : 'no prior brain generation existed'}`,
|
|
802
846
|
`Candidate retained for inspection at ${stageDir}.`);
|
|
803
847
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
848
|
+
// THE CREATOR NO LONGER LEAVES A SECOND KB BEHIND (ADR-0098). The new generation just passed landed
|
|
849
|
+
// coverage validation, so the prior one is released the moment kbCopyProof shows nothing in it is
|
|
850
|
+
// unique: every private-store file byte-identical in the live brain, every store the live brain lacks
|
|
851
|
+
// of public provenance. A copy holding anything else is KEPT and its files are named. Measured
|
|
852
|
+
// 2026-10-01: two install-preserved copies (~2.4 GB) had outlived every proof that could release them.
|
|
853
|
+
let priorGeneration = null;
|
|
854
|
+
if (hadPrior) {
|
|
855
|
+
const proof = kbCopyProof({ copyDir: preservedDir, liveDir: cacheDir });
|
|
856
|
+
if (proof.disposable) {
|
|
857
|
+
try {
|
|
858
|
+
fs.rmSync(preservedDir, { recursive: true, force: true });
|
|
859
|
+
ok(`released the prior generation (${proof.reason})`);
|
|
860
|
+
priorGeneration = { status: 'RELEASED', path: preservedDir, reason: proof.reason };
|
|
861
|
+
} catch (error) {
|
|
862
|
+
warn(`prior generation could not be removed (${error.message}); the next update or npx ruvnet-brain --clean retries`);
|
|
863
|
+
priorGeneration = { status: 'PRESERVED_UNCLASSIFIED', path: preservedDir, automaticCleanupEligible: true };
|
|
864
|
+
}
|
|
865
|
+
} else {
|
|
866
|
+
warn(`KEPT the prior generation at ${preservedDir}: ${proof.reason}`);
|
|
867
|
+
for (const { file, why } of proof.unique.slice(0, 10)) info(c.dim(` ${file} — ${why}`));
|
|
868
|
+
priorGeneration = { status: 'PRESERVED_UNIQUE', path: preservedDir, unique: proof.unique, automaticCleanupEligible: false };
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
clearActivationMarker();
|
|
872
|
+
process.removeListener('exit', clearActivationMarker);
|
|
807
873
|
ok(`brain unpacked to ${cacheDir}`);
|
|
808
|
-
return { status: 'ACTIVATED', priorGeneration
|
|
809
|
-
? { status: 'PRESERVED_UNCLASSIFIED', path: preservedDir, automaticCleanupEligible: false }
|
|
810
|
-
: null };
|
|
874
|
+
return { status: 'ACTIVATED', priorGeneration };
|
|
811
875
|
}
|
|
812
876
|
|
|
813
877
|
/** Stage and validate a bundle for forge-update's private-overlay recovery rail
|
|
@@ -1297,6 +1361,30 @@ export function withLiveConsoleState(recorded, { receiptDir, alive, probe } = {}
|
|
|
1297
1361
|
return { ...recorded, consoleRuntime: live };
|
|
1298
1362
|
}
|
|
1299
1363
|
|
|
1364
|
+
/**
|
|
1365
|
+
* 4.3.40's ruflo leftovers inside this project's `.swarm` (cleanLegacyRufloDebris). --update removes
|
|
1366
|
+
* them; --doctor only reports (dryRun). Either way a REFUSED artifact (unexpected contents, a symlink) is
|
|
1367
|
+
* printed, never dropped silently. Not a project (or no .swarm): nothing to say.
|
|
1368
|
+
*/
|
|
1369
|
+
export function reportLegacyRufloDebris({ projectDir = process.cwd(), dryRun = false } = {}) {
|
|
1370
|
+
let storeDir;
|
|
1371
|
+
try { storeDir = path.dirname(resolveProjectStore({ projectDir }).canonicalAgentDbPath); } catch { return null; }
|
|
1372
|
+
if (!fs.existsSync(storeDir)) return null;
|
|
1373
|
+
const result = cleanLegacyRufloDebris(storeDir, { dryRun });
|
|
1374
|
+
for (const entry of result.removed) {
|
|
1375
|
+
// Dry run applies the same checks (allowlist, mirror proof, in-use window); only the final
|
|
1376
|
+
// unchanged-since-proof comparison can still keep it at --update time.
|
|
1377
|
+
if (dryRun) info(`legacy ruflo debris from 4.3.40 in ${entry}: passes every check; --update removes it unless it is written to before then`);
|
|
1378
|
+
else ok(`removed legacy ruflo debris from 4.3.40: ${entry}`);
|
|
1379
|
+
}
|
|
1380
|
+
for (const { path: entry, reason, kept } of result.refused) {
|
|
1381
|
+
// A KEPT nested AgentDB holds rows (or may still be written): never suggest deleting it by hand.
|
|
1382
|
+
if (kept) warn(`left 4.3.40's nested ruflo store in place — ${entry}: ${reason}. It is checked again on every update.`);
|
|
1383
|
+
else warn(`left legacy ruflo debris in place — ${entry}: ${reason}. Inspect it; remove it yourself if it is ruflo's.`);
|
|
1384
|
+
}
|
|
1385
|
+
return result;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1300
1388
|
export function consoleRestartState(identity, {
|
|
1301
1389
|
receiptDir = path.join(process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'console-instances'),
|
|
1302
1390
|
alive, probe,
|
|
@@ -1463,17 +1551,22 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1463
1551
|
const installedHookRetirement = claudeInstalledHookRetirementStatus({ plugin: installed });
|
|
1464
1552
|
if (installed.installed && versionSatisfies(installed.version, expectedVersion) && installedHookRetirement.ok) {
|
|
1465
1553
|
ok(`plugin installed at user scope (global, alongside Ruflo / RuVector) — exact version ${installed.version}`);
|
|
1466
|
-
if (shellBoundary.restartRequired) {
|
|
1467
|
-
|
|
1554
|
+
if (shellBoundary.restartRequired && shellBoundary.known) {
|
|
1555
|
+
info(` boot-level plugin declarations changed (${shellBoundary.paths.join(', ')}): new Claude Code sessions load them; already-open windows keep the old hook definitions until they are reopened.`);
|
|
1556
|
+
} else if (shellBoundary.restartRequired) {
|
|
1557
|
+
warn(`could not verify the plugin's boot-level declarations (${shellBoundary.reason}); restart Claude Code once to be sure they are loaded.`);
|
|
1468
1558
|
} else if (before.installed && before.version !== installed.version) {
|
|
1469
1559
|
info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
|
|
1470
1560
|
}
|
|
1471
|
-
info(` commands available${shellBoundary.restartRequired ? '
|
|
1561
|
+
info(` commands available${shellBoundary.restartRequired ? ' in new sessions' : ' immediately'}: ${c.bold('/rnbc')} (also /rnb, /rvbc), ${c.bold('/ruvnet-brain:configure')}`);
|
|
1472
1562
|
return {
|
|
1473
1563
|
host: true, wired: true, version: installed.version, manualMarketplace, manualInstall,
|
|
1474
1564
|
shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
|
|
1475
1565
|
restartRequired: shellBoundary.restartRequired,
|
|
1476
|
-
|
|
1566
|
+
// 'open-sessions': the new declarations are installed and PROVEN changed — only sessions already
|
|
1567
|
+
// open booted the old ones. 'unproven': the boot surface could not be compared at all.
|
|
1568
|
+
...(shellBoundary.restartRequired ? { sessionSafety: 'restart-required', sessionSafetyReason: shellBoundary.reason,
|
|
1569
|
+
restartScope: shellBoundary.known ? 'open-sessions' : 'unproven' } : {}),
|
|
1477
1570
|
};
|
|
1478
1571
|
}
|
|
1479
1572
|
|
|
@@ -1485,7 +1578,7 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1485
1578
|
? `Claude installed the plugin with ${installedHookRetirement.registrations.length} retired lifecycle registration(s); refusing to call this host converged.`
|
|
1486
1579
|
: mismatch
|
|
1487
1580
|
? `Claude installed ${installed.version || 'an unknown version'}, not required ${expectedVersion}; refusing to call this host converged.`
|
|
1488
|
-
: `the plugin did NOT land — so slash commands like ${c.bold('/
|
|
1581
|
+
: `the plugin did NOT land — so slash commands like ${c.bold('/rnbc')} will not exist yet.`);
|
|
1489
1582
|
info(`${c.green('Your brain still works')}: search_ruvnet is wired and Claude will ground answers with it.`);
|
|
1490
1583
|
info(`Only the plugin extras (slash commands, the Console, skills, and MCP declaration) are missing.`);
|
|
1491
1584
|
info(`Run these two yourself to finish:`);
|
|
@@ -1822,6 +1915,16 @@ const CODEX_PLUGIN_ID = 'ruvnet-brain@ruvnet-brain';
|
|
|
1822
1915
|
const CODEX_MARKETPLACE = 'ruvnet-brain';
|
|
1823
1916
|
const CODEX_MARKETPLACE_SOURCE = 'stuinfla/ruvnet-brain';
|
|
1824
1917
|
|
|
1918
|
+
/**
|
|
1919
|
+
* The Brain hooks Codex will hold back after replacing `installedRoot`'s plugin with `candidateRoot`'s:
|
|
1920
|
+
* [{ key, status: 'modified'|'untrusted' }]. No installed root = a fresh install = every hook untrusted.
|
|
1921
|
+
* An unreadable file is treated as absent (fail toward telling the user to review, never toward silence).
|
|
1922
|
+
*/
|
|
1923
|
+
export function codexHooksNeedingReview(installedRoot, candidateRoot) {
|
|
1924
|
+
const read = (root) => { try { return root ? JSON.parse(fs.readFileSync(path.join(root, 'hooks', 'codex-hooks.json'), 'utf8')) : {}; } catch { return {}; } };
|
|
1925
|
+
return codexTrustChanges(read(installedRoot), read(candidateRoot));
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1825
1928
|
function codexMarketplaceTarget() {
|
|
1826
1929
|
return path.join(
|
|
1827
1930
|
process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'),
|
|
@@ -1953,6 +2056,11 @@ export function wireCodexPlugin({
|
|
|
1953
2056
|
path.join(REPO_ROOT, 'plugin'),
|
|
1954
2057
|
)
|
|
1955
2058
|
: { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
|
|
2059
|
+
// Which Brain hooks will Codex hold back after this update until the user re-reviews them? Codex keys
|
|
2060
|
+
// trust to a hash of each hook's definition (scripts/codex-hook-trust.mjs), and codex-hooks.json is not
|
|
2061
|
+
// a boot-surface path, so the shell boundary above cannot see it. Computed from the two files, offline.
|
|
2062
|
+
const hooksNeedingReview = codexHooksNeedingReview(before.installed
|
|
2063
|
+
? codexInstalledPluginRoot({ codexHome, status: before }) : null, path.join(REPO_ROOT, 'plugin'));
|
|
1956
2064
|
if (before.installed && before.enabled && versionSatisfies(before.version, expectedVersion)) {
|
|
1957
2065
|
if (announce) ok(`Codex Brain plugin already installed and enabled (${before.version || 'version unknown'}) — no changes.`);
|
|
1958
2066
|
return {
|
|
@@ -2002,14 +2110,20 @@ export function wireCodexPlugin({
|
|
|
2002
2110
|
if (announce) {
|
|
2003
2111
|
ok(`Codex Brain plugin installed and enabled (${after.version || 'version unknown'}).`);
|
|
2004
2112
|
if (shellBoundary.restartRequired) {
|
|
2005
|
-
warn(`boot-level plugin declarations changed; restart Codex
|
|
2113
|
+
warn(`boot-level plugin declarations changed; restart Codex, then review them in /hooks (${shellBoundary.paths.join(', ') || shellBoundary.reason}).`);
|
|
2006
2114
|
} else if (before.installed && before.version !== after.version) {
|
|
2007
2115
|
info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
|
|
2008
2116
|
}
|
|
2117
|
+
if (hooksNeedingReview.length) {
|
|
2118
|
+
warn(`Codex will NOT run ${hooksNeedingReview.length} Brain hook${hooksNeedingReview.length === 1 ? '' : 's'} until you review`
|
|
2119
|
+
+ ` ${hooksNeedingReview.length === 1 ? 'it' : 'them'} (${hooksNeedingReview.map((h) => `${h.key.split(':').slice(-3).join(':')} ${h.status}`).join(', ')}).`);
|
|
2120
|
+
info(` ${CODEX_TRUST_ACTION}`);
|
|
2121
|
+
}
|
|
2009
2122
|
}
|
|
2010
2123
|
return {
|
|
2011
2124
|
host: true,
|
|
2012
2125
|
action: before.installed ? 'updated' : 'installed',
|
|
2126
|
+
hooksNeedingReview,
|
|
2013
2127
|
...after,
|
|
2014
2128
|
shellChanged: shellBoundary.changed,
|
|
2015
2129
|
shellChangedPaths: shellBoundary.paths,
|
|
@@ -2017,6 +2131,10 @@ export function wireCodexPlugin({
|
|
|
2017
2131
|
...(shellBoundary.restartRequired ? {
|
|
2018
2132
|
sessionSafety: 'restart-required',
|
|
2019
2133
|
sessionSafetyReason: shellBoundary.reason,
|
|
2134
|
+
// Never 'open-sessions' for Codex: changed hook definitions show as PENDING until reviewed in
|
|
2135
|
+
// /hooks (doctor fails closed on pending trust; the SessionStart notice says to trust them), and no
|
|
2136
|
+
// measurement shows a fresh Codex session running them without that step. Unproven = restart.
|
|
2137
|
+
restartScope: 'unproven',
|
|
2020
2138
|
} : {}),
|
|
2021
2139
|
};
|
|
2022
2140
|
}
|
|
@@ -2108,6 +2226,15 @@ export function classifyCodexLifecycle(plugin, listed = null) {
|
|
|
2108
2226
|
const unexpected = hooks.filter((hook) => !continuityHookId(hook?.command, hook?.event));
|
|
2109
2227
|
if (unexpected.length) return { state: 'unexpected-runtime-hooks', plugin, hooks: unexpected, errors };
|
|
2110
2228
|
if (conforming.length === 0) return { state: 'inactive-by-design', plugin, hooks, errors };
|
|
2229
|
+
// REGISTERED IS NOT RUNNING (4.5, review-4.3.39 #7). Codex runs a plugin hook only when its stored
|
|
2230
|
+
// trusted_hash equals the hash of its CURRENT definition (scripts/codex-hook-trust.mjs cites the source);
|
|
2231
|
+
// `untrusted` and `modified` hooks are listed but skipped. A release that edits a command's text leaves
|
|
2232
|
+
// that hook `modified` on every machine that trusted the old one — measured on the owner's machine
|
|
2233
|
+
// 2026-10-01: SessionEnd `modified` after 4.4.0, so it had silently stopped. A user-disabled hook is the
|
|
2234
|
+
// user's choice and is not reported as pending.
|
|
2235
|
+
const pending = conforming.filter((hook) => hook?.enabled !== false
|
|
2236
|
+
&& (hook?.trustStatus === 'untrusted' || hook?.trustStatus === 'modified'));
|
|
2237
|
+
if (pending.length) return { state: 'pending-trust', plugin, hooks: conforming, pending, errors };
|
|
2111
2238
|
return { state: 'continuity-registered', plugin, hooks: conforming, errors };
|
|
2112
2239
|
}
|
|
2113
2240
|
|
|
@@ -2140,6 +2267,18 @@ export function codexLifecycleGuidance(status) {
|
|
|
2140
2267
|
+ ' observe Stop or PreCompact, so no capture handler was registered on those.',
|
|
2141
2268
|
action: null,
|
|
2142
2269
|
};
|
|
2270
|
+
case 'pending-trust': {
|
|
2271
|
+
const pending = Array.isArray(status?.pending) ? status.pending : [];
|
|
2272
|
+
const named = pending.map((hook) => `${hook?.event ?? hook?.eventName ?? '?'} (${hook?.trustStatus})`).join(', ');
|
|
2273
|
+
return {
|
|
2274
|
+
healthy: false,
|
|
2275
|
+
intentional: false,
|
|
2276
|
+
summary: `Codex is NOT running ${pending.length} Brain hook${pending.length === 1 ? '' : 's'} until you review ${pending.length === 1 ? 'it' : 'them'}: ${named}.`,
|
|
2277
|
+
detail: 'Codex runs a hook only while its definition matches the one you trusted; an update that changed'
|
|
2278
|
+
+ ' a hook leaves it "modified", a new hook "untrusted", and Codex skips both without an error.',
|
|
2279
|
+
action: CODEX_TRUST_ACTION,
|
|
2280
|
+
};
|
|
2281
|
+
}
|
|
2143
2282
|
case 'inactive-by-design':
|
|
2144
2283
|
return {
|
|
2145
2284
|
healthy: true,
|
|
@@ -2413,7 +2552,7 @@ function gatherInstallState(cacheDir) {
|
|
|
2413
2552
|
try {
|
|
2414
2553
|
repos = fs
|
|
2415
2554
|
.readdirSync(cacheDir)
|
|
2416
|
-
.filter((f) => f.endsWith('.rvf')).length;
|
|
2555
|
+
.filter((f) => f.endsWith('.rvf') && !isVolumeMetadata(f)).length; // an exFAT `._x.rvf` shadow is not a store
|
|
2417
2556
|
} catch {
|
|
2418
2557
|
/* ignore */
|
|
2419
2558
|
}
|
|
@@ -2474,11 +2613,13 @@ function ensureVerifier(cacheDir) {
|
|
|
2474
2613
|
} catch { return 'unavailable'; }
|
|
2475
2614
|
}
|
|
2476
2615
|
|
|
2616
|
+
/** The installed verifier: the module, or { broken } when the file is there but will not load (an install
|
|
2617
|
+
* defect, ✗), or null when no verifier exists at all (neither installed nor shippable — "cannot settle"). */
|
|
2477
2618
|
async function loadCitationVerifier(cacheDir) {
|
|
2478
2619
|
ensureVerifier(cacheDir);
|
|
2479
2620
|
const p = path.join(cacheDir, 'verify-citation.mjs');
|
|
2480
2621
|
if (!fs.existsSync(p)) return null;
|
|
2481
|
-
try { return await import(pathToFileURL(p).href); } catch { return
|
|
2622
|
+
try { return await import(pathToFileURL(p).href); } catch (error) { return { broken: String(error?.message || error).slice(0, 200) }; }
|
|
2482
2623
|
}
|
|
2483
2624
|
|
|
2484
2625
|
// Proving grounding means proving the answer's CITATION RESOLVES — that the file it points at is a
|
|
@@ -2493,14 +2634,53 @@ export function resolveRuntimeModelCache(env = process.env, home = os.homedir())
|
|
|
2493
2634
|
|
|
2494
2635
|
async function smokeQuery(cacheDir) {
|
|
2495
2636
|
const ask = path.join(cacheDir, 'forge-ask-all.mjs');
|
|
2496
|
-
|
|
2637
|
+
// A missing reader is an INSTALL DEFECT, named as one (✗ with its fix), never a silent skip.
|
|
2638
|
+
if (!fs.existsSync(ask)) {
|
|
2639
|
+
warn('the reader forge-ask-all.mjs is not in the installed brain — the live question cannot be asked');
|
|
2640
|
+
return { ran: false, grounded: false, reason: 'reader-missing: forge-ask-all.mjs is not in the installed brain' };
|
|
2641
|
+
}
|
|
2497
2642
|
step(
|
|
2498
2643
|
'Asking the brain a real question',
|
|
2499
2644
|
'this warms the local model and checks that retrieval returns usable, cited evidence',
|
|
2500
2645
|
);
|
|
2501
2646
|
const Q = DOCTOR_SMOKE_QUERY;
|
|
2647
|
+
const modelCache = resolveRuntimeModelCache();
|
|
2648
|
+
// WARMING IS NOT ANSWERING. A cold cache means the first question also downloads and loads the
|
|
2649
|
+
// models, so a timeout could not tell "still fetching" from "broken". Fetch them first, as their
|
|
2650
|
+
// own step with their own bound and their own verdict; the question's limit then measures an
|
|
2651
|
+
// answer. A warm cache (every later doctor run) skips this step entirely.
|
|
2652
|
+
const cold = coldModels(cacheDir, modelCache);
|
|
2653
|
+
if (cold.length) {
|
|
2654
|
+
// The warm-up imports the reader's own modules. Missing ones are an incomplete install (✗, reinstall),
|
|
2655
|
+
// not a "could not prepare the models" crash with an ERR_MODULE_NOT_FOUND stack.
|
|
2656
|
+
const missing = ['forge-ask.mjs', 'forge-rerank.mjs'].filter((f) => !fs.existsSync(path.join(cacheDir, f)));
|
|
2657
|
+
if (missing.length) {
|
|
2658
|
+
warn(`the reader is incomplete — ${missing.join(', ')} missing from the installed brain`);
|
|
2659
|
+
return { ran: true, grounded: false, reason: `reader-incomplete: ${missing.join(', ')} missing from the installed brain` };
|
|
2660
|
+
}
|
|
2661
|
+
info(c.dim(`warming ${cold.length} local model(s) once: ${cold.join(', ')}`));
|
|
2662
|
+
const warmStarted = Date.now();
|
|
2663
|
+
const w = spawnSync(process.execPath, ['--input-type=module', '-e', MODEL_WARMUP_SCRIPT], {
|
|
2664
|
+
cwd: cacheDir, encoding: 'utf8', timeout: MODEL_WARMUP_TIMEOUT_MS,
|
|
2665
|
+
env: { ...process.env, KB_MODEL_CACHE: modelCache },
|
|
2666
|
+
});
|
|
2667
|
+
const warmSecs = ((Date.now() - warmStarted) / 1000).toFixed(1);
|
|
2668
|
+
if (w.status === 0) {
|
|
2669
|
+
ok(`local models ready in ${warmSecs}s (downloaded and loaded once; later questions skip this)`);
|
|
2670
|
+
} else if (w.status === 3) {
|
|
2671
|
+
info(c.dim('this bundle predates the separate warm-up step — the question below includes it'));
|
|
2672
|
+
} else {
|
|
2673
|
+
// ONE classification (installed-brain-health.mjs classifyWarmupFailure): spawnSync's own timeout is
|
|
2674
|
+
// advisory (a slow machine); a native crash (a signal with no ETIMEDOUT) is a broken runtime, ✗.
|
|
2675
|
+
const v = classifyWarmupFailure({ error: w.error, signal: w.signal, status: w.status, secs: warmSecs, limitSecs: MODEL_WARMUP_TIMEOUT_MS / 1000 });
|
|
2676
|
+
warn(`could not prepare the local models — the warm-up ${v.cause}`);
|
|
2677
|
+
const err = `${w.stderr || ''}`.trim();
|
|
2678
|
+
if (err) for (const line of err.split('\n').slice(0, 8)) info(c.dim(` ${line.slice(0, 200)}`));
|
|
2679
|
+
return { ran: true, grounded: false, reason: `model-warmup-${v.kind}: ${v.cause}`, stderr: err.slice(0, 4000),
|
|
2680
|
+
...(v.advisory ? { slow: true, warmupTimeout: true } : {}) };
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2502
2683
|
info(`Q: ${c.cyan(`"${Q}"`)}`);
|
|
2503
|
-
info(c.dim('(first run downloads a small local model once — this can take a minute)'));
|
|
2504
2684
|
const started = Date.now();
|
|
2505
2685
|
let r;
|
|
2506
2686
|
try {
|
|
@@ -2517,19 +2697,18 @@ async function smokeQuery(cacheDir) {
|
|
|
2517
2697
|
// path here makes the install smoke warm the model cache the product will actually reopen,
|
|
2518
2698
|
// instead of a second kb-local cache that can go green while the real door stays cold.
|
|
2519
2699
|
//
|
|
2520
|
-
// RUVNET_BRAIN_QUERY_DEADLINE_MS: this
|
|
2521
|
-
//
|
|
2522
|
-
//
|
|
2523
|
-
//
|
|
2524
|
-
//
|
|
2525
|
-
//
|
|
2526
|
-
//
|
|
2527
|
-
//
|
|
2528
|
-
//
|
|
2529
|
-
// else. Only applied if the caller hasn't already set an explicit override.
|
|
2700
|
+
// RUVNET_BRAIN_QUERY_DEADLINE_MS: 45s for this one probe, against the general 20s deadline
|
|
2701
|
+
// (kb/query-deadline.mjs DEFAULT_QUERY_DEADLINE_MS) that protects every normal query. Still
|
|
2702
|
+
// bounded — the module's core guarantee. The 27-29s once quoted here (public-verification runs
|
|
2703
|
+
// 36324328134, 36325803503) were THREE doctors started at once on the 3-vCPU/7GB macOS runner,
|
|
2704
|
+
// not one cold probe. Measured 2026-10-01 on that runner (ci-probe run 36882813925): model
|
|
2705
|
+
// download+load 6-7s (now its own step above), this question alone 6.6-10.4s with a warm cache
|
|
2706
|
+
// and 14-15s with the fetch inside it, three at once 24-34s; the real --doctor, alone, verified
|
|
2707
|
+
// in 11.8s (4.4.1) and 15.4s (4.4.0). 45s is ~3x the measured single-doctor cost. Only applied
|
|
2708
|
+
// if the caller hasn't already set an explicit override.
|
|
2530
2709
|
env: {
|
|
2531
2710
|
...process.env,
|
|
2532
|
-
KB_MODEL_CACHE:
|
|
2711
|
+
KB_MODEL_CACHE: modelCache,
|
|
2533
2712
|
RUVNET_BRAIN_QUERY_DEADLINE_MS: process.env.RUVNET_BRAIN_QUERY_DEADLINE_MS ?? '45000',
|
|
2534
2713
|
},
|
|
2535
2714
|
});
|
|
@@ -2547,11 +2726,10 @@ async function smokeQuery(cacheDir) {
|
|
|
2547
2726
|
// crash, a timeout, and a missing module all read as "nothing is wrong, it will warm up".
|
|
2548
2727
|
// Observed on this machine: a smoke query that produced no answer in 240s was reported as a
|
|
2549
2728
|
// first-run download. spawnSync already tells us which it was; say that instead.
|
|
2550
|
-
const
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
: `the reader exited 0 after ${secs}s but printed nothing`;
|
|
2729
|
+
const limitMs = Number(process.env.RUVNET_BRAIN_QUERY_DEADLINE_MS ?? 45000);
|
|
2730
|
+
const failure = classifySmokeFailure({ error: r.error, signal: r.signal, status: r.status,
|
|
2731
|
+
stderr: r.stderr, secs, limitSecs: Number.isFinite(limitMs) ? limitMs / 1000 : 45 });
|
|
2732
|
+
const { cause } = failure;
|
|
2555
2733
|
warn(`no answer came back — ${cause}`);
|
|
2556
2734
|
// SHOW THE ACTUAL ERROR (issue #37 bug 2, Agentist-Elder, 2026-07-21).
|
|
2557
2735
|
//
|
|
@@ -2574,17 +2752,29 @@ async function smokeQuery(cacheDir) {
|
|
|
2574
2752
|
}
|
|
2575
2753
|
// The reason travels with the verdict so the doctor's "Grounding NOT proven (<reason>)" line
|
|
2576
2754
|
// names the real cause too, instead of the generic token.
|
|
2577
|
-
return { ran: true, grounded: false, reason: `no-answer: ${cause}`, secs, stderr: err.slice(0, 4000)
|
|
2755
|
+
return { ran: true, grounded: false, reason: `no-answer: ${cause}`, secs, stderr: err.slice(0, 4000),
|
|
2756
|
+
slow: failure.kind === 'slow' };
|
|
2578
2757
|
}
|
|
2579
2758
|
|
|
2580
2759
|
const verifier = await loadCitationVerifier(cacheDir);
|
|
2760
|
+
if (verifier?.broken) {
|
|
2761
|
+
warn(`the installed citation verifier does not load (${verifier.broken}) — grounding cannot be proven until it is reinstalled`);
|
|
2762
|
+
return { ran: true, grounded: false, reason: `reader-broken: verify-citation.mjs does not load (${verifier.broken})` };
|
|
2763
|
+
}
|
|
2581
2764
|
if (!verifier) {
|
|
2582
2765
|
info(`the brain answered in ${secs}s, but this bundle predates the citation verifier —`);
|
|
2583
2766
|
info(c.dim(' re-run `npx ruvnet-brain` to refresh it, and grounding will be PROVEN, not assumed'));
|
|
2584
2767
|
return { ran: true, grounded: null, reason: 'verifier-missing' };
|
|
2585
2768
|
}
|
|
2586
2769
|
|
|
2587
|
-
|
|
2770
|
+
// A verifier that throws when CALLED is as broken as one that will not load: a named ✗, never a crash that
|
|
2771
|
+
// leaves --doctor --json with nothing to parse (re-review a6 SF4).
|
|
2772
|
+
let v;
|
|
2773
|
+
try { v = await verifier.verifyGrounding(out, cacheDir); } catch (error) {
|
|
2774
|
+
const why = String(error?.message || error).slice(0, 200);
|
|
2775
|
+
warn(`the installed citation verifier failed while checking the answer (${why}) — reinstall to repair it`);
|
|
2776
|
+
return { ran: true, grounded: false, reason: `reader-broken: verify-citation.mjs threw (${why})` };
|
|
2777
|
+
}
|
|
2588
2778
|
const evidence = classifySmokeEvidence(v, out);
|
|
2589
2779
|
if (v.grounded && !evidence.usable) {
|
|
2590
2780
|
warn(`the citation resolves, but the question was not answered with sufficient evidence (${evidence.reason})`);
|
|
@@ -2764,15 +2954,47 @@ function meterSummaryLine() {
|
|
|
2764
2954
|
// Honest prose that no machine can read is not a check. It could not gate a CI job, a nightly probe,
|
|
2765
2955
|
// or a `&&` in someone's shell. It now RETURNS a verdict and main() exits with it, so "needs
|
|
2766
2956
|
// attention" and "success" can never again be the same thing to a script.
|
|
2767
|
-
|
|
2957
|
+
// `--doctor --json` runs THIS SAME doctor (review S5: it used to print only the confirmation object, so the
|
|
2958
|
+
// text doctor and the JSON could give one machine two verdicts). The narration goes to stderr; stdout carries
|
|
2959
|
+
// only the ONE verdict object (brain-confirmation.mjs doctorVerdict), and the exit code is its exitCode.
|
|
2960
|
+
async function doctor({ json = false } = {}) {
|
|
2961
|
+
if (!json) return doctorRun({ json });
|
|
2962
|
+
const log = console.log;
|
|
2963
|
+
console.log = (...args) => console.error(...args);
|
|
2964
|
+
try { return await doctorRun({ json }); } finally { console.log = log; }
|
|
2965
|
+
}
|
|
2966
|
+
async function doctorRun({ json }) {
|
|
2768
2967
|
printBanner('doctor');
|
|
2769
2968
|
console.log(c.dim('Checking every part of the install and reporting green/red.\n'));
|
|
2770
2969
|
const cacheDir = process.env.RUVNET_BRAIN_KB || path.join(os.homedir(), '.cache', 'ruvnet-brain', 'kb');
|
|
2771
2970
|
info(`brain dir: ${c.bold(cacheDir)}`);
|
|
2971
|
+
{
|
|
2972
|
+
const where = brainLocation();
|
|
2973
|
+
if (where.state === 'linked') ok(`the Brain lives on another disk: ${where.real} (${where.path} links to it; that disk is mounted)`);
|
|
2974
|
+
}
|
|
2772
2975
|
const present = fs.existsSync(path.join(cacheDir, 'forge-mcp-all.mjs'));
|
|
2773
2976
|
if (!present) {
|
|
2774
|
-
|
|
2775
|
-
|
|
2977
|
+
// "not installed" is a FAILING doctor, not a neutral one — and the same verdict in both outputs. An
|
|
2978
|
+
// interrupted --move-brain may have left the ONLY copy at <home>.old-<pid>: its Move line names the `mv`
|
|
2979
|
+
// back (a fresh install over it would build a second, public-only Brain).
|
|
2980
|
+
let moveLines = [];
|
|
2981
|
+
try { // names only (measure:false): no tree walk on a machine that has no Brain here
|
|
2982
|
+
moveLines = confirm({ footprint: inventoryFootprint({ now: footprintNow(), measure: false }), installedVersion: PACKAGE_VERSION, now: footprintNow() })
|
|
2983
|
+
.lines.filter((l) => l.id === 'move-leftover');
|
|
2984
|
+
} catch { /* the install line stands */ }
|
|
2985
|
+
// A Brain an interrupted move set aside is RESTORED, never reinstalled over (re-review a6 SHOULD-FIX 1).
|
|
2986
|
+
const restore = moveLines.find((l) => l.state === 'fail');
|
|
2987
|
+
if (restore) warn(`the Brain is not at its path, but an interrupted move left it here — restore it, do NOT reinstall: ${restore.fix}`);
|
|
2988
|
+
else warn('brain not found here — run the installer first: npx ruvnet-brain');
|
|
2989
|
+
const verdict = doctorVerdict({ schemaVersion: 1, kind: 'ruvnet-brain-confirmation', lines: moveLines },
|
|
2990
|
+
[{ id: 'install', label: 'Install', state: 'fail', detail: `brain not found at ${cacheDir}`,
|
|
2991
|
+
fix: moveLines.find((l) => l.state === 'fail')?.fix || 'npx ruvnet-brain' }]);
|
|
2992
|
+
if (json) process.stdout.write(`${JSON.stringify(verdict, null, 2)}\n`);
|
|
2993
|
+
else {
|
|
2994
|
+
if (moveLines.length) console.log(`\n${formatConfirmation(verdict, { color: c, summary: false })}`);
|
|
2995
|
+
console.log(`\n ${c.red('✗ FAILING')} — ${verdict.failing.join(', ')}: run the fix named ${moveLines.length ? 'on each ✗ line' : 'above'}.`);
|
|
2996
|
+
}
|
|
2997
|
+
return verdict.exitCode;
|
|
2776
2998
|
}
|
|
2777
2999
|
const convergencePath = path.join(process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'host-convergence.json');
|
|
2778
3000
|
let hostConvergence = { healthy: true, state: 'not-recorded' };
|
|
@@ -2781,7 +3003,10 @@ async function doctor() {
|
|
|
2781
3003
|
const recorded = withLiveConsoleState(JSON.parse(fs.readFileSync(convergencePath, 'utf8')),
|
|
2782
3004
|
{ receiptDir: path.join(path.dirname(convergencePath), 'console-instances') });
|
|
2783
3005
|
hostConvergence = classifyHostConvergence(recorded);
|
|
2784
|
-
if (hostConvergence.healthy)
|
|
3006
|
+
if (hostConvergence.healthy) {
|
|
3007
|
+
ok(`host convergence receipt: ${hostConvergence.state}`);
|
|
3008
|
+
if (hostConvergence.notice) info(hostConvergence.notice);
|
|
3009
|
+
}
|
|
2785
3010
|
else {
|
|
2786
3011
|
warn(`host convergence incomplete: ${hostConvergence.state}`);
|
|
2787
3012
|
info(`Retry the same generation: ${c.bold('npx ruvnet-brain --update')}${hostConvergence.action ? `; ${hostConvergence.action}` : ''}`);
|
|
@@ -2791,6 +3016,7 @@ async function doctor() {
|
|
|
2791
3016
|
warn(`host convergence receipt is invalid: ${error.message}`);
|
|
2792
3017
|
}
|
|
2793
3018
|
}
|
|
3019
|
+
try { reportLegacyRufloDebris({ dryRun: true }); } catch (error) { warn(`legacy ruflo debris check failed: ${error.message}`); }
|
|
2794
3020
|
const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(cacheDir);
|
|
2795
3021
|
const nightlyHealth = schedulerStatus({ platform: process.platform, env: process.env,
|
|
2796
3022
|
brainHome, kbDir: cacheDir });
|
|
@@ -2835,9 +3061,17 @@ async function doctor() {
|
|
|
2835
3061
|
cleanupStrayRuvectorDb(); // issue #39: sweep a leftover empty scaffold from before this fix, if one's here
|
|
2836
3062
|
const env = detectEnvironment();
|
|
2837
3063
|
let rufloOperational = null;
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
3064
|
+
const rufloAt = locateRuflo();
|
|
3065
|
+
if (env.ruflo && !rufloAt.cli) {
|
|
3066
|
+
// Wired into ~/.claude/settings.json (npx) but no CLI to ask: the probe used to spawn a missing `ruflo`,
|
|
3067
|
+
// read the empty output as "operational", and print a ✓ it had not measured.
|
|
3068
|
+
rufloOperational = { configuredOnly: true };
|
|
3069
|
+
info('Ruflo is configured in ~/.claude/settings.json but no ruflo CLI is on PATH — not probed');
|
|
3070
|
+
} else if (env.ruflo) {
|
|
3071
|
+
rufloOperational = probeRufloOperationalHealth({ cli: rufloAt.cli });
|
|
3072
|
+
if (rufloOperational.notInitialized) {
|
|
3073
|
+
info('Ruflo CLI present; this directory has not run `ruflo init`, so there is no project learning to judge here (not a failure)');
|
|
3074
|
+
} else if (rufloOperational.healthy) {
|
|
2841
3075
|
ok(rufloOperational.directMode
|
|
2842
3076
|
? 'Ruflo direct mode ready — daemon/swarm is stopped by design; AgentDB remains CLI-backed'
|
|
2843
3077
|
: 'Ruflo operational — runtime, memory, and learning signals agree');
|
|
@@ -2905,7 +3139,12 @@ async function doctor() {
|
|
|
2905
3139
|
console.log(` really exists in your local KB. Checked in ${smoke.secs}s, no cloud, no API key.`);
|
|
2906
3140
|
} else if (smoke.grounded === false) {
|
|
2907
3141
|
console.log(` ${c.yellow('! Grounding NOT proven')} (${smoke.reason}). The install is present but the brain did not`);
|
|
2908
|
-
|
|
3142
|
+
// A timeout mid-answer is not a damaged KB, and reinstalling a working brain does not make the
|
|
3143
|
+
// machine faster — so that case gets its own, true advice. Still NOT proven, still failing.
|
|
3144
|
+
console.log(smoke.slow
|
|
3145
|
+
? ' answer inside the limit. The reader was working, not broken — a reinstall will not help. Run\n'
|
|
3146
|
+
+ ' npx ruvnet-brain --doctor again when the machine is less busy.'
|
|
3147
|
+
: ' answer from a verifiable source. Re-run npx ruvnet-brain to repair the KB.');
|
|
2909
3148
|
} else if (smoke.grounded === null) {
|
|
2910
3149
|
console.log(` ${c.yellow('! Grounding not verifiable')} on this bundle — it predates the citation verifier.`);
|
|
2911
3150
|
console.log(' Re-run npx ruvnet-brain to refresh, then --doctor will prove it.');
|
|
@@ -2954,6 +3193,27 @@ async function doctor() {
|
|
|
2954
3193
|
c.dim('\n Heads-up: a window that was ALREADY open when you installed needs a restart to pick it up;\n newly-opened windows are fine.\n'),
|
|
2955
3194
|
);
|
|
2956
3195
|
|
|
3196
|
+
// ── AGENTDB RECORDING (ADR-100 §4) — positive confirmation, or the loud reason it is not ────────
|
|
3197
|
+
// For the project --doctor is run from. Read-only: the outbox and the store are inspected, nothing
|
|
3198
|
+
// is written. A project without `.swarm` has not adopted the store and gets no line. It is a LINE OF THE
|
|
3199
|
+
// ONE VERDICT (re-review S2: it used to be narration only, so --json never saw it): advisory '!' when
|
|
3200
|
+
// stuck — recording is the project's opt-in memory, not the Brain's health — ✓ when proven, ○ otherwise.
|
|
3201
|
+
let agentdbLine = null;
|
|
3202
|
+
try {
|
|
3203
|
+
const { ContinuityJournal, recordingLine } = await import('../plugin/scripts/continuity-journal.mjs');
|
|
3204
|
+
const { resolveProjectStore } = await import('../plugin/scripts/project-store-resolver.mjs');
|
|
3205
|
+
const journal = new ContinuityJournal({ projectRoot: resolveProjectStore({ projectDir: process.cwd() }).projectRoot });
|
|
3206
|
+
if (fs.existsSync(journal.swarm)) {
|
|
3207
|
+
const status = journal.status();
|
|
3208
|
+
const detail = recordingLine(status).replace(/^AgentDB: /, '');
|
|
3209
|
+
agentdbLine = { id: 'agentdb', label: 'AgentDB', detail,
|
|
3210
|
+
state: status.stuck ? 'warn' : status.lastCommitAt && !status.notApplicable ? 'ok' : 'unknown',
|
|
3211
|
+
fix: status.stuck ? (status.problem === 'stuck-pending' ? 'ruflo doctor --fix (then the next session drains the outbox)' : 'node <plugin>/scripts/continuity-brief.mjs --clear') : null };
|
|
3212
|
+
}
|
|
3213
|
+
} catch (error) {
|
|
3214
|
+
agentdbLine = { id: 'agentdb', label: 'AgentDB', state: 'unknown', detail: `recording status unavailable: ${error.message}`, fix: null };
|
|
3215
|
+
}
|
|
3216
|
+
|
|
2957
3217
|
// ── THE MECHANICAL VERDICT ────────────────────────────────────────────────────────────────────
|
|
2958
3218
|
// `--hooks` is retained as a compatibility alias for a read-only zero-registration proof. It must
|
|
2959
3219
|
// never execute dormant hook bodies.
|
|
@@ -2980,12 +3240,14 @@ async function doctor() {
|
|
|
2980
3240
|
// "unproven" verdict DOES gate the exit code. Its successful live citation proof above must clear
|
|
2981
3241
|
// an older failure before this read; otherwise one invocation can print both PROVEN and UNPROVEN.
|
|
2982
3242
|
let groundingUnprovenPersisted = false;
|
|
3243
|
+
let persistedGrounding = null;
|
|
2983
3244
|
try {
|
|
2984
3245
|
const mod = await import(new URL('../scripts/selfcheck.mjs', import.meta.url).href);
|
|
2985
3246
|
if (smoke.grounded === true) {
|
|
2986
|
-
mod.writeInstallState({ grounding: 'proven', reason: null, clearedBy: 'doctor-live-proof' });
|
|
3247
|
+
mod.writeInstallState({ grounding: 'proven', reason: null, clearedBy: 'doctor-live-proof', coverageSha256: liveCoverageSha256(cacheDir) });
|
|
2987
3248
|
}
|
|
2988
|
-
|
|
3249
|
+
persistedGrounding = mod.readInstallState();
|
|
3250
|
+
groundingUnprovenPersisted = mod.groundingUnproven(persistedGrounding);
|
|
2989
3251
|
if (groundingUnprovenPersisted) {
|
|
2990
3252
|
console.log(` ${c.yellow('! Grounding UNPROVEN')} (recorded at ${c.bold(mod.installStatePath())}).`);
|
|
2991
3253
|
console.log(` This is what makes ${c.bold('--doctor')} fail here even though nothing above crashed — re-run`);
|
|
@@ -3007,24 +3269,44 @@ async function doctor() {
|
|
|
3007
3269
|
const codexTrustBypassed = process.env.RUVNET_CODEX_HOOK_TRUST_MODE === 'bypass';
|
|
3008
3270
|
const codexWiringFailed = Boolean(cx.host && !cx.wired);
|
|
3009
3271
|
const codexReadinessFailed = Boolean(codexMcp?.blocking);
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|| (nightlyHealth.state === 'on' && !['ok', 'running'].includes(nightlyHealth.runHealth?.state))
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3272
|
+
// ADR-0098 positive confirmation (read-only here; `--clean` enforces) PLUS every doctor check, as lines of
|
|
3273
|
+
// ONE block, judged by ONE function (doctorVerdict): ✗ anywhere fails; ! (currency: KB age, a host plugin
|
|
3274
|
+
// behind, npm newer) advises and never fails, so a correctly installed older build still verifies.
|
|
3275
|
+
const confirmation = await printConfirmation({ print: false });
|
|
3276
|
+
const check = (id, label, failedNow, detail, fix, { advisory = false } = {}) => ({ id, label,
|
|
3277
|
+
state: failedNow ? (advisory ? 'warn' : 'fail') : 'ok', detail, fix: failedNow ? fix : null });
|
|
3278
|
+
const nightlyFailed = nightlyHealth.state === 'degraded'
|
|
3279
|
+
|| (nightlyHealth.state === 'on' && !['ok', 'running'].includes(nightlyHealth.runHealth?.state));
|
|
3280
|
+
const codexFailed = codexWiringFailed || codexReadinessFailed || (codexLifecycleFailed && !codexTrustBypassed);
|
|
3281
|
+
const checks = [
|
|
3282
|
+
// With --hooks the install reading was never part of the verdict (release install verification); keep that.
|
|
3283
|
+
check('install', 'Install', !allGreen, `${v.repos} store(s), reader ${v.reader ? 'present' : 'MISSING'}, search server ${v.mcp ? 'present' : 'MISSING'}`,
|
|
3284
|
+
'npx ruvnet-brain', { advisory: Boolean(hookResult) }),
|
|
3285
|
+
...(hookResult ? [check('hooks', 'Hooks', hookResult.exitCode !== 0, 'automatic Brain hook continuity policy', 'npx ruvnet-brain')] : []),
|
|
3286
|
+
check('identity', 'Identity', !installedIdentity.healthy, installedIdentity.healthy ? 'search engine, validator and archive manifest agree'
|
|
3287
|
+
: installedIdentity.issues.join('; '), 'npx ruvnet-brain@latest --update'),
|
|
3288
|
+
groundingCheckLine({ smoke, persisted: persistedGrounding, coverageSha256: liveCoverageSha256(cacheDir) }),
|
|
3289
|
+
...(cx.host ? [check('codex', 'Codex', codexFailed, codexFailed ? (codexWiringFailed ? 'host detected but NOT wired'
|
|
3290
|
+
: codexReadinessFailed ? 'MCP readiness blocked' : 'lifecycle hooks unhealthy') : 'wired', 'npx ruvnet-brain')] : []),
|
|
3291
|
+
check('nightly', 'Nightly', nightlyFailed, `${nightlyHealth.state}${nightlyHealth.runHealth?.state ? `, last run ${nightlyHealth.runHealth.state}` : ''}`,
|
|
3292
|
+
nightlyHealth.state === 'degraded' ? 'npx ruvnet-brain --enable-nightly' : 'npx ruvnet-brain --update'),
|
|
3293
|
+
check('host-convergence', 'Hosts sync', !hostConvergence.healthy, hostConvergence.state, 'npx ruvnet-brain --update'),
|
|
3294
|
+
...(rufloOperational ? [rufloCheckLine(rufloOperational)] : []),
|
|
3295
|
+
...(agentdbLine ? [agentdbLine] : []),
|
|
3296
|
+
];
|
|
3297
|
+
// THE ONE VERDICT. Text, --json and the exit code are all read from this object; nothing else decides.
|
|
3298
|
+
const verdict = doctorVerdict(confirmation, checks);
|
|
3299
|
+
if (json) {
|
|
3300
|
+
process.stdout.write(`${JSON.stringify(verdict, null, 2)}\n`);
|
|
3024
3301
|
} else {
|
|
3025
|
-
console.log(`\n
|
|
3302
|
+
console.log(`\n${formatConfirmation(verdict, { color: c, summary: false })}`);
|
|
3303
|
+
if (!verdict.ok) {
|
|
3304
|
+
console.log(`\n ${c.red('✗ FAILING')} — ${verdict.failing.join(', ')}: run the fix named on each ✗ line.`);
|
|
3305
|
+
} else {
|
|
3306
|
+
console.log(`\n ${c.green('✓ Healthy.')} The brain is installed, reachable, and its checks pass${verdict.advisories.length ? ` (${verdict.advisories.length} advisory ! line(s): ${verdict.advisories.join(', ')})` : ''}.`);
|
|
3307
|
+
}
|
|
3026
3308
|
}
|
|
3027
|
-
return
|
|
3309
|
+
return verdict.exitCode;
|
|
3028
3310
|
}
|
|
3029
3311
|
|
|
3030
3312
|
// ── the post-install self-check, wired for both --doctor --hooks and the installer's last step ────
|
|
@@ -3206,7 +3488,7 @@ function feedbackHealthLines(cacheDir) {
|
|
|
3206
3488
|
const env = detectEnvironment();
|
|
3207
3489
|
const allGreen = s.repos > 0 && s.reader && s.mcp;
|
|
3208
3490
|
return [
|
|
3209
|
-
`${s.repos} repo stores on disk · reader ${s.reader ? 'ok' : 'MISSING'} · search_ruvnet ${s.mcp ? 'ok' : 'MISSING'} · plugin ${s.plugin ? 'ok' : 'NOT INSTALLED (no /
|
|
3491
|
+
`${s.repos} repo stores on disk · reader ${s.reader ? 'ok' : 'MISSING'} · search_ruvnet ${s.mcp ? 'ok' : 'MISSING'} · plugin ${s.plugin ? 'ok' : 'NOT INSTALLED (no /rnbc)'}`,
|
|
3210
3492
|
`toolkit: Ruflo ${env.ruflo ? 'present' : 'not found'} · RuVector ${env.ruvector ? 'present' : 'not found'} · claude CLI ${env.claude ? 'present' : 'not found'}`,
|
|
3211
3493
|
// NOT called a "verdict": this reads only repos/reader/mcp, while --doctor's verdict also weighs
|
|
3212
3494
|
// grounding, Codex wiring, nightly health, host convergence and the hook policy. Two lines both
|
|
@@ -3306,6 +3588,11 @@ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = n
|
|
|
3306
3588
|
if (/^unresolved rollback state exists/.test(String(result?.reason || ''))) {
|
|
3307
3589
|
return { verdict: 'refused-retained-copies', fallback: false, exitCode: status || 1 };
|
|
3308
3590
|
}
|
|
3591
|
+
// Exit 6 / "not enough free disk space": the updater measured before unpacking and touched nothing. A
|
|
3592
|
+
// fresh-install fallback needs at least as much room, so it would only fail later and messier.
|
|
3593
|
+
if (status === 6 || /^not enough free disk space/.test(String(result?.reason || ''))) {
|
|
3594
|
+
return { verdict: 'refused-disk-space', fallback: false, exitCode: status || 6 };
|
|
3595
|
+
}
|
|
3309
3596
|
// Exit 2 is "manifest unreachable, nothing touched". The fallback exists for a DEAD manifest URL (an old
|
|
3310
3597
|
// bundle polling a path that 404s); a rate limit, a 5xx or no network is transient, and a full fresh
|
|
3311
3598
|
// reinstall over it re-downloads the brain and preserves another full copy each time. Retry later instead.
|
|
@@ -3464,6 +3751,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3464
3751
|
codexReceipt.restartRequired = true;
|
|
3465
3752
|
codexReceipt.sessionSafety = results.codex.sessionSafety || null;
|
|
3466
3753
|
codexReceipt.sessionSafetyReason = results.codex.sessionSafetyReason || null;
|
|
3754
|
+
codexReceipt.restartScope = results.codex.restartScope || 'unproven';
|
|
3467
3755
|
}
|
|
3468
3756
|
const claudeReceipt = {
|
|
3469
3757
|
state: results.claude.host ? 'ready' : 'absent',
|
|
@@ -3473,6 +3761,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3473
3761
|
claudeReceipt.restartRequired = true;
|
|
3474
3762
|
claudeReceipt.sessionSafety = results.claude.sessionSafety || null;
|
|
3475
3763
|
claudeReceipt.sessionSafetyReason = results.claude.sessionSafetyReason || null;
|
|
3764
|
+
claudeReceipt.restartScope = results.claude.restartScope || 'unproven';
|
|
3476
3765
|
}
|
|
3477
3766
|
if (okApplied) {
|
|
3478
3767
|
// ISSUE #153 — a running host may freeze an old plugin root. Reclaim only generations whose
|
|
@@ -3563,9 +3852,16 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
|
|
|
3563
3852
|
return { healthy: false, state: 'version-mismatch', action: `required version ${expectedVersion}` };
|
|
3564
3853
|
}
|
|
3565
3854
|
const hostStates = Object.values(receipt.hosts || {});
|
|
3566
|
-
|
|
3855
|
+
// A host that is ready at the expected version and whose ONLY gap is PROVEN changed boot-level
|
|
3856
|
+
// declarations is converged: new sessions load the new hooks; only windows already open booted the
|
|
3857
|
+
// old ones (owner's Mac, 4.3.40 -> 4.4.0: a fresh `claude -p` loaded 4.4.0 and ran its hooks). That is
|
|
3858
|
+
// reported, not failed. An UNPROVEN boot surface (it could not be compared) still requires a restart.
|
|
3859
|
+
const openSessionsOnly = (host) => host?.state === 'ready' && versionSatisfies(host.version, expectedVersion)
|
|
3860
|
+
&& host.restartRequired === true && host.restartScope === 'open-sessions';
|
|
3861
|
+
const openSessions = Object.entries(receipt.hosts || {}).filter(([, host]) => openSessionsOnly(host)).map(([name]) => name);
|
|
3862
|
+
const badHost = hostStates.find((host) => !openSessionsOnly(host) && (!['ready', 'disabled', 'absent'].includes(host?.state)
|
|
3567
3863
|
|| (host.state === 'ready' && !versionSatisfies(host.version, expectedVersion))
|
|
3568
|
-
|| (host.state === 'ready' && host.restartRequired === true));
|
|
3864
|
+
|| (host.state === 'ready' && host.restartRequired === true)));
|
|
3569
3865
|
if (badHost?.restartRequired === true) {
|
|
3570
3866
|
return { healthy: false, state: 'host-restart-required', action: badHost.sessionSafetyReason || 'restart the host, then re-run --doctor' };
|
|
3571
3867
|
}
|
|
@@ -3575,11 +3871,28 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
|
|
|
3575
3871
|
? `the installer could not replace the running Console (${receipt.consoleRuntime.replacementFailures.join('; ')}); ` : '';
|
|
3576
3872
|
return { healthy: false, state: receipt.consoleRuntime?.state || 'console-unproven', action: `${why}restart Console, then re-run --doctor` };
|
|
3577
3873
|
}
|
|
3874
|
+
if (openSessions.length) return { healthy: true, state: 'channels-converged', openSessions, notice: openSessionsNotice(openSessions, receipt.desiredVersion) };
|
|
3578
3875
|
return { healthy: true, state: 'channels-converged' };
|
|
3579
3876
|
}
|
|
3580
3877
|
|
|
3878
|
+
const HOST_LABELS = { claude: 'Claude Code', codex: 'Codex' };
|
|
3879
|
+
/** The one accurate line for converged hosts whose already-open windows booted the old declarations. */
|
|
3880
|
+
export function openSessionsNotice(hosts, version) {
|
|
3881
|
+
const names = hosts.map((host) => HOST_LABELS[host] || host).join('/');
|
|
3882
|
+
return `new ${names} sessions use ${version}; already-open windows keep the old hook definitions until they are reopened`;
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
/** A moved brain whose disk is unplugged (plugin/scripts/brain-location.mjs state 'unmounted') is never
|
|
3886
|
+
* reinstalled, updated or cleaned beside the dead link (ADR-098): stop with that module's one line. */
|
|
3887
|
+
function refuseUnmountedBrain() {
|
|
3888
|
+
const roots = footprintRoots();
|
|
3889
|
+
if (!roots.dangling) return;
|
|
3890
|
+
die(roots.location.message, 'Nothing was installed, updated or removed.');
|
|
3891
|
+
}
|
|
3892
|
+
|
|
3581
3893
|
async function runUpdate() {
|
|
3582
3894
|
printBanner('update');
|
|
3895
|
+
refuseUnmountedBrain();
|
|
3583
3896
|
const kbDir = resolvedKbDir();
|
|
3584
3897
|
const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(kbDir);
|
|
3585
3898
|
if (FLAG_HOST_SYNC_ONLY) {
|
|
@@ -3590,6 +3903,7 @@ async function runUpdate() {
|
|
|
3590
3903
|
process.exitCode = 1;
|
|
3591
3904
|
return;
|
|
3592
3905
|
}
|
|
3906
|
+
if (convergence.convergence?.notice) info(convergence.convergence.notice);
|
|
3593
3907
|
try {
|
|
3594
3908
|
const managed = applyManagedCatalogUpdate({
|
|
3595
3909
|
routerDir: path.join(os.homedir(), '.claude', 'model-router'),
|
|
@@ -3694,6 +4008,12 @@ async function runUpdate() {
|
|
|
3694
4008
|
} catch (error) {
|
|
3695
4009
|
warn(`could not place the trusted coverage validator (${error.message}); the updater will report what it finds`);
|
|
3696
4010
|
}
|
|
4011
|
+
// ADR-0098: release every KB copy that is PROVEN disposable before the updater looks. Its preflight
|
|
4012
|
+
// refuses ("unresolved rollback state exists") while a full copy it cannot account for sits beside the
|
|
4013
|
+
// live KB, and its own redundancy proof can never account for an older generation — measured
|
|
4014
|
+
// 2026-10-01: three copies (3.6 GB) kept every later update from running at all. This runs under the
|
|
4015
|
+
// refresh lock this process already holds; private-unique copies stay and are named.
|
|
4016
|
+
enforceFootprint({ holdingRefreshLock: true, quiet: true });
|
|
3697
4017
|
info(c.dim("running the bundle's own self-updater (backs up first, re-verifies, never half-applies)…\n"));
|
|
3698
4018
|
// Relative filename + matching cwd — same launch convention as smokeQuery(); stdio:'inherit'
|
|
3699
4019
|
// streams the updater's narration live and unedited.
|
|
@@ -3794,6 +4114,33 @@ async function runUpdate() {
|
|
|
3794
4114
|
return;
|
|
3795
4115
|
}
|
|
3796
4116
|
if (cleanupPending) updateStatus = 0;
|
|
4117
|
+
// The updater refuses unsigned or mis-signed bundles (exit 3/4), so an applied result names bytes whose
|
|
4118
|
+
// signature verified. Record it bound to the live COVERAGE.json (ADR-0098 positive confirmation).
|
|
4119
|
+
if (updaterResult?.terminalVerdict === 'applied' && updaterResult.bundleSha256) {
|
|
4120
|
+
try {
|
|
4121
|
+
const corpusTag = JSON.parse(fs.readFileSync(path.join(kbDir, 'SOURCE.json'), 'utf8')).corpusReleaseTag || null;
|
|
4122
|
+
writeSignatureRecord({ brainHome: footprintRoots().brainHome, kbDir, bundleSha256: updaterResult.bundleSha256,
|
|
4123
|
+
releaseTag: corpusTag, source: 'update', now: footprintNow() });
|
|
4124
|
+
} catch (error) { warn(`signature verification could not be recorded (${error.message})`); }
|
|
4125
|
+
} else if (updateStatus === 0) {
|
|
4126
|
+
// Nothing was applied (already current). The doctor names `--update` as the fix for a missing record, so
|
|
4127
|
+
// this run must be able to write it — but only from proof: this machine's own receipt of an update that
|
|
4128
|
+
// APPLIED a signature-verified bundle whose coverage digest equals the live COVERAGE.json (review S5).
|
|
4129
|
+
const roots = footprintRoots();
|
|
4130
|
+
try {
|
|
4131
|
+
if (!signatureRecordValid({ brainHome: roots.brainHome, kbDir })) {
|
|
4132
|
+
const evidence = signatureEvidenceFromReceipts({ brainHome: roots.brainHome, kbDir });
|
|
4133
|
+
if (evidence) {
|
|
4134
|
+
writeSignatureRecord({ brainHome: roots.brainHome, kbDir, bundleSha256: evidence.bundleSha256,
|
|
4135
|
+
source: `update-receipt:${evidence.runId}`, now: footprintNow() });
|
|
4136
|
+
ok('signature verification recorded for the live knowledge (from this machine\'s verified update receipt)');
|
|
4137
|
+
} else {
|
|
4138
|
+
warn('no signature verification is on record for the live knowledge, and no verified update of these exact bytes is either;');
|
|
4139
|
+
info(`it is recorded by the next update that applies a signed release, or now by a verified reinstall: ${c.bold('npx ruvnet-brain@latest --force')}`);
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4142
|
+
} catch (error) { warn(`signature verification could not be recorded (${error.message})`); }
|
|
4143
|
+
}
|
|
3797
4144
|
let phaseEvidence = updaterResult?.phaseEvidence || null;
|
|
3798
4145
|
if (!phaseEvidence) {
|
|
3799
4146
|
const installed = validateCoverageDirectory(kbDir, { expectedVersion: PACKAGE_VERSION });
|
|
@@ -3826,9 +4173,11 @@ async function runUpdate() {
|
|
|
3826
4173
|
if (!convergence.ok) {
|
|
3827
4174
|
warn(`host synchronization is incomplete — runtime stays on the prior verified generation${convergence.error ? ` (${convergence.error})` : ''}`);
|
|
3828
4175
|
updateStatus = 1;
|
|
3829
|
-
}
|
|
4176
|
+
} else if (convergence.convergence?.notice) info(convergence.convergence.notice);
|
|
4177
|
+
try { reportLegacyRufloDebris(); } catch (error) { warn(`legacy ruflo debris cleanup failed: ${error.message}`); }
|
|
3830
4178
|
recordRefreshPhase(refreshReceipt, 'host-convergence', convergence.ok && convergence.convergence?.healthy === true ? 'PASS' : 'FAIL', {
|
|
3831
4179
|
state: convergence.convergence?.state || null, error: convergence.error || null,
|
|
4180
|
+
...(convergence.convergence?.openSessions ? { openSessions: convergence.convergence.openSessions } : {}),
|
|
3832
4181
|
execution: { kind: 'executed', runId: refreshReceipt.runId },
|
|
3833
4182
|
});
|
|
3834
4183
|
}
|
|
@@ -3879,12 +4228,24 @@ async function runUpdate() {
|
|
|
3879
4228
|
execution: { kind: 'executed', runId: refreshReceipt.runId },
|
|
3880
4229
|
required: cleanupFailed || updateStatus === 0,
|
|
3881
4230
|
});
|
|
4231
|
+
// ADR-0098: after the swap, enforce the footprint (the updater just released its own rollback; this
|
|
4232
|
+
// releases everything else that must not exist) and record the result on the receipt as an advisory —
|
|
4233
|
+
// a footprint problem is reported, it never fails an otherwise-good update.
|
|
4234
|
+
const footprint = enforceFootprint({ holdingRefreshLock: true });
|
|
4235
|
+
try {
|
|
4236
|
+
const after = footprint?.after;
|
|
4237
|
+
recordRefreshAdvisory(refreshReceipt, 'footprint', after && after.kbCopies === 1 && !after.cruft.length && after.withinBudget ? 'PASS' : 'FAIL', {
|
|
4238
|
+
kbCopies: after?.kbCopies ?? null, cruft: after?.cruft.length ?? null, totalBytes: after?.totalBytes ?? null,
|
|
4239
|
+
budgetBytes: after?.budgetBytes ?? null, removed: footprint?.removed.length ?? 0, freedBytes: footprint?.freedBytes ?? 0,
|
|
4240
|
+
});
|
|
4241
|
+
} catch { /* the advisory never blocks settlement */ }
|
|
3882
4242
|
settleRefresh(cleanupPending ? 12 : (retentionFailed ? 1 : updateStatus), {
|
|
3883
4243
|
phase: cleanupFailed ? 'cleanup' : (updateStatus === 0 ? 'complete' : 'failed'),
|
|
3884
4244
|
terminalVerdict: cleanupPending ? 'cleanup-pending' : retentionFailed ? 'recovery-required'
|
|
3885
4245
|
: (outcome.verdict === 'noop' ? 'noop' : 'applied'),
|
|
3886
4246
|
storageDelta: updaterResult?.storageDelta || null, lifecycleRetention: retention });
|
|
3887
4247
|
process.removeListener('exit', exitGuard);
|
|
4248
|
+
await printConfirmation({ footprint: footprint?.after || null });
|
|
3888
4249
|
}
|
|
3889
4250
|
|
|
3890
4251
|
function enableNightly() {
|
|
@@ -4062,6 +4423,63 @@ const upgradeNoticeStatePath = () =>
|
|
|
4062
4423
|
const brainOffSentinelPath = () =>
|
|
4063
4424
|
path.join(process.env.RUVNET_BRAIN_STATE_DIR || path.join(os.homedir(), '.config', 'ruvnet-brain'), 'brain-off');
|
|
4064
4425
|
|
|
4426
|
+
// ── ADR-0098: THE FOOTPRINT GUARANTEE — one KB, current, in use, nothing building up ─────────────
|
|
4427
|
+
// The classifier and sweep live in plugin/scripts/brain-footprint.mjs (shared with SessionStart's
|
|
4428
|
+
// detached sweep); this file supplies the two collectors only it may run: the lease-aware plugin
|
|
4429
|
+
// generation collector and the lifecycle-evidence pruner. Test seams (RUVNET_BRAIN_TEST=1 only):
|
|
4430
|
+
// RUVNET_BRAIN_TEST_NPM_LATEST pins the registry answer, RUVNET_BRAIN_TEST_NOW the clock.
|
|
4431
|
+
const footprintNow = () => (process.env.RUVNET_BRAIN_TEST === '1' && Date.parse(process.env.RUVNET_BRAIN_TEST_NOW || ''))
|
|
4432
|
+
|| Date.now();
|
|
4433
|
+
async function npmLatestVersion() {
|
|
4434
|
+
if (process.env.RUVNET_BRAIN_TEST === '1' && process.env.RUVNET_BRAIN_TEST_NPM_LATEST) {
|
|
4435
|
+
return { version: process.env.RUVNET_BRAIN_TEST_NPM_LATEST, checkedAt: footprintNow(), source: 'test registry seam' };
|
|
4436
|
+
}
|
|
4437
|
+
try {
|
|
4438
|
+
const response = await fetch('https://registry.npmjs.org/ruvnet-brain/latest', { signal: AbortSignal.timeout(3_000) });
|
|
4439
|
+
const metadata = response.ok ? await response.json() : null;
|
|
4440
|
+
return typeof metadata?.version === 'string' ? { version: metadata.version, checkedAt: Date.now(), source: 'npm registry, live' } : null;
|
|
4441
|
+
} catch { return null; }
|
|
4442
|
+
}
|
|
4443
|
+
function footprintEvidence() {
|
|
4444
|
+
const { brainHome, kbDir } = footprintRoots();
|
|
4445
|
+
try { return assessLifecycleEvidence({ brainHome, kbDir }); } catch { return null; }
|
|
4446
|
+
}
|
|
4447
|
+
|
|
4448
|
+
/** Enforce the footprint now: remove what must not exist (proof-gated), rotate over-cap logs, collect
|
|
4449
|
+
* lease-free plugin generations. Narrates every removal and every refusal; never throws. */
|
|
4450
|
+
export function enforceFootprint({ holdingRefreshLock = false, pruneEvidence = false, quiet = false } = {}) {
|
|
4451
|
+
let result;
|
|
4452
|
+
try {
|
|
4453
|
+
result = sweepFootprint({ apply: true, holdingRefreshLock, now: footprintNow(), evidence: footprintEvidence(),
|
|
4454
|
+
collectPluginGenerations: ({ registryPath, apply }) => prunePluginGenerations({ registryPath, apply }),
|
|
4455
|
+
pruneEvidence: pruneEvidence ? (args) => pruneLifecycleEvidence(args) : null });
|
|
4456
|
+
} catch (error) {
|
|
4457
|
+
warn(`footprint sweep could not run (${error.message}); nothing was removed`);
|
|
4458
|
+
return null;
|
|
4459
|
+
}
|
|
4460
|
+
if (result.removed.length) ok(`footprint: removed ${result.removed.length} item(s) that must not exist, freed ${formatBytes(result.freedBytes)}`);
|
|
4461
|
+
for (const r of quiet ? [] : result.removed) info(c.dim(` removed ${r.path.replace(os.homedir(), '~')} — ${r.reason}`));
|
|
4462
|
+
if (result.rotated.length) ok(`footprint: rotated ${result.rotated.length} log(s) past their size cap`);
|
|
4463
|
+
if (result.plugins?.removed?.length) ok(`footprint: collected plugin generation(s) ${result.plugins.removed.join(', ')}`);
|
|
4464
|
+
for (const k of result.kept.filter((item) => /^KEPT/.test(item.reason) || item.unique)) {
|
|
4465
|
+
warn(`footprint: ${k.path.replace(os.homedir(), '~')} ${k.reason}`);
|
|
4466
|
+
for (const { file, why } of (k.unique || []).slice(0, 5)) info(c.dim(` ${file} — ${why}`));
|
|
4467
|
+
}
|
|
4468
|
+
return result;
|
|
4469
|
+
}
|
|
4470
|
+
|
|
4471
|
+
/** Print (or emit as JSON) the positive-confirmation block for the machine as it is right now. */
|
|
4472
|
+
async function printConfirmation({ footprint = null, json = false, print = true } = {}) {
|
|
4473
|
+
const now = footprintNow();
|
|
4474
|
+
// Read-only: the inventory only — no copy is proven (no GB-sized hashing) and nothing is written (re-review S4).
|
|
4475
|
+
const fp = footprint || inventoryFootprint({ now, evidence: footprintEvidence() });
|
|
4476
|
+
const result = confirm({ footprint: fp, npmLatest: await npmLatestVersion(), installedVersion: PACKAGE_VERSION, now });
|
|
4477
|
+
if (!print) return result;
|
|
4478
|
+
if (json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
4479
|
+
else console.log(`\n${formatConfirmation(result, { color: c })}`);
|
|
4480
|
+
return result;
|
|
4481
|
+
}
|
|
4482
|
+
|
|
4065
4483
|
/**
|
|
4066
4484
|
* Everything this installer can leave on a machine, DERIVED from disk — never asserted.
|
|
4067
4485
|
*
|
|
@@ -4851,20 +5269,31 @@ function detectEnvironment() {
|
|
|
4851
5269
|
platform: process.platform,
|
|
4852
5270
|
arch: process.arch,
|
|
4853
5271
|
claude: have('claude'),
|
|
4854
|
-
ruflo:
|
|
5272
|
+
ruflo: (() => { const r = locateRuflo(); return Boolean(r.cli || r.configured); })(),
|
|
4855
5273
|
ruvector: have('ruvector') || hasUserScopeMcpServer('ruvector'),
|
|
4856
5274
|
};
|
|
4857
|
-
// Also honor a toolkit that's wired into the Claude config even if the CLI isn't on PATH (npx users).
|
|
4858
|
-
try {
|
|
4859
|
-
const settings = path.join(os.homedir(), '.claude', 'settings.json');
|
|
4860
|
-
if (fs.existsSync(settings)) {
|
|
4861
|
-
const s = fs.readFileSync(settings, 'utf8');
|
|
4862
|
-
if (/claude-flow|\bruflo\b/i.test(s)) env.ruflo = true;
|
|
4863
|
-
}
|
|
4864
|
-
} catch { /* ignore — detection is best-effort */ }
|
|
4865
5275
|
return env;
|
|
4866
5276
|
}
|
|
4867
5277
|
|
|
5278
|
+
/**
|
|
5279
|
+
* THE Ruflo locator — the only way the installer and --doctor decide whether Ruflo is here. Every route it
|
|
5280
|
+
* uses is a parameter, so a caller (or a test) controls all of them: the CLI is `ruflo` (or the legacy
|
|
5281
|
+
* `claude-flow`) found by the shell on `env.PATH`, and `configured` means `~/.claude/settings.json`
|
|
5282
|
+
* mentions it (npx users with no CLI on PATH). Nothing else is consulted — not node's own directory, not the
|
|
5283
|
+
* npm prefix — unless it is on that PATH. (CI run 36919727923: `npm i -g ruflo` puts ruflo beside node, so a
|
|
5284
|
+
* test PATH that kept node's directory still "had" ruflo.)
|
|
5285
|
+
*/
|
|
5286
|
+
export function locateRuflo({ env = process.env, home = os.homedir() } = {}) {
|
|
5287
|
+
const which = (cmd) => {
|
|
5288
|
+
const r = IS_WIN ? spawnSync('where', [cmd], { env, encoding: 'utf8' })
|
|
5289
|
+
: spawnSync('sh', ['-c', `command -v -- ${cmd}`], { env, encoding: 'utf8' });
|
|
5290
|
+
return !r.error && r.status === 0 ? (String(r.stdout || '').trim().split(/\r?\n/)[0] || null) : null;
|
|
5291
|
+
};
|
|
5292
|
+
let configured = false;
|
|
5293
|
+
try { configured = /claude-flow|\bruflo\b/i.test(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8')); } catch { /* none */ }
|
|
5294
|
+
return { cli: which('ruflo') || which('claude-flow'), configured };
|
|
5295
|
+
}
|
|
5296
|
+
|
|
4868
5297
|
export function classifyRufloOperationalHealth({ status = '', memory = '', metrics = '' } = {}) {
|
|
4869
5298
|
const stopped = /\b(?:RuFlo V3 \[STOPPED\]|Swarm not running|MCP Server\s*\\n.*Not running)/i.test(status);
|
|
4870
5299
|
const statusSaysNoMemory = /Backend\s*[|:]?\s*none|Entries\s*[|:]?\s*0\b/i.test(status);
|
|
@@ -4889,22 +5318,94 @@ export function classifyRufloOperationalHealth({ status = '', memory = '', metri
|
|
|
4889
5318
|
};
|
|
4890
5319
|
}
|
|
4891
5320
|
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
}
|
|
5321
|
+
const RUFLO_NOT_INITIALIZED = /not initialized in this directory/i;
|
|
5322
|
+
const rufloProbeRun = (args, cli = 'ruflo', timeoutMs = 10_000) => {
|
|
5323
|
+
// Every `ruflo` invocation auto-starts a project background daemon unless this is set
|
|
5324
|
+
// (verified live: ~/.npm-global/lib/node_modules/ruflo/node_modules/@claude-flow/cli/dist/src/
|
|
5325
|
+
// services/daemon-autostart.js:85) — a read-only health probe must not leave one running.
|
|
5326
|
+
const result = spawnSync(cli, args, { cwd: process.cwd(), encoding: 'utf8', timeout: timeoutMs, shell: IS_WIN && /\.(?:cmd|bat)$/i.test(cli),
|
|
5327
|
+
env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
|
|
5328
|
+
// A spawn failure or a timeout is marked, so it can never pass for an (empty, "healthy") answer.
|
|
5329
|
+
return `${result.stdout || ''}\n${result.stderr || ''}${result.error ? `\n[ruflo-probe-error] ${result.error.code || result.error.message}` : ''}`;
|
|
5330
|
+
};
|
|
5331
|
+
|
|
5332
|
+
/**
|
|
5333
|
+
* The Ruflo probe, READ-ONLY and therefore the same on every run. Measured on ruflo 3.49.0: in a directory
|
|
5334
|
+
* that has not run `ruflo init`, `ruflo status` only reports "not initialized", but `ruflo status memory`
|
|
5335
|
+
* WRITES .swarm/, .claude-flow/ and ruvector.db into it and `hooks metrics` writes .claude-flow/. So the old
|
|
5336
|
+
* probe initialized the user's directory itself: the first --doctor read "not initialized" as degraded
|
|
5337
|
+
* learning (✗), every later one saw "[STOPPED]" (direct mode, ✓) — text and --json disagreed on CI Linux,
|
|
5338
|
+
* run 36915686695. An uninitialized directory is now "not applicable" and the writing commands never run.
|
|
5339
|
+
*/
|
|
5340
|
+
export function probeRufloOperationalHealth({ cli = 'ruflo', timeoutMs = 10_000, run = (args) => rufloProbeRun(args, cli, timeoutMs) } = {}) {
|
|
5341
|
+
const status = run(['status']);
|
|
5342
|
+
// No answer (could not start, timed out, printed nothing) is NOT health, and nothing more is asked (re-review NIT).
|
|
5343
|
+
if (/\[ruflo-probe-error\]/.test(status) || !status.trim()) {
|
|
5344
|
+
return { healthy: false, unanswered: true, directMode: false, stopped: false, zeroLearning: false, memoryContradiction: false, memoryEntries: 0 };
|
|
5345
|
+
}
|
|
5346
|
+
if (RUFLO_NOT_INITIALIZED.test(status)) {
|
|
5347
|
+
return { healthy: true, notInitialized: true, directMode: false, stopped: false, zeroLearning: false, memoryContradiction: false, memoryEntries: 0 };
|
|
5348
|
+
}
|
|
4901
5349
|
return classifyRufloOperationalHealth({
|
|
4902
|
-
status
|
|
5350
|
+
status,
|
|
4903
5351
|
memory: run(['status', 'memory']),
|
|
4904
5352
|
metrics: run(['hooks', 'metrics', '--v3-dashboard']),
|
|
4905
5353
|
});
|
|
4906
5354
|
}
|
|
4907
5355
|
|
|
5356
|
+
/** sha256 of the live COVERAGE.json: the identity a persisted grounding verdict is bound to. */
|
|
5357
|
+
export function liveCoverageSha256(kbDir) {
|
|
5358
|
+
try { return crypto.createHash('sha256').update(fs.readFileSync(path.join(kbDir, 'COVERAGE.json'))).digest('hex'); } catch { return null; }
|
|
5359
|
+
}
|
|
5360
|
+
|
|
5361
|
+
/**
|
|
5362
|
+
* THE Grounding line of the doctor's one verdict (ADR-058 §D8), from the live question and the persisted
|
|
5363
|
+
* verdict only (owner ruling: a WARM-UP timeout is advisory; the timed QUESTION timing out is ✗):
|
|
5364
|
+
* live proven ✓ (and the persisted verdict was just rewritten, bound to these bytes)
|
|
5365
|
+
* persisted UNPROVEN, live not proven ✗ — the persisted verdict gates
|
|
5366
|
+
* reader missing / incomplete / broken ✗ — an install defect, fix: reinstall (a verifier that throws on
|
|
5367
|
+
* import is "broken", not "cannot settle")
|
|
5368
|
+
* warm-up crashed, answer wrong or late ✗
|
|
5369
|
+
* model warm-up ran out of time ! — a slow machine, advisory, fix: run it again
|
|
5370
|
+
* NO verifier at all (none installed, none shippable): the live question cannot settle it, so a persisted
|
|
5371
|
+
* 'proven' verdict FOR THESE BYTES (coverage digest) passes, named as not re-proven live; anything else ✗.
|
|
5372
|
+
* Only this narrow case can pass without a live proof.
|
|
5373
|
+
*/
|
|
5374
|
+
export function groundingCheckLine({ smoke = {}, persisted = null, coverageSha256 = null } = {}) {
|
|
5375
|
+
const line = (state, detail, fix = null) => ({ id: 'grounding', label: 'Grounding', state, detail, fix: state === 'ok' ? null : fix });
|
|
5376
|
+
// Coarse by design (tests/unit/install-state.test.mjs): any recorded state that is not literally 'proven' is unproven.
|
|
5377
|
+
const recorded = persisted && typeof persisted === 'object' ? persisted : null;
|
|
5378
|
+
if (smoke.grounded === true) return line('ok', `proven (${smoke.receipt?.path || 'cited passage'})`);
|
|
5379
|
+
if (recorded && recorded.grounding !== 'proven') return line('fail', `recorded UNPROVEN${recorded.reason ? ` (${recorded.reason})` : ''}`, 'npx ruvnet-brain');
|
|
5380
|
+
if (smoke.grounded === null) {
|
|
5381
|
+
const forTheseBytes = recorded?.grounding === 'proven' && Boolean(coverageSha256) && recorded.coverageSha256 === coverageSha256;
|
|
5382
|
+
return forTheseBytes
|
|
5383
|
+
? line('ok', `not re-proven live (${smoke.reason || 'no verifier'}); last proven for these bytes${recorded.clearedBy ? ` by ${recorded.clearedBy}` : ''}`)
|
|
5384
|
+
: line('fail', `not verifiable live (${smoke.reason || 'no verifier'}) and never proven for these bytes`, 'npx ruvnet-brain');
|
|
5385
|
+
}
|
|
5386
|
+
if (smoke.warmupTimeout) return line('warn', `not proven in time (${smoke.reason || 'slow'})`, 'npx ruvnet-brain --doctor (again, when the machine is less busy)');
|
|
5387
|
+
// The reader's own deadline ("slow on this machine, not broken") is ✗, but its fix is to run it again: a
|
|
5388
|
+
// reinstall does not make a machine faster (and the narration says so).
|
|
5389
|
+
return line('fail', `not proven (${smoke.reason || (smoke.ran === false ? 'the live question did not run' : 'unknown')})`,
|
|
5390
|
+
smoke.slow ? 'npx ruvnet-brain --doctor (again, when the machine is less busy)' : 'npx ruvnet-brain');
|
|
5391
|
+
}
|
|
5392
|
+
|
|
5393
|
+
/** THE Ruflo line of the doctor's one verdict: derived only from the probe result, for text and --json alike. */
|
|
5394
|
+
export function rufloCheckLine(health) {
|
|
5395
|
+
if (health.unanswered) {
|
|
5396
|
+
return { id: 'ruflo', label: 'Ruflo', state: 'fail', detail: '`ruflo status` did not answer (could not start or timed out)', fix: 'ruflo doctor --fix' };
|
|
5397
|
+
}
|
|
5398
|
+
if (health.configuredOnly) {
|
|
5399
|
+
return { id: 'ruflo', label: 'Ruflo', state: 'unknown', detail: 'configured in ~/.claude/settings.json; no ruflo CLI on PATH (not probed)', fix: null };
|
|
5400
|
+
}
|
|
5401
|
+
if (health.notInitialized) {
|
|
5402
|
+
return { id: 'ruflo', label: 'Ruflo', state: 'unknown', detail: 'CLI present; not initialized in this directory (no project learning to judge)', fix: null };
|
|
5403
|
+
}
|
|
5404
|
+
return health.healthy
|
|
5405
|
+
? { id: 'ruflo', label: 'Ruflo', state: 'ok', detail: health.directMode ? 'direct mode (daemon stopped by design)' : 'operational', fix: null }
|
|
5406
|
+
: { id: 'ruflo', label: 'Ruflo', state: 'fail', detail: 'operational learning DEGRADED', fix: 'ruflo doctor --fix' };
|
|
5407
|
+
}
|
|
5408
|
+
|
|
4908
5409
|
// ── issue #39: the ruvector MCP server's own native VectorDb defaults ITS storage to
|
|
4909
5410
|
// "./ruvector.db" relative to whatever cwd it happens to be launched in — and Claude Code
|
|
4910
5411
|
// always launches an MCP server with cwd = the current project, so every consumer project got
|
|
@@ -5446,7 +5947,11 @@ nothing; re-run later, or pick a release yourself with --version <tag>.
|
|
|
5446
5947
|
Usage:
|
|
5447
5948
|
npx ruvnet-brain Install the brain + Claude Code plugin (recommended, npm)
|
|
5448
5949
|
npx github:stuinfla/ruvnet-brain Same, but from the bleeding-edge GitHub commit
|
|
5449
|
-
npx ruvnet-brain --doctor Health-check an existing install (green/red per part)
|
|
5950
|
+
npx ruvnet-brain --doctor Health-check an existing install (green/red per part), ending with the
|
|
5951
|
+
positive confirmation: latest software, ONE current signed KB in use,
|
|
5952
|
+
footprint within budget, no cruft. --doctor --json prints only that, as JSON.
|
|
5953
|
+
npx ruvnet-brain --clean Remove everything that must not exist (old KB copies — never one holding
|
|
5954
|
+
private files the live brain lacks — old installers, over-cap logs), then confirm.
|
|
5450
5955
|
EXITS NON-ZERO when the install is genuinely broken, so it can gate a
|
|
5451
5956
|
script: npx ruvnet-brain --doctor && ./deploy.sh
|
|
5452
5957
|
npx ruvnet-brain --doctor --hooks
|
|
@@ -5477,7 +5982,9 @@ Usage:
|
|
|
5477
5982
|
opt-in prompt appears once at install; answer lives in a plain file:
|
|
5478
5983
|
~/.cache/ruvnet-brain/.telemetry-consent)
|
|
5479
5984
|
node bin/install.mjs --version <tag> Install a specific Release tag (e.g. --version v0.5.0-dev)
|
|
5480
|
-
node bin/install.mjs --pin
|
|
5985
|
+
node bin/install.mjs --pin <tag> Same as --version <tag>: install exactly that release
|
|
5986
|
+
node bin/install.mjs --move-brain <dir> Move the whole Brain to <dir> (e.g. an external disk); ~/.cache/ruvnet-brain links to it
|
|
5987
|
+
node bin/install.mjs --move-brain --back Bring the Brain back to ~/.cache/ruvnet-brain
|
|
5481
5988
|
node bin/install.mjs --local Install from a repo clone's assembled dist/ruvnet-brain/
|
|
5482
5989
|
node bin/install.mjs --force Re-fetch and reinstall even if already present
|
|
5483
5990
|
node bin/install.mjs --no-verify Skip the post-install verify + warm-up smoke test
|
|
@@ -5516,10 +6023,42 @@ the installer reports that boot-level declarations changed.
|
|
|
5516
6023
|
&& canonical(process.argv[1]) === canonical(fileURLToPath(import.meta.url));
|
|
5517
6024
|
if (!invokedDirectly) return;
|
|
5518
6025
|
if (FLAG_HELP) return showHelp();
|
|
6026
|
+
// The Brain moved to another disk (a symlink at ~/.cache/ruvnet-brain) and that disk is not here:
|
|
6027
|
+
// say so in one line and stop. Never re-create a fresh brain in ~/.cache over the dangling link, and
|
|
6028
|
+
// never update one that is not there. --doctor reports it as its verdict.
|
|
6029
|
+
{
|
|
6030
|
+
const where = brainLocation();
|
|
6031
|
+
if (where.state === 'unmounted' && !FLAG_MOVE_BRAIN) {
|
|
6032
|
+
if (FLAG_DOCTOR) { printBanner('doctor'); warn(where.message); console.log(`\n ${c.red('✗ BRAIN DISK NOT MOUNTED')} — nothing was checked or changed; this is not a health verdict.`); process.exitCode = 1; return; }
|
|
6033
|
+
die(where.message, 'Plug the disk in (or mount it), then run the same command again.');
|
|
6034
|
+
}
|
|
6035
|
+
}
|
|
6036
|
+
if (FLAG_MOVE_BRAIN) {
|
|
6037
|
+
printBanner('move the brain');
|
|
6038
|
+
try {
|
|
6039
|
+
const moved = moveBrain({ to: MOVE_BRAIN_TO, back: argv.includes('--back'), log: info });
|
|
6040
|
+
ok(`the Brain is at ${moved.to}${moved.to === moved.link ? '' : ` (${moved.link} links to it)`} — ${(moved.bytes / 1024 ** 3).toFixed(2)} GB moved, verified byte for byte`);
|
|
6041
|
+
} catch (error) {
|
|
6042
|
+
if (error instanceof MoveRefused) die(error.message);
|
|
6043
|
+
throw error;
|
|
6044
|
+
}
|
|
6045
|
+
return;
|
|
6046
|
+
}
|
|
5519
6047
|
// `process.exitCode`, not `return` — doctor()'s verdict is the whole point of running it in a
|
|
5520
6048
|
// script. A bare `return await doctor()` discarded the number, which is how "! Needs attention"
|
|
5521
6049
|
// and `echo $?` → 0 coexisted for so long.
|
|
5522
|
-
|
|
6050
|
+
// `--doctor --json`: the positive-confirmation object ONLY (no narration), exit 0 iff every line that
|
|
6051
|
+
// can be proven here is green — for scripts, CI, and agents (ADR-0098).
|
|
6052
|
+
if (FLAG_DOCTOR) { process.exitCode = await doctor({ json: FLAG_JSON }); return; }
|
|
6053
|
+
// `--clean`: enforce the footprint guarantee now, then confirm. Exit 0 only when exactly one KB copy
|
|
6054
|
+
// remains and nothing that must not exist is left (a private-unique copy kept for safety is a 1).
|
|
6055
|
+
if (FLAG_CLEAN) {
|
|
6056
|
+
if (!FLAG_JSON) printBanner('clean');
|
|
6057
|
+
const swept = enforceFootprint({ pruneEvidence: true, quiet: FLAG_JSON });
|
|
6058
|
+
const result = await printConfirmation({ footprint: swept?.after || null, json: FLAG_JSON });
|
|
6059
|
+
process.exitCode = swept && result.footprint.kbCopies === 1 && result.footprint.cruft.length === 0 ? 0 : 1;
|
|
6060
|
+
return;
|
|
6061
|
+
}
|
|
5523
6062
|
if (FLAG_DEMO) return runDemo();
|
|
5524
6063
|
if (FLAG_FEEDBACK) return runFeedback();
|
|
5525
6064
|
if (FLAG_UPDATE) return runUpdate();
|
|
@@ -5557,6 +6096,7 @@ the installer reports that boot-level declarations changed.
|
|
|
5557
6096
|
}
|
|
5558
6097
|
|
|
5559
6098
|
await printPlanAndConfirm();
|
|
6099
|
+
refuseUnmountedBrain();
|
|
5560
6100
|
|
|
5561
6101
|
const { cacheDir, isCustom } = resolveCacheDir();
|
|
5562
6102
|
|
|
@@ -5656,13 +6196,17 @@ the installer reports that boot-level declarations changed.
|
|
|
5656
6196
|
// bundle cannot also swap the key it is checked against).
|
|
5657
6197
|
//
|
|
5658
6198
|
// SIGNING_REQUIRED was `false` transitionally, for releases that predated signing. That is over:
|
|
5659
|
-
// every release from v2.0.0 on is signed
|
|
6199
|
+
// every release from v2.0.0 on is signed.
|
|
5660
6200
|
// Leaving it false left a real downgrade path — strip or 404 the small .sig file and the missing-
|
|
5661
6201
|
// signature branch printed a warning and extracted 800MB+ of executable .mjs anyway. No alarm
|
|
5662
6202
|
// fired, because no signature was ever obtained. Now a missing signature fails closed like an
|
|
5663
6203
|
// invalid one, and --no-verify remains the single explicit, user-chosen override.
|
|
5664
6204
|
const SIGNING_REQUIRED = true;
|
|
5665
|
-
|
|
6205
|
+
let signedBundleSha256 = null; // set only when THIS run verified the bundle's Ed25519 signature
|
|
6206
|
+
// A local bundle that carries its signature beside it is verified (and recorded) too; without one it
|
|
6207
|
+
// installs as before, unverified and recorded as such (the doctor says "signature NOT verified").
|
|
6208
|
+
const localSigned = !downloaded && Boolean(zipPath) && fs.existsSync(`${zipPath}.sig`);
|
|
6209
|
+
if ((downloaded || localSigned) && !FLAG_NO_VERIFY) {
|
|
5666
6210
|
const sigPath = `${zipPath}.sig`;
|
|
5667
6211
|
const hasSig = fs.existsSync(sigPath);
|
|
5668
6212
|
if (!hasSig) {
|
|
@@ -5678,6 +6222,7 @@ the installer reports that boot-level declarations changed.
|
|
|
5678
6222
|
`Refusing to extract an unverified bundle. Re-run to fetch a fresh copy; if it persists, the\nrelease may be tampered — report it. (Override at your own risk with ${c.bold('--no-verify')}.)`);
|
|
5679
6223
|
}
|
|
5680
6224
|
ok(reason);
|
|
6225
|
+
signedBundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(zipPath)).digest('hex');
|
|
5681
6226
|
}
|
|
5682
6227
|
}
|
|
5683
6228
|
// The tag is only carried when it came from a genuine `latest` resolution — a pinned/offline
|
|
@@ -5686,6 +6231,12 @@ the installer reports that boot-level declarations changed.
|
|
|
5686
6231
|
await unzipInto(zipPath, cacheDir, sourceDir, {
|
|
5687
6232
|
releaseTag: release && release.source === 'latest' ? (release.tag_name || release.tag || null) : null,
|
|
5688
6233
|
});
|
|
6234
|
+
if (signedBundleSha256) {
|
|
6235
|
+
try {
|
|
6236
|
+
writeSignatureRecord({ brainHome: footprintRoots().brainHome, kbDir: cacheDir, bundleSha256: signedBundleSha256,
|
|
6237
|
+
releaseTag: release?.tag_name || release?.tag || null, source: 'install', now: footprintNow() });
|
|
6238
|
+
} catch (error) { warn(`signature verification could not be recorded (${error.message})`); }
|
|
6239
|
+
}
|
|
5689
6240
|
const brainProfile = readBrainProfile();
|
|
5690
6241
|
if (brainProfile !== 'complete') {
|
|
5691
6242
|
const scoped = applyBrainProfile(cacheDir, brainProfile);
|
|
@@ -5703,7 +6254,7 @@ the installer reports that boot-level declarations changed.
|
|
|
5703
6254
|
} catch (error) {
|
|
5704
6255
|
die(
|
|
5705
6256
|
`the Brain Console runtime could not be installed (${error.message})`,
|
|
5706
|
-
`The knowledge base is present, but /
|
|
6257
|
+
`The knowledge base is present, but /rnbc would be broken. Re-run the installer from a complete package.`,
|
|
5707
6258
|
);
|
|
5708
6259
|
}
|
|
5709
6260
|
retireManagedHookRegistrations();
|
|
@@ -5766,6 +6317,11 @@ the installer reports that boot-level declarations changed.
|
|
|
5766
6317
|
// out what we did — which is precisely the position the 2026-07-20 corporate-machine reporter was
|
|
5767
6318
|
// left in. Derived from disk, so it can only ever describe what is actually there.
|
|
5768
6319
|
try { printFootprint(); } catch { /* a summary must never break a finished install */ }
|
|
6320
|
+
// ADR-0098: enforce the footprint the install just produced, then prove it in one block.
|
|
6321
|
+
try {
|
|
6322
|
+
const swept = enforceFootprint({ pruneEvidence: true, quiet: true });
|
|
6323
|
+
await printConfirmation({ footprint: swept?.after || null });
|
|
6324
|
+
} catch (error) { warn(`positive confirmation could not run (${error.message})`); }
|
|
5769
6325
|
|
|
5770
6326
|
// ── SCOPE + UPGRADE, on the path a real user actually takes ─────────────────────────────────
|
|
5771
6327
|
//
|
|
@@ -5845,6 +6401,8 @@ the installer reports that boot-level declarations changed.
|
|
|
5845
6401
|
mod.writeInstallState({
|
|
5846
6402
|
grounding,
|
|
5847
6403
|
reason: !smoke ? 'verify-skipped' : (smoke.grounded === true ? null : (smoke.reason || (smoke.ran ? 'not-grounded' : 'no-answer'))),
|
|
6404
|
+
// The verdict is about THESE bytes: a later corpus no longer inherits it (re-review S1).
|
|
6405
|
+
coverageSha256: liveCoverageSha256(cacheDir),
|
|
5848
6406
|
});
|
|
5849
6407
|
if (grounding !== 'proven' && process.env.RUVNET_STRICT_INSTALL === '1') {
|
|
5850
6408
|
die(
|