ruvnet-brain 4.4.1 → 4.5.1
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 +612 -113
- 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 +263 -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 +17 -2
- 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 +4 -2
- package/plugin/scripts/protect-brain-state.sh +4 -1
- package/plugin/scripts/session-snapshot-hook.mjs +27 -3
- 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-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/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,10 @@ 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';
|
|
75
85
|
import { cleanLegacyRufloDebris } from '../plugin/scripts/project-progression-store.mjs';
|
|
76
86
|
import { resolveProjectStore } from '../plugin/scripts/project-store-resolver.mjs';
|
|
77
87
|
import { runHostCli, waitForHostCli } from '../scripts/host-cli.mjs';
|
|
@@ -107,17 +117,6 @@ const PACKAGE_VERSION = (() => {
|
|
|
107
117
|
const REPO = 'stuinfla/ruvnet-brain';
|
|
108
118
|
const RELEASE_API = `https://api.github.com/repos/${REPO}/releases/latest`;
|
|
109
119
|
const ASSET_NAME = 'ruvnet-brain.zip';
|
|
110
|
-
// Known-good BUNDLE tag, used ONLY by --pin. It is no longer a silent fallback for a failed
|
|
111
|
-
// latest-release lookup: that bundle predates ReleaseCoverage and cannot pass validation, so a lookup
|
|
112
|
-
// failure now stops with its real cause (resolveRelease / releaseLookupFailure).
|
|
113
|
-
//
|
|
114
|
-
// This MUST NOT be derived from this package's own version. The installer and the brain bundle are
|
|
115
|
-
// two independent version streams (README: "Three independent things version separately here — by
|
|
116
|
-
// design"). Reading it from package.json produced a tag that has never existed — installer 1.14.0-dev
|
|
117
|
-
// asking for releases/download/v1.14.0-dev/ruvnet-brain.zip, which 404s, while the newest bundle
|
|
118
|
-
// Release is v0.5.0-dev. Verified live: v1.14.0-dev → HTTP 404, v0.5.0-dev → HTTP 200. The safety net
|
|
119
|
-
// was broken in exactly the situation it exists for. Bump this by hand when a new bundle ships.
|
|
120
|
-
const RELEASE_VERSION = 'v2.9.0'; // sync-version-ignore: the BUNDLE Release tag, not this package's version
|
|
121
120
|
const fallbackUrl = (tag) => `https://github.com/${REPO}/releases/download/${tag}/${ASSET_NAME}`;
|
|
122
121
|
const APPROX_SIZE = '~736MB';
|
|
123
122
|
|
|
@@ -132,7 +131,6 @@ const FLAG_HOOKS = argv.includes('--hooks');
|
|
|
132
131
|
const FLAG_NO_VERIFY = argv.includes('--no-verify');
|
|
133
132
|
// Escape hatch for the installer's closing self-check ONLY (it never disables --doctor's verdict).
|
|
134
133
|
const FLAG_NO_SELFCHECK = argv.includes('--no-selfcheck');
|
|
135
|
-
const FLAG_PIN = argv.includes('--pin'); // skip the latest-check, use the bundled default
|
|
136
134
|
const FLAG_DEMO = argv.includes('--demo'); // guided, real (non-fabricated) walkthrough of the brain in action
|
|
137
135
|
const FLAG_FEEDBACK = argv.includes('--feedback'); // prefill a GitHub Discussion (version + health, nothing private) and open it
|
|
138
136
|
// ── freshness flags — invoke/schedule the SELF-UPDATER the bundle already ships (kb/forge-update.mjs) ──
|
|
@@ -149,6 +147,12 @@ const FLAG_DISABLE_SPEND_GUARD = argv.includes('--disable-spend-guard'); // the
|
|
|
149
147
|
const FLAG_UNINSTALL = argv.includes('--uninstall'); // reverse everything, in one command
|
|
150
148
|
const FLAG_WHAT_CHANGED = argv.includes('--what-changed'); // show our footprint on this machine
|
|
151
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
|
|
152
156
|
// ── onboarding-experience flags (all optional; every offer is safe to decline) ──
|
|
153
157
|
const FLAG_YES = argv.includes('--yes') || argv.includes('-y'); // accept every optional offer non-interactively
|
|
154
158
|
const FLAG_PLAN = argv.includes('--plan') || argv.includes('--dry-run'); // show the interactive checklist, then exit — install NOTHING
|
|
@@ -158,12 +162,32 @@ const FLAG_ENHANCE_CLAUDE_MD = argv.includes('--enhance-claude-md'); // add the
|
|
|
158
162
|
const FLAG_NO_ENHANCE = argv.includes('--no-enhance'); // skip the CLAUDE.md offer entirely
|
|
159
163
|
const FLAG_STATUSLINE = argv.includes('--statusline'); // opt in to the status-bar version segment, non-interactively
|
|
160
164
|
const FLAG_NO_STATUSLINE = argv.includes('--no-statusline'); // decline the status-bar offer without prompting
|
|
161
|
-
// --version <tag> forces a specific Release tag (e.g. --version v0.5.0-dev)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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;
|
|
167
191
|
|
|
168
192
|
// ── tiny narrating logger — every step says WHAT and WHY ─────────────────────────────────────────
|
|
169
193
|
const c = {
|
|
@@ -335,7 +359,7 @@ function fetchJson(url, redirects = 0) {
|
|
|
335
359
|
|
|
336
360
|
// ── step: resolve which Release to download (latest by default; safe fallback) ───────────────────
|
|
337
361
|
// Default behavior: ask GitHub for the LATEST Release and use its ruvnet-brain.zip asset.
|
|
338
|
-
// --version <tag>
|
|
362
|
+
// --version <tag> / --pin <tag> install exactly that tag; there is no built-in fallback release.
|
|
339
363
|
// Any failure (offline / rate-limited / no releases) THROWS with the HTTP status or network error and
|
|
340
364
|
// a retry hint; callers that must download stop on it, the staleness check reports "could not check".
|
|
341
365
|
/**
|
|
@@ -370,14 +394,10 @@ async function resolveRelease() {
|
|
|
370
394
|
'so a stranger always gets the most current brain — not whatever was hardcoded when this script shipped',
|
|
371
395
|
);
|
|
372
396
|
|
|
373
|
-
if (
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (FORCED_VERSION) {
|
|
379
|
-
info(`--version set: forcing Release ${c.bold(FORCED_VERSION)} (no latest-check)`);
|
|
380
|
-
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 };
|
|
381
401
|
}
|
|
382
402
|
|
|
383
403
|
// Deterministic integration seam: stale/current behavior must not depend on GitHub API quota.
|
|
@@ -476,12 +496,13 @@ async function obtainBundle(release) {
|
|
|
476
496
|
return { zipPath: localZip, downloaded: false };
|
|
477
497
|
}
|
|
478
498
|
|
|
479
|
-
|
|
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;
|
|
480
501
|
step(
|
|
481
502
|
`Downloading the brain (${APPROX_SIZE})`,
|
|
482
503
|
'the brain embeds source from dozens of RuvNet repos — too big for git, so it ships as a Release',
|
|
483
504
|
);
|
|
484
|
-
info(`version: ${c.bold(
|
|
505
|
+
info(`version: ${c.bold(release.tag)}`);
|
|
485
506
|
info(`from: ${downloadUrl}`);
|
|
486
507
|
// Download into a PRIVATE, per-run temp DIR — never a predictable os.tmpdir()/ruvnet-brain-<pid>.zip
|
|
487
508
|
// filename (CWE-377: a guessable path invites a pre-created or symlinked file at that location to be
|
|
@@ -623,6 +644,18 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
|
|
|
623
644
|
);
|
|
624
645
|
|
|
625
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
|
+
}
|
|
626
659
|
const stageDir = fs.mkdtempSync(path.join(path.dirname(cacheDir), `.${path.basename(cacheDir)}.install-stage-`));
|
|
627
660
|
const localCopy = async () => `local directory copy — ${copyLocalBundleInto(sourceDir, stageDir)} top-level entries`;
|
|
628
661
|
const nodeExtract = async () => {
|
|
@@ -749,6 +782,15 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
|
|
|
749
782
|
}
|
|
750
783
|
}
|
|
751
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);
|
|
752
794
|
const priorPrefix = `${path.basename(cacheDir)}.install-prior-`;
|
|
753
795
|
const unresolved = fs.readdirSync(parent).filter((name) => name.startsWith(priorPrefix));
|
|
754
796
|
if (unresolved.length) {
|
|
@@ -803,13 +845,33 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
|
|
|
803
845
|
hadPrior ? 'the prior brain generation was not moved' : 'no prior brain generation existed'}`,
|
|
804
846
|
`Candidate retained for inspection at ${stageDir}.`);
|
|
805
847
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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);
|
|
809
873
|
ok(`brain unpacked to ${cacheDir}`);
|
|
810
|
-
return { status: 'ACTIVATED', priorGeneration
|
|
811
|
-
? { status: 'PRESERVED_UNCLASSIFIED', path: preservedDir, automaticCleanupEligible: false }
|
|
812
|
-
: null };
|
|
874
|
+
return { status: 'ACTIVATED', priorGeneration };
|
|
813
875
|
}
|
|
814
876
|
|
|
815
877
|
/** Stage and validate a bundle for forge-update's private-overlay recovery rail
|
|
@@ -1496,7 +1558,7 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1496
1558
|
} else if (before.installed && before.version !== installed.version) {
|
|
1497
1559
|
info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
|
|
1498
1560
|
}
|
|
1499
|
-
info(` commands available${shellBoundary.restartRequired ? ' in new sessions' : ' immediately'}: ${c.bold('/
|
|
1561
|
+
info(` commands available${shellBoundary.restartRequired ? ' in new sessions' : ' immediately'}: ${c.bold('/rnbc')} (also /rnb, /rvbc), ${c.bold('/ruvnet-brain:configure')}`);
|
|
1500
1562
|
return {
|
|
1501
1563
|
host: true, wired: true, version: installed.version, manualMarketplace, manualInstall,
|
|
1502
1564
|
shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
|
|
@@ -1516,7 +1578,7 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1516
1578
|
? `Claude installed the plugin with ${installedHookRetirement.registrations.length} retired lifecycle registration(s); refusing to call this host converged.`
|
|
1517
1579
|
: mismatch
|
|
1518
1580
|
? `Claude installed ${installed.version || 'an unknown version'}, not required ${expectedVersion}; refusing to call this host converged.`
|
|
1519
|
-
: `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.`);
|
|
1520
1582
|
info(`${c.green('Your brain still works')}: search_ruvnet is wired and Claude will ground answers with it.`);
|
|
1521
1583
|
info(`Only the plugin extras (slash commands, the Console, skills, and MCP declaration) are missing.`);
|
|
1522
1584
|
info(`Run these two yourself to finish:`);
|
|
@@ -1853,6 +1915,16 @@ const CODEX_PLUGIN_ID = 'ruvnet-brain@ruvnet-brain';
|
|
|
1853
1915
|
const CODEX_MARKETPLACE = 'ruvnet-brain';
|
|
1854
1916
|
const CODEX_MARKETPLACE_SOURCE = 'stuinfla/ruvnet-brain';
|
|
1855
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
|
+
|
|
1856
1928
|
function codexMarketplaceTarget() {
|
|
1857
1929
|
return path.join(
|
|
1858
1930
|
process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'),
|
|
@@ -1984,6 +2056,11 @@ export function wireCodexPlugin({
|
|
|
1984
2056
|
path.join(REPO_ROOT, 'plugin'),
|
|
1985
2057
|
)
|
|
1986
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'));
|
|
1987
2064
|
if (before.installed && before.enabled && versionSatisfies(before.version, expectedVersion)) {
|
|
1988
2065
|
if (announce) ok(`Codex Brain plugin already installed and enabled (${before.version || 'version unknown'}) — no changes.`);
|
|
1989
2066
|
return {
|
|
@@ -2037,10 +2114,16 @@ export function wireCodexPlugin({
|
|
|
2037
2114
|
} else if (before.installed && before.version !== after.version) {
|
|
2038
2115
|
info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
|
|
2039
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
|
+
}
|
|
2040
2122
|
}
|
|
2041
2123
|
return {
|
|
2042
2124
|
host: true,
|
|
2043
2125
|
action: before.installed ? 'updated' : 'installed',
|
|
2126
|
+
hooksNeedingReview,
|
|
2044
2127
|
...after,
|
|
2045
2128
|
shellChanged: shellBoundary.changed,
|
|
2046
2129
|
shellChangedPaths: shellBoundary.paths,
|
|
@@ -2143,6 +2226,15 @@ export function classifyCodexLifecycle(plugin, listed = null) {
|
|
|
2143
2226
|
const unexpected = hooks.filter((hook) => !continuityHookId(hook?.command, hook?.event));
|
|
2144
2227
|
if (unexpected.length) return { state: 'unexpected-runtime-hooks', plugin, hooks: unexpected, errors };
|
|
2145
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 };
|
|
2146
2238
|
return { state: 'continuity-registered', plugin, hooks: conforming, errors };
|
|
2147
2239
|
}
|
|
2148
2240
|
|
|
@@ -2175,6 +2267,18 @@ export function codexLifecycleGuidance(status) {
|
|
|
2175
2267
|
+ ' observe Stop or PreCompact, so no capture handler was registered on those.',
|
|
2176
2268
|
action: null,
|
|
2177
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
|
+
}
|
|
2178
2282
|
case 'inactive-by-design':
|
|
2179
2283
|
return {
|
|
2180
2284
|
healthy: true,
|
|
@@ -2448,7 +2552,7 @@ function gatherInstallState(cacheDir) {
|
|
|
2448
2552
|
try {
|
|
2449
2553
|
repos = fs
|
|
2450
2554
|
.readdirSync(cacheDir)
|
|
2451
|
-
.filter((f) => f.endsWith('.rvf')).length;
|
|
2555
|
+
.filter((f) => f.endsWith('.rvf') && !isVolumeMetadata(f)).length; // an exFAT `._x.rvf` shadow is not a store
|
|
2452
2556
|
} catch {
|
|
2453
2557
|
/* ignore */
|
|
2454
2558
|
}
|
|
@@ -2509,11 +2613,13 @@ function ensureVerifier(cacheDir) {
|
|
|
2509
2613
|
} catch { return 'unavailable'; }
|
|
2510
2614
|
}
|
|
2511
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"). */
|
|
2512
2618
|
async function loadCitationVerifier(cacheDir) {
|
|
2513
2619
|
ensureVerifier(cacheDir);
|
|
2514
2620
|
const p = path.join(cacheDir, 'verify-citation.mjs');
|
|
2515
2621
|
if (!fs.existsSync(p)) return null;
|
|
2516
|
-
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) }; }
|
|
2517
2623
|
}
|
|
2518
2624
|
|
|
2519
2625
|
// Proving grounding means proving the answer's CITATION RESOLVES — that the file it points at is a
|
|
@@ -2528,14 +2634,53 @@ export function resolveRuntimeModelCache(env = process.env, home = os.homedir())
|
|
|
2528
2634
|
|
|
2529
2635
|
async function smokeQuery(cacheDir) {
|
|
2530
2636
|
const ask = path.join(cacheDir, 'forge-ask-all.mjs');
|
|
2531
|
-
|
|
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
|
+
}
|
|
2532
2642
|
step(
|
|
2533
2643
|
'Asking the brain a real question',
|
|
2534
2644
|
'this warms the local model and checks that retrieval returns usable, cited evidence',
|
|
2535
2645
|
);
|
|
2536
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
|
+
}
|
|
2537
2683
|
info(`Q: ${c.cyan(`"${Q}"`)}`);
|
|
2538
|
-
info(c.dim('(first run downloads a small local model once — this can take a minute)'));
|
|
2539
2684
|
const started = Date.now();
|
|
2540
2685
|
let r;
|
|
2541
2686
|
try {
|
|
@@ -2552,19 +2697,18 @@ async function smokeQuery(cacheDir) {
|
|
|
2552
2697
|
// path here makes the install smoke warm the model cache the product will actually reopen,
|
|
2553
2698
|
// instead of a second kb-local cache that can go green while the real door stays cold.
|
|
2554
2699
|
//
|
|
2555
|
-
// RUVNET_BRAIN_QUERY_DEADLINE_MS: this
|
|
2556
|
-
//
|
|
2557
|
-
//
|
|
2558
|
-
//
|
|
2559
|
-
//
|
|
2560
|
-
//
|
|
2561
|
-
//
|
|
2562
|
-
//
|
|
2563
|
-
//
|
|
2564
|
-
// 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.
|
|
2565
2709
|
env: {
|
|
2566
2710
|
...process.env,
|
|
2567
|
-
KB_MODEL_CACHE:
|
|
2711
|
+
KB_MODEL_CACHE: modelCache,
|
|
2568
2712
|
RUVNET_BRAIN_QUERY_DEADLINE_MS: process.env.RUVNET_BRAIN_QUERY_DEADLINE_MS ?? '45000',
|
|
2569
2713
|
},
|
|
2570
2714
|
});
|
|
@@ -2582,11 +2726,10 @@ async function smokeQuery(cacheDir) {
|
|
|
2582
2726
|
// crash, a timeout, and a missing module all read as "nothing is wrong, it will warm up".
|
|
2583
2727
|
// Observed on this machine: a smoke query that produced no answer in 240s was reported as a
|
|
2584
2728
|
// first-run download. spawnSync already tells us which it was; say that instead.
|
|
2585
|
-
const
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
: `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;
|
|
2590
2733
|
warn(`no answer came back — ${cause}`);
|
|
2591
2734
|
// SHOW THE ACTUAL ERROR (issue #37 bug 2, Agentist-Elder, 2026-07-21).
|
|
2592
2735
|
//
|
|
@@ -2609,17 +2752,29 @@ async function smokeQuery(cacheDir) {
|
|
|
2609
2752
|
}
|
|
2610
2753
|
// The reason travels with the verdict so the doctor's "Grounding NOT proven (<reason>)" line
|
|
2611
2754
|
// names the real cause too, instead of the generic token.
|
|
2612
|
-
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' };
|
|
2613
2757
|
}
|
|
2614
2758
|
|
|
2615
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
|
+
}
|
|
2616
2764
|
if (!verifier) {
|
|
2617
2765
|
info(`the brain answered in ${secs}s, but this bundle predates the citation verifier —`);
|
|
2618
2766
|
info(c.dim(' re-run `npx ruvnet-brain` to refresh it, and grounding will be PROVEN, not assumed'));
|
|
2619
2767
|
return { ran: true, grounded: null, reason: 'verifier-missing' };
|
|
2620
2768
|
}
|
|
2621
2769
|
|
|
2622
|
-
|
|
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
|
+
}
|
|
2623
2778
|
const evidence = classifySmokeEvidence(v, out);
|
|
2624
2779
|
if (v.grounded && !evidence.usable) {
|
|
2625
2780
|
warn(`the citation resolves, but the question was not answered with sufficient evidence (${evidence.reason})`);
|
|
@@ -2799,15 +2954,47 @@ function meterSummaryLine() {
|
|
|
2799
2954
|
// Honest prose that no machine can read is not a check. It could not gate a CI job, a nightly probe,
|
|
2800
2955
|
// or a `&&` in someone's shell. It now RETURNS a verdict and main() exits with it, so "needs
|
|
2801
2956
|
// attention" and "success" can never again be the same thing to a script.
|
|
2802
|
-
|
|
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 }) {
|
|
2803
2967
|
printBanner('doctor');
|
|
2804
2968
|
console.log(c.dim('Checking every part of the install and reporting green/red.\n'));
|
|
2805
2969
|
const cacheDir = process.env.RUVNET_BRAIN_KB || path.join(os.homedir(), '.cache', 'ruvnet-brain', 'kb');
|
|
2806
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
|
+
}
|
|
2807
2975
|
const present = fs.existsSync(path.join(cacheDir, 'forge-mcp-all.mjs'));
|
|
2808
2976
|
if (!present) {
|
|
2809
|
-
|
|
2810
|
-
|
|
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;
|
|
2811
2998
|
}
|
|
2812
2999
|
const convergencePath = path.join(process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'host-convergence.json');
|
|
2813
3000
|
let hostConvergence = { healthy: true, state: 'not-recorded' };
|
|
@@ -2874,9 +3061,17 @@ async function doctor() {
|
|
|
2874
3061
|
cleanupStrayRuvectorDb(); // issue #39: sweep a leftover empty scaffold from before this fix, if one's here
|
|
2875
3062
|
const env = detectEnvironment();
|
|
2876
3063
|
let rufloOperational = null;
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
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) {
|
|
2880
3075
|
ok(rufloOperational.directMode
|
|
2881
3076
|
? 'Ruflo direct mode ready — daemon/swarm is stopped by design; AgentDB remains CLI-backed'
|
|
2882
3077
|
: 'Ruflo operational — runtime, memory, and learning signals agree');
|
|
@@ -2944,7 +3139,12 @@ async function doctor() {
|
|
|
2944
3139
|
console.log(` really exists in your local KB. Checked in ${smoke.secs}s, no cloud, no API key.`);
|
|
2945
3140
|
} else if (smoke.grounded === false) {
|
|
2946
3141
|
console.log(` ${c.yellow('! Grounding NOT proven')} (${smoke.reason}). The install is present but the brain did not`);
|
|
2947
|
-
|
|
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.');
|
|
2948
3148
|
} else if (smoke.grounded === null) {
|
|
2949
3149
|
console.log(` ${c.yellow('! Grounding not verifiable')} on this bundle — it predates the citation verifier.`);
|
|
2950
3150
|
console.log(' Re-run npx ruvnet-brain to refresh, then --doctor will prove it.');
|
|
@@ -2993,6 +3193,27 @@ async function doctor() {
|
|
|
2993
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'),
|
|
2994
3194
|
);
|
|
2995
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
|
+
|
|
2996
3217
|
// ── THE MECHANICAL VERDICT ────────────────────────────────────────────────────────────────────
|
|
2997
3218
|
// `--hooks` is retained as a compatibility alias for a read-only zero-registration proof. It must
|
|
2998
3219
|
// never execute dormant hook bodies.
|
|
@@ -3019,12 +3240,14 @@ async function doctor() {
|
|
|
3019
3240
|
// "unproven" verdict DOES gate the exit code. Its successful live citation proof above must clear
|
|
3020
3241
|
// an older failure before this read; otherwise one invocation can print both PROVEN and UNPROVEN.
|
|
3021
3242
|
let groundingUnprovenPersisted = false;
|
|
3243
|
+
let persistedGrounding = null;
|
|
3022
3244
|
try {
|
|
3023
3245
|
const mod = await import(new URL('../scripts/selfcheck.mjs', import.meta.url).href);
|
|
3024
3246
|
if (smoke.grounded === true) {
|
|
3025
|
-
mod.writeInstallState({ grounding: 'proven', reason: null, clearedBy: 'doctor-live-proof' });
|
|
3247
|
+
mod.writeInstallState({ grounding: 'proven', reason: null, clearedBy: 'doctor-live-proof', coverageSha256: liveCoverageSha256(cacheDir) });
|
|
3026
3248
|
}
|
|
3027
|
-
|
|
3249
|
+
persistedGrounding = mod.readInstallState();
|
|
3250
|
+
groundingUnprovenPersisted = mod.groundingUnproven(persistedGrounding);
|
|
3028
3251
|
if (groundingUnprovenPersisted) {
|
|
3029
3252
|
console.log(` ${c.yellow('! Grounding UNPROVEN')} (recorded at ${c.bold(mod.installStatePath())}).`);
|
|
3030
3253
|
console.log(` This is what makes ${c.bold('--doctor')} fail here even though nothing above crashed — re-run`);
|
|
@@ -3046,24 +3269,44 @@ async function doctor() {
|
|
|
3046
3269
|
const codexTrustBypassed = process.env.RUVNET_CODEX_HOOK_TRUST_MODE === 'bypass';
|
|
3047
3270
|
const codexWiringFailed = Boolean(cx.host && !cx.wired);
|
|
3048
3271
|
const codexReadinessFailed = Boolean(codexMcp?.blocking);
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|| (nightlyHealth.state === 'on' && !['ok', 'running'].includes(nightlyHealth.runHealth?.state))
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
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`);
|
|
3063
3301
|
} else {
|
|
3064
|
-
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
|
+
}
|
|
3065
3308
|
}
|
|
3066
|
-
return
|
|
3309
|
+
return verdict.exitCode;
|
|
3067
3310
|
}
|
|
3068
3311
|
|
|
3069
3312
|
// ── the post-install self-check, wired for both --doctor --hooks and the installer's last step ────
|
|
@@ -3245,7 +3488,7 @@ function feedbackHealthLines(cacheDir) {
|
|
|
3245
3488
|
const env = detectEnvironment();
|
|
3246
3489
|
const allGreen = s.repos > 0 && s.reader && s.mcp;
|
|
3247
3490
|
return [
|
|
3248
|
-
`${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)'}`,
|
|
3249
3492
|
`toolkit: Ruflo ${env.ruflo ? 'present' : 'not found'} · RuVector ${env.ruvector ? 'present' : 'not found'} · claude CLI ${env.claude ? 'present' : 'not found'}`,
|
|
3250
3493
|
// NOT called a "verdict": this reads only repos/reader/mcp, while --doctor's verdict also weighs
|
|
3251
3494
|
// grounding, Codex wiring, nightly health, host convergence and the hook policy. Two lines both
|
|
@@ -3345,6 +3588,11 @@ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = n
|
|
|
3345
3588
|
if (/^unresolved rollback state exists/.test(String(result?.reason || ''))) {
|
|
3346
3589
|
return { verdict: 'refused-retained-copies', fallback: false, exitCode: status || 1 };
|
|
3347
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
|
+
}
|
|
3348
3596
|
// Exit 2 is "manifest unreachable, nothing touched". The fallback exists for a DEAD manifest URL (an old
|
|
3349
3597
|
// bundle polling a path that 404s); a rate limit, a 5xx or no network is transient, and a full fresh
|
|
3350
3598
|
// reinstall over it re-downloads the brain and preserves another full copy each time. Retry later instead.
|
|
@@ -3634,8 +3882,17 @@ export function openSessionsNotice(hosts, version) {
|
|
|
3634
3882
|
return `new ${names} sessions use ${version}; already-open windows keep the old hook definitions until they are reopened`;
|
|
3635
3883
|
}
|
|
3636
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
|
+
|
|
3637
3893
|
async function runUpdate() {
|
|
3638
3894
|
printBanner('update');
|
|
3895
|
+
refuseUnmountedBrain();
|
|
3639
3896
|
const kbDir = resolvedKbDir();
|
|
3640
3897
|
const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(kbDir);
|
|
3641
3898
|
if (FLAG_HOST_SYNC_ONLY) {
|
|
@@ -3751,6 +4008,12 @@ async function runUpdate() {
|
|
|
3751
4008
|
} catch (error) {
|
|
3752
4009
|
warn(`could not place the trusted coverage validator (${error.message}); the updater will report what it finds`);
|
|
3753
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 });
|
|
3754
4017
|
info(c.dim("running the bundle's own self-updater (backs up first, re-verifies, never half-applies)…\n"));
|
|
3755
4018
|
// Relative filename + matching cwd — same launch convention as smokeQuery(); stdio:'inherit'
|
|
3756
4019
|
// streams the updater's narration live and unedited.
|
|
@@ -3851,6 +4114,33 @@ async function runUpdate() {
|
|
|
3851
4114
|
return;
|
|
3852
4115
|
}
|
|
3853
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
|
+
}
|
|
3854
4144
|
let phaseEvidence = updaterResult?.phaseEvidence || null;
|
|
3855
4145
|
if (!phaseEvidence) {
|
|
3856
4146
|
const installed = validateCoverageDirectory(kbDir, { expectedVersion: PACKAGE_VERSION });
|
|
@@ -3938,12 +4228,24 @@ async function runUpdate() {
|
|
|
3938
4228
|
execution: { kind: 'executed', runId: refreshReceipt.runId },
|
|
3939
4229
|
required: cleanupFailed || updateStatus === 0,
|
|
3940
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 */ }
|
|
3941
4242
|
settleRefresh(cleanupPending ? 12 : (retentionFailed ? 1 : updateStatus), {
|
|
3942
4243
|
phase: cleanupFailed ? 'cleanup' : (updateStatus === 0 ? 'complete' : 'failed'),
|
|
3943
4244
|
terminalVerdict: cleanupPending ? 'cleanup-pending' : retentionFailed ? 'recovery-required'
|
|
3944
4245
|
: (outcome.verdict === 'noop' ? 'noop' : 'applied'),
|
|
3945
4246
|
storageDelta: updaterResult?.storageDelta || null, lifecycleRetention: retention });
|
|
3946
4247
|
process.removeListener('exit', exitGuard);
|
|
4248
|
+
await printConfirmation({ footprint: footprint?.after || null });
|
|
3947
4249
|
}
|
|
3948
4250
|
|
|
3949
4251
|
function enableNightly() {
|
|
@@ -4121,6 +4423,63 @@ const upgradeNoticeStatePath = () =>
|
|
|
4121
4423
|
const brainOffSentinelPath = () =>
|
|
4122
4424
|
path.join(process.env.RUVNET_BRAIN_STATE_DIR || path.join(os.homedir(), '.config', 'ruvnet-brain'), 'brain-off');
|
|
4123
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
|
+
|
|
4124
4483
|
/**
|
|
4125
4484
|
* Everything this installer can leave on a machine, DERIVED from disk — never asserted.
|
|
4126
4485
|
*
|
|
@@ -4910,20 +5269,31 @@ function detectEnvironment() {
|
|
|
4910
5269
|
platform: process.platform,
|
|
4911
5270
|
arch: process.arch,
|
|
4912
5271
|
claude: have('claude'),
|
|
4913
|
-
ruflo:
|
|
5272
|
+
ruflo: (() => { const r = locateRuflo(); return Boolean(r.cli || r.configured); })(),
|
|
4914
5273
|
ruvector: have('ruvector') || hasUserScopeMcpServer('ruvector'),
|
|
4915
5274
|
};
|
|
4916
|
-
// Also honor a toolkit that's wired into the Claude config even if the CLI isn't on PATH (npx users).
|
|
4917
|
-
try {
|
|
4918
|
-
const settings = path.join(os.homedir(), '.claude', 'settings.json');
|
|
4919
|
-
if (fs.existsSync(settings)) {
|
|
4920
|
-
const s = fs.readFileSync(settings, 'utf8');
|
|
4921
|
-
if (/claude-flow|\bruflo\b/i.test(s)) env.ruflo = true;
|
|
4922
|
-
}
|
|
4923
|
-
} catch { /* ignore — detection is best-effort */ }
|
|
4924
5275
|
return env;
|
|
4925
5276
|
}
|
|
4926
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
|
+
|
|
4927
5297
|
export function classifyRufloOperationalHealth({ status = '', memory = '', metrics = '' } = {}) {
|
|
4928
5298
|
const stopped = /\b(?:RuFlo V3 \[STOPPED\]|Swarm not running|MCP Server\s*\\n.*Not running)/i.test(status);
|
|
4929
5299
|
const statusSaysNoMemory = /Backend\s*[|:]?\s*none|Entries\s*[|:]?\s*0\b/i.test(status);
|
|
@@ -4948,22 +5318,94 @@ export function classifyRufloOperationalHealth({ status = '', memory = '', metri
|
|
|
4948
5318
|
};
|
|
4949
5319
|
}
|
|
4950
5320
|
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
}
|
|
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
|
+
}
|
|
4960
5349
|
return classifyRufloOperationalHealth({
|
|
4961
|
-
status
|
|
5350
|
+
status,
|
|
4962
5351
|
memory: run(['status', 'memory']),
|
|
4963
5352
|
metrics: run(['hooks', 'metrics', '--v3-dashboard']),
|
|
4964
5353
|
});
|
|
4965
5354
|
}
|
|
4966
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
|
+
|
|
4967
5409
|
// ── issue #39: the ruvector MCP server's own native VectorDb defaults ITS storage to
|
|
4968
5410
|
// "./ruvector.db" relative to whatever cwd it happens to be launched in — and Claude Code
|
|
4969
5411
|
// always launches an MCP server with cwd = the current project, so every consumer project got
|
|
@@ -5505,7 +5947,11 @@ nothing; re-run later, or pick a release yourself with --version <tag>.
|
|
|
5505
5947
|
Usage:
|
|
5506
5948
|
npx ruvnet-brain Install the brain + Claude Code plugin (recommended, npm)
|
|
5507
5949
|
npx github:stuinfla/ruvnet-brain Same, but from the bleeding-edge GitHub commit
|
|
5508
|
-
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.
|
|
5509
5955
|
EXITS NON-ZERO when the install is genuinely broken, so it can gate a
|
|
5510
5956
|
script: npx ruvnet-brain --doctor && ./deploy.sh
|
|
5511
5957
|
npx ruvnet-brain --doctor --hooks
|
|
@@ -5536,7 +5982,9 @@ Usage:
|
|
|
5536
5982
|
opt-in prompt appears once at install; answer lives in a plain file:
|
|
5537
5983
|
~/.cache/ruvnet-brain/.telemetry-consent)
|
|
5538
5984
|
node bin/install.mjs --version <tag> Install a specific Release tag (e.g. --version v0.5.0-dev)
|
|
5539
|
-
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
|
|
5540
5988
|
node bin/install.mjs --local Install from a repo clone's assembled dist/ruvnet-brain/
|
|
5541
5989
|
node bin/install.mjs --force Re-fetch and reinstall even if already present
|
|
5542
5990
|
node bin/install.mjs --no-verify Skip the post-install verify + warm-up smoke test
|
|
@@ -5575,10 +6023,42 @@ the installer reports that boot-level declarations changed.
|
|
|
5575
6023
|
&& canonical(process.argv[1]) === canonical(fileURLToPath(import.meta.url));
|
|
5576
6024
|
if (!invokedDirectly) return;
|
|
5577
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
|
+
}
|
|
5578
6047
|
// `process.exitCode`, not `return` — doctor()'s verdict is the whole point of running it in a
|
|
5579
6048
|
// script. A bare `return await doctor()` discarded the number, which is how "! Needs attention"
|
|
5580
6049
|
// and `echo $?` → 0 coexisted for so long.
|
|
5581
|
-
|
|
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
|
+
}
|
|
5582
6062
|
if (FLAG_DEMO) return runDemo();
|
|
5583
6063
|
if (FLAG_FEEDBACK) return runFeedback();
|
|
5584
6064
|
if (FLAG_UPDATE) return runUpdate();
|
|
@@ -5616,6 +6096,7 @@ the installer reports that boot-level declarations changed.
|
|
|
5616
6096
|
}
|
|
5617
6097
|
|
|
5618
6098
|
await printPlanAndConfirm();
|
|
6099
|
+
refuseUnmountedBrain();
|
|
5619
6100
|
|
|
5620
6101
|
const { cacheDir, isCustom } = resolveCacheDir();
|
|
5621
6102
|
|
|
@@ -5715,13 +6196,17 @@ the installer reports that boot-level declarations changed.
|
|
|
5715
6196
|
// bundle cannot also swap the key it is checked against).
|
|
5716
6197
|
//
|
|
5717
6198
|
// SIGNING_REQUIRED was `false` transitionally, for releases that predated signing. That is over:
|
|
5718
|
-
// every release from v2.0.0 on is signed
|
|
6199
|
+
// every release from v2.0.0 on is signed.
|
|
5719
6200
|
// Leaving it false left a real downgrade path — strip or 404 the small .sig file and the missing-
|
|
5720
6201
|
// signature branch printed a warning and extracted 800MB+ of executable .mjs anyway. No alarm
|
|
5721
6202
|
// fired, because no signature was ever obtained. Now a missing signature fails closed like an
|
|
5722
6203
|
// invalid one, and --no-verify remains the single explicit, user-chosen override.
|
|
5723
6204
|
const SIGNING_REQUIRED = true;
|
|
5724
|
-
|
|
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) {
|
|
5725
6210
|
const sigPath = `${zipPath}.sig`;
|
|
5726
6211
|
const hasSig = fs.existsSync(sigPath);
|
|
5727
6212
|
if (!hasSig) {
|
|
@@ -5737,6 +6222,7 @@ the installer reports that boot-level declarations changed.
|
|
|
5737
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')}.)`);
|
|
5738
6223
|
}
|
|
5739
6224
|
ok(reason);
|
|
6225
|
+
signedBundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(zipPath)).digest('hex');
|
|
5740
6226
|
}
|
|
5741
6227
|
}
|
|
5742
6228
|
// The tag is only carried when it came from a genuine `latest` resolution — a pinned/offline
|
|
@@ -5745,6 +6231,12 @@ the installer reports that boot-level declarations changed.
|
|
|
5745
6231
|
await unzipInto(zipPath, cacheDir, sourceDir, {
|
|
5746
6232
|
releaseTag: release && release.source === 'latest' ? (release.tag_name || release.tag || null) : null,
|
|
5747
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
|
+
}
|
|
5748
6240
|
const brainProfile = readBrainProfile();
|
|
5749
6241
|
if (brainProfile !== 'complete') {
|
|
5750
6242
|
const scoped = applyBrainProfile(cacheDir, brainProfile);
|
|
@@ -5762,7 +6254,7 @@ the installer reports that boot-level declarations changed.
|
|
|
5762
6254
|
} catch (error) {
|
|
5763
6255
|
die(
|
|
5764
6256
|
`the Brain Console runtime could not be installed (${error.message})`,
|
|
5765
|
-
`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.`,
|
|
5766
6258
|
);
|
|
5767
6259
|
}
|
|
5768
6260
|
retireManagedHookRegistrations();
|
|
@@ -5825,6 +6317,11 @@ the installer reports that boot-level declarations changed.
|
|
|
5825
6317
|
// out what we did — which is precisely the position the 2026-07-20 corporate-machine reporter was
|
|
5826
6318
|
// left in. Derived from disk, so it can only ever describe what is actually there.
|
|
5827
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})`); }
|
|
5828
6325
|
|
|
5829
6326
|
// ── SCOPE + UPGRADE, on the path a real user actually takes ─────────────────────────────────
|
|
5830
6327
|
//
|
|
@@ -5904,6 +6401,8 @@ the installer reports that boot-level declarations changed.
|
|
|
5904
6401
|
mod.writeInstallState({
|
|
5905
6402
|
grounding,
|
|
5906
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),
|
|
5907
6406
|
});
|
|
5908
6407
|
if (grounding !== 'proven' && process.env.RUVNET_STRICT_INSTALL === '1') {
|
|
5909
6408
|
die(
|