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
|
@@ -58,8 +58,11 @@ import crypto from 'node:crypto';
|
|
|
58
58
|
import { fileURLToPath } from 'node:url';
|
|
59
59
|
import { CAPABILITIES, INTENTS, MIN_CUES } from './advocacy-catalog.mjs';
|
|
60
60
|
import {
|
|
61
|
-
ACTIONS, record, shouldStillOffer, stateHashOf, precision, pendingOffers, reconcileIgnored,
|
|
61
|
+
ACTIONS, record, shouldStillOffer, stateHashOf, precision, pendingOffers, reconcileIgnored, loadOutcomes,
|
|
62
62
|
} from './advocacy-outcomes.mjs';
|
|
63
|
+
import { packageRecommenderEnabled, offerNames } from './package-recommender-flag.mjs';
|
|
64
|
+
// Matcher + socket client load only when the flag is on at process start: default-off pays nothing.
|
|
65
|
+
const PKG = packageRecommenderEnabled() ? { ...(await import('./package-recommender.mjs')), ...(await import('./package-recommender-client.mjs')) } : null;
|
|
63
66
|
|
|
64
67
|
const HOME = process.env.RUVNET_HOME_OVERRIDE || os.homedir();
|
|
65
68
|
|
|
@@ -67,6 +70,8 @@ const HOME = process.env.RUVNET_HOME_OVERRIDE || os.homedir();
|
|
|
67
70
|
* key that anticipate.sh offers into the SAME ledger. One ledger, two producers, disjoint identities. */
|
|
68
71
|
export const FINDING_PREFIX = 'recommend:';
|
|
69
72
|
|
|
73
|
+
export { packageRecommenderEnabled };
|
|
74
|
+
export const PACKAGE_PREFIX = `${FINDING_PREFIX}pkg:`; // package-card offers (ADR-093): same ledger and dial
|
|
70
75
|
export const BUDGET_MS = Number(process.env.RUVNET_ADVOCACY_ROUTE_BUDGET_MS) || 1500;
|
|
71
76
|
|
|
72
77
|
/** At most ONE recommendation per session. anticipate.sh allows two; this route is louder per offer
|
|
@@ -196,8 +201,10 @@ function putSession(st, sid, offers) {
|
|
|
196
201
|
// names first.
|
|
197
202
|
const ACCEPT_BARE = /^(y|yes|yeah|yep|ok|okay|sure|do it|go ahead|please do|sounds good|let'?s do it|use it)\b/i;
|
|
198
203
|
const DECLINE_BARE = /^(n|no|nope|nah|skip|not now|no thanks|leave it|don'?t)\b/i;
|
|
199
|
-
|
|
200
|
-
const
|
|
204
|
+
// Name boundaries that also hold for scoped ids ("@ruvector/typesafe"), where \b before "@" never matches.
|
|
205
|
+
const named = (verbs, id) => new RegExp(`\\b(${verbs})\\b[^.!?]{0,30}(?<![\\w@/-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w/-])`, 'i');
|
|
206
|
+
const acceptNamed = (id) => named('use|add|wire|set ?up|install|try|go with|switch to', id);
|
|
207
|
+
const declineNamed = (id) => named("no|not|don'?t|skip|drop|without|forget", id);
|
|
201
208
|
|
|
202
209
|
/**
|
|
203
210
|
* Resolve this session's still-pending offers against the prompt that just arrived.
|
|
@@ -227,12 +234,12 @@ export function resolvePriorOffers(promptText, sessionId, { file, state } = {})
|
|
|
227
234
|
let changed = false;
|
|
228
235
|
for (const offer of offers) {
|
|
229
236
|
if (!deliverable.has(offer.id)) continue;
|
|
230
|
-
const
|
|
237
|
+
const { names: caps, set } = offerNames(offer); // a candidate set never resolves on a bare yes/no
|
|
231
238
|
let action = null;
|
|
232
|
-
if (
|
|
233
|
-
else if (
|
|
234
|
-
else if (bareOk && DECLINE_BARE.test(text)) action = ACTIONS.DISMISSED;
|
|
235
|
-
else if (bareOk && ACCEPT_BARE.test(text)) action = ACTIONS.APPLIED;
|
|
239
|
+
if (caps.some((c) => declineNamed(c).test(text))) action = ACTIONS.DISMISSED;
|
|
240
|
+
else if (caps.some((c) => acceptNamed(c).test(text))) action = ACTIONS.APPLIED;
|
|
241
|
+
else if (bareOk && !set && DECLINE_BARE.test(text)) action = ACTIONS.DISMISSED;
|
|
242
|
+
else if (bareOk && !set && ACCEPT_BARE.test(text)) action = ACTIONS.APPLIED;
|
|
236
243
|
if (!action) continue;
|
|
237
244
|
let ok = false;
|
|
238
245
|
try {
|
|
@@ -280,9 +287,12 @@ export function summary({ file } = {}) {
|
|
|
280
287
|
// resolution here. Seven cheap reads beat a second copy of that logic drifting from the first.
|
|
281
288
|
const counts = { applied: 0, dismissed: 0, ignored: 0 };
|
|
282
289
|
let target = null;
|
|
283
|
-
|
|
290
|
+
// Package ids are open-ended: read them from the canonical ledger, not a static list.
|
|
291
|
+
const ids = new Set(Object.keys(CAPABILITIES).map((capId) => `${FINDING_PREFIX}${capId}`));
|
|
292
|
+
try { for (const r of loadOutcomes(...(file ? [file] : []))) if (r.id.startsWith(PACKAGE_PREFIX)) ids.add(r.id); } catch { /* catalogue ids only */ }
|
|
293
|
+
for (const id of ids) {
|
|
284
294
|
try {
|
|
285
|
-
const p = precision({ ...(file ? { file } : {}), id
|
|
295
|
+
const p = precision({ ...(file ? { file } : {}), id });
|
|
286
296
|
counts.applied += p.applied || 0;
|
|
287
297
|
counts.dismissed += p.dismissed || 0;
|
|
288
298
|
counts.ignored += p.ignored || 0;
|
|
@@ -355,27 +365,52 @@ export function buildCandidate({ prompt, match, availability }) {
|
|
|
355
365
|
};
|
|
356
366
|
}
|
|
357
367
|
|
|
368
|
+
/**
|
|
369
|
+
* The two lanes, as one shape: { capability, id, stateHash, intent, extra, build() }. The closed
|
|
370
|
+
* catalogue keeps precedence — each of its intents was measured missing on a real host. The package
|
|
371
|
+
* lane (ADR-0093) is consulted ONLY when the catalogue is silent AND the flag is an explicit opt-in.
|
|
372
|
+
*/
|
|
373
|
+
function chooseLane(prompt, env, semantic, getOffers, file) {
|
|
374
|
+
const match = classify(prompt);
|
|
375
|
+
if (match) {
|
|
376
|
+
return {
|
|
377
|
+
capability: match.capability, id: `${FINDING_PREFIX}${match.capability}`, intent: match.intent.id, extra: {},
|
|
378
|
+
stateHash: stateHashOf([`intent:${match.intent.id}`, `capability:${match.capability}`]),
|
|
379
|
+
build: () => buildCandidate({ prompt, match, availability: availabilityOf(match.capability) }),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
if (!packageRecommenderEnabled(env) || !PKG) return null;
|
|
383
|
+
const allowed = (id) => { try { return shouldStillOffer(id, { severity: 'normal', ...(file ? { file } : {}) }); } catch { return false; } };
|
|
384
|
+
const warm = PKG.semanticLane({ prompt, semantic, offered: new Set(getOffers().flatMap((o) => o?.candidates || [o?.capability])), allowed, findingPrefix: PACKAGE_PREFIX });
|
|
385
|
+
if (warm) return warm;
|
|
386
|
+
const pick = PKG.recommend(prompt); // cold worker: the lexical lane, fast and high-precision
|
|
387
|
+
if (!pick) return null;
|
|
388
|
+
return {
|
|
389
|
+
capability: PKG.shortName(pick.card), id: `${PACKAGE_PREFIX}${pick.card.id}`, intent: 'package-card',
|
|
390
|
+
extra: { package: pick.card.id }, stateHash: stateHashOf([`package:${pick.card.id}`]),
|
|
391
|
+
build: () => PKG.buildPackageCandidate({ prompt, pick, findingPrefix: PACKAGE_PREFIX }),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
358
395
|
/**
|
|
359
396
|
* The whole decision, minus process IO. Returns the candidate to emit, or null for SILENCE, and says
|
|
360
397
|
* WHY it stayed silent so a test can distinguish "no intent" from "already said" from "suppressed" —
|
|
361
398
|
* three very different bugs that all look identical from the outside.
|
|
362
399
|
*/
|
|
363
|
-
export function decide({ prompt, sessionId, file, state, now = Date.now(), startedAt = Date.now() }) {
|
|
400
|
+
export function decide({ prompt, sessionId, file, state, now = Date.now(), startedAt = Date.now(), env = process.env, semantic = null }) {
|
|
364
401
|
if (Date.now() - startedAt > BUDGET_MS) return { candidate: null, reason: 'budget-exceeded' };
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const offers =
|
|
369
|
-
if (offers.filter((o) => o && o.at).length >= MAX_PER_SESSION) return { candidate: null, reason: 'session-cap' };
|
|
370
|
-
if (offers.some((o) => o && o.capability ===
|
|
402
|
+
let st = null; const getOffers = () => offersOf((st ||= state || readState()), sessionId); // lazy: no lane, no read
|
|
403
|
+
const lane = chooseLane(prompt, env, semantic, getOffers, file);
|
|
404
|
+
if (!lane) return { candidate: null, reason: 'no-intent' };
|
|
405
|
+
const offers = getOffers();
|
|
406
|
+
if (offers.filter((o) => o && o.at).length >= (lane.cap || MAX_PER_SESSION)) return { candidate: null, reason: 'session-cap' };
|
|
407
|
+
if (offers.some((o) => o && o.capability === lane.capability)) return { candidate: null, reason: 'already-offered' };
|
|
371
408
|
|
|
372
|
-
const id = `${FINDING_PREFIX}${match.capability}`;
|
|
373
|
-
const stateHash = stateHashOf([`intent:${match.intent.id}`, `capability:${match.capability}`]);
|
|
374
409
|
let allowed = true;
|
|
375
|
-
try { allowed = shouldStillOffer(id, { severity: 'normal', stateHash, ...(file ? { file } : {}) }); } catch { allowed = false; }
|
|
410
|
+
try { allowed = shouldStillOffer(lane.id, { severity: 'normal', stateHash: lane.stateHash, ...(file ? { file } : {}) }); } catch { allowed = false; }
|
|
376
411
|
if (!allowed) return { candidate: null, reason: 'suppressed' };
|
|
377
412
|
|
|
378
|
-
const candidate =
|
|
413
|
+
const candidate = lane.build();
|
|
379
414
|
if (!candidate) return { candidate: null, reason: 'no-card' };
|
|
380
415
|
if (Date.now() - startedAt > BUDGET_MS) return { candidate: null, reason: 'budget-exceeded' };
|
|
381
416
|
|
|
@@ -384,7 +419,7 @@ export function decide({ prompt, sessionId, file, state, now = Date.now(), start
|
|
|
384
419
|
// speaking without remembering it, which repeats on the very next prompt; repeating is what gets a
|
|
385
420
|
// hook switched off for good.
|
|
386
421
|
offers.push({
|
|
387
|
-
id, capability:
|
|
422
|
+
id: lane.id, capability: lane.capability, intent: lane.intent, ...lane.extra,
|
|
388
423
|
at: new Date(now).toISOString(), promptHash: candidate.promptHash,
|
|
389
424
|
sessionId, severity: 'normal', resolved: null,
|
|
390
425
|
});
|
|
@@ -438,7 +473,8 @@ async function main() {
|
|
|
438
473
|
// decision is what lets "use agentic-qe" record an `applied` and still be classified on its merits.
|
|
439
474
|
try { resolvePriorOffers(prompt, sessionId); } catch { /* a lost transition costs one row, never the turn */ }
|
|
440
475
|
|
|
441
|
-
const
|
|
476
|
+
const semantic = PKG ? await PKG.semanticFor(prompt, { catalogueMatched: Boolean(classify(prompt)), startedAt }) : null;
|
|
477
|
+
const { candidate } = decide({ prompt, sessionId, startedAt, semantic });
|
|
442
478
|
if (!candidate) return 0;
|
|
443
479
|
|
|
444
480
|
if (process.env.RUVNET_EMIT_CANDIDATES === '1') {
|
|
@@ -389,6 +389,10 @@ function advocacyLevel() {
|
|
|
389
389
|
// version did) meant every real save through the console/CLI was invisible here and the dial
|
|
390
390
|
// silently fell back to the default. Keep a top-level fallback for a hand-written/legacy file.
|
|
391
391
|
const v = (parsed && parsed.settings && parsed.settings.advocacy) ?? (parsed && parsed.advocacy);
|
|
392
|
+
// The console's dial saves an INTEGER 1–5 (ADR-052). Level 1 is "Only when I ask" — silent here;
|
|
393
|
+
// 2–5 all let this hook's single high-bar nudge through (it has no severity axis to split on).
|
|
394
|
+
// Before this mapping every integer fell through to the speaking default (RNBC QA 2026-10-01).
|
|
395
|
+
if (Number.isInteger(v) && v >= 1 && v <= 5) return v === 1 ? 'off' : v >= 4 ? 'all' : 'important-only';
|
|
392
396
|
return (v === 'off' || v === 'important-only' || v === 'all') ? v : 'important-only';
|
|
393
397
|
} catch { return 'important-only'; }
|
|
394
398
|
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// brain-confirmation.mjs — POSITIVE CONFIRMATION (ADR-0098). One block that proves, from disk and from
|
|
2
|
+
// the registry, that this machine has the latest software, the latest knowledge, exactly one copy of it,
|
|
3
|
+
// that the search worker opened that copy, and that nothing else is building up. Printed after every
|
|
4
|
+
// install/update, by `--doctor` (and as JSON by `--doctor --json`), and as ONE SessionStart line only when
|
|
5
|
+
// something is wrong. Every failing line names the one command that fixes it.
|
|
6
|
+
//
|
|
7
|
+
// Readers reused, never restated: the footprint classifier (brain-footprint.mjs), per-process MCP
|
|
8
|
+
// readiness (mcp-readiness.mjs), the token ledger the search server already appends to on every answer
|
|
9
|
+
// (kb/forge-mcp-all.mjs meterLog), SOURCE.json / COVERAGE.json of the live KB, the Stable Spine's
|
|
10
|
+
// active.json, the Claude plugin registry, and host-update.mjs --check's recorded npm version.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import { cmpVersion, footprintRoots, physical } from './brain-footprint.mjs';
|
|
16
|
+
import { readAll as readReadiness } from './mcp-readiness.mjs';
|
|
17
|
+
import { volumeOf } from './brain-location.mjs';
|
|
18
|
+
|
|
19
|
+
export const KNOWLEDGE_MAX_AGE_HOURS = 48;
|
|
20
|
+
export const SIGNATURE_RECORD = 'knowledge-signature.json';
|
|
21
|
+
export const FOOTPRINT_LINE_PREFIX = '[RuvNet Brain — FOOTPRINT ';
|
|
22
|
+
const CLEAN = 'npx ruvnet-brain --clean';
|
|
23
|
+
const UPDATE = 'npx ruvnet-brain@latest --update';
|
|
24
|
+
|
|
25
|
+
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
26
|
+
const sha256File = (file) => { try { return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); } catch { return null; } };
|
|
27
|
+
const iso = (ms) => (Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 16).replace('T', ' ') + 'Z' : 'unknown');
|
|
28
|
+
const ago = (ms, now) => { const h = (now - ms) / 3_600_000; return h < 48 ? `${Math.max(0, Math.round(h))}h ago` : `${Math.round(h / 24)}d ago`; };
|
|
29
|
+
export const formatBytes = (n) => (n >= 1024 ** 3 ? `${(n / 1024 ** 3).toFixed(2)} GB` : `${Math.round(n / 1024 ** 2)} MB`);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Record that the bytes now live were signature-verified, bound to the live COVERAGE.json so a later
|
|
33
|
+
* replacement of the tree by anything unverified reads as unverified. Written by bin/install.mjs only.
|
|
34
|
+
*/
|
|
35
|
+
export function writeSignatureRecord({ brainHome, kbDir, bundleSha256, releaseTag = null, source, now = Date.now() }) {
|
|
36
|
+
const coverageSha256 = sha256File(path.join(kbDir, 'COVERAGE.json'));
|
|
37
|
+
const record = { schemaVersion: 1, kind: 'ruvnet-brain-knowledge-signature', verifiedAt: new Date(now).toISOString(),
|
|
38
|
+
bundleSha256, releaseTag, coverageSha256, source };
|
|
39
|
+
fs.mkdirSync(brainHome, { recursive: true });
|
|
40
|
+
const file = path.join(brainHome, SIGNATURE_RECORD);
|
|
41
|
+
fs.writeFileSync(`${file}.tmp-${process.pid}`, `${JSON.stringify(record, null, 2)}\n`);
|
|
42
|
+
fs.renameSync(`${file}.tmp-${process.pid}`, file);
|
|
43
|
+
return record;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The newest successful search answer the server metered (kb/forge-mcp-all.mjs meterLog), from the tail only. */
|
|
47
|
+
export function lastAnswerMs(ledgerFile, tailBytes = 256 * 1024) {
|
|
48
|
+
let text = '';
|
|
49
|
+
try {
|
|
50
|
+
const size = fs.statSync(ledgerFile).size;
|
|
51
|
+
const fd = fs.openSync(ledgerFile, 'r');
|
|
52
|
+
const buf = Buffer.alloc(Math.min(size, tailBytes));
|
|
53
|
+
try { fs.readSync(fd, buf, 0, buf.length, size - buf.length); } finally { fs.closeSync(fd); }
|
|
54
|
+
text = buf.toString('utf8');
|
|
55
|
+
} catch { return null; }
|
|
56
|
+
for (const line of text.split('\n').reverse()) {
|
|
57
|
+
try {
|
|
58
|
+
const e = JSON.parse(line);
|
|
59
|
+
if (e?.source === 'mcp' && e.tool === 'search_ruvnet' && !e.disabled && Date.parse(e.ts)) return Date.parse(e.ts);
|
|
60
|
+
} catch { /* a partial first line */ }
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Latest published version: the caller's live registry read, else host-update.mjs --check's recorded one. */
|
|
66
|
+
function npmLatestFrom({ npmLatest, brainHome }) {
|
|
67
|
+
if (npmLatest?.version) return npmLatest;
|
|
68
|
+
const file = path.join(brainHome, '.last-version-check.log');
|
|
69
|
+
try {
|
|
70
|
+
const version = fs.readFileSync(file, 'utf8').split(/\r?\n/).map((l) => l.trim()).find((l) => /^\d+\.\d+\.\d+/.test(l));
|
|
71
|
+
if (version) return { version, checkedAt: fs.statSync(file).mtimeMs, source: 'last background check' };
|
|
72
|
+
} catch { /* never checked */ }
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// state: 'ok' ✓ | 'fail' ✗ (structural: gates the verdict) | 'warn' ! (currency: shown with its fix, never gates)
|
|
77
|
+
// | 'unknown' ○. Currency is advisory on purpose: a correctly installed OLDER build (a recovery re-run of an
|
|
78
|
+
// earlier release, a quiet week with no new corpus) must still pass install verification (`--doctor --hooks`).
|
|
79
|
+
const line = (id, label, state, detail, fix = null) => ({ id, label, state, detail, fix: state === 'fail' || state === 'warn' ? fix : null });
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param footprint inventoryFootprint()/sweepFootprint().after output (required)
|
|
83
|
+
* @param npmLatest {version, checkedAt, source} from a live registry read, or null (falls back to the record)
|
|
84
|
+
* @param installedVersion the package version the caller runs, used when no spine is active
|
|
85
|
+
*/
|
|
86
|
+
export function confirm({ footprint, env = process.env, home = os.homedir(), now = Date.now(), npmLatest = null,
|
|
87
|
+
installedVersion = null, readiness = null } = {}) {
|
|
88
|
+
const roots = footprint?.roots || footprintRoots({ env, home });
|
|
89
|
+
const lines = [];
|
|
90
|
+
const active = readJson(path.join(roots.brainHome, 'active.json'));
|
|
91
|
+
const runtimeIdentity = readJson(path.join(roots.kbDir, 'RUNTIME-IDENTITY.json'));
|
|
92
|
+
const installed = active?.version || runtimeIdentity?.brainVersion || installedVersion || null;
|
|
93
|
+
|
|
94
|
+
// Software
|
|
95
|
+
const latest = npmLatestFrom({ npmLatest, brainHome: roots.brainHome });
|
|
96
|
+
if (!installed) lines.push(line('software', 'Software', 'fail', 'no installed Brain runtime found', 'npx ruvnet-brain@latest'));
|
|
97
|
+
else if (!latest) lines.push(line('software', 'Software', 'unknown', `${installed} installed; npm latest could not be checked (offline?)`));
|
|
98
|
+
else {
|
|
99
|
+
const behind = cmpVersion(installed, latest.version) < 0;
|
|
100
|
+
lines.push(line('software', 'Software', behind ? 'warn' : 'ok',
|
|
101
|
+
`${installed} installed ${behind ? '<' : '='} npm latest ${latest.version} (checked ${iso(latest.checkedAt)}, ${latest.source || 'npm registry'})`, UPDATE));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Hosts
|
|
105
|
+
const runtime = active?.version || installed;
|
|
106
|
+
const hosts = footprint.items.filter((i) => i.id === 'plugin' && i.class === 'must-exist');
|
|
107
|
+
if (!hosts.length) lines.push(line('hosts', 'Hosts', 'unknown', 'no Claude Code or Codex plugin registered on this machine'));
|
|
108
|
+
else {
|
|
109
|
+
const off = hosts.filter((h) => h.version !== runtime);
|
|
110
|
+
const label = (h) => `${h.host === 'claude' ? 'Claude Code' : 'Codex'} ${h.version}`;
|
|
111
|
+
lines.push(line('hosts', 'Hosts', !runtime ? 'fail' : off.length ? 'warn' : 'ok',
|
|
112
|
+
`${hosts.map(label).join(' · ')} ${off.length ? '≠' : '='} runtime ${runtime || 'unknown'}`, UPDATE));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Knowledge — an unmounted moved brain is reported, never "fixed" by a reinstall beside the dead link.
|
|
116
|
+
if (roots.dangling) {
|
|
117
|
+
lines.push(line('knowledge', 'Knowledge', 'fail', roots.location.message,
|
|
118
|
+
`mount ${roots.location.volume}, then: npx ruvnet-brain --doctor`));
|
|
119
|
+
return { schemaVersion: 1, kind: 'ruvnet-brain-confirmation', ok: false, checkedAt: new Date(now).toISOString(), lines,
|
|
120
|
+
location: roots.location, footprint: { kbCopies: 0, totalBytes: 0, budgetBytes: 0, breakdown: {}, cruft: [], unowned: [] } };
|
|
121
|
+
}
|
|
122
|
+
const source = readJson(path.join(roots.kbDir, 'SOURCE.json'));
|
|
123
|
+
const builtMs = Date.parse(source?.builtUtc || '');
|
|
124
|
+
const signature = readJson(path.join(roots.brainHome, SIGNATURE_RECORD));
|
|
125
|
+
const coverage = sha256File(path.join(roots.kbDir, 'COVERAGE.json'));
|
|
126
|
+
const signed = Boolean(signature?.coverageSha256 && coverage && signature.coverageSha256 === coverage);
|
|
127
|
+
const tag = source?.corpusReleaseTag || source?.releaseTag || null;
|
|
128
|
+
// Structural problems (a second copy, unverified bytes) gate; age is currency and only advises.
|
|
129
|
+
const knowledgeProblems = [];
|
|
130
|
+
const knowledgeAdvice = [];
|
|
131
|
+
if (footprint.kbCopies !== 1) knowledgeProblems.push([`${footprint.kbCopies} copies on disk (must be exactly 1)`, footprint.kbCopyFix || (footprint.kbCopies ? CLEAN : 'npx ruvnet-brain@latest')]);
|
|
132
|
+
if (!signed) knowledgeProblems.push([signature ? 'signature record does not match the live COVERAGE.json' : 'no signature verification recorded for these bytes', UPDATE]);
|
|
133
|
+
if (!Number.isFinite(builtMs) || (now - builtMs) / 3_600_000 >= KNOWLEDGE_MAX_AGE_HOURS) knowledgeAdvice.push([`built ${Number.isFinite(builtMs) ? ago(builtMs, now) : 'at an unknown time'} (limit ${KNOWLEDGE_MAX_AGE_HOURS}h)`, UPDATE]);
|
|
134
|
+
const where = roots.location?.state === 'linked'
|
|
135
|
+
? `at ${roots.kbDir} (moved to ${volumeOf(roots.location.real)}, mounted)` : `at ${roots.kbDir}`;
|
|
136
|
+
const moved = footprint.moveLeftovers || { copies: 0, bytes: 0 };
|
|
137
|
+
const knowledgeDetail = [`${footprint.kbCopies} copy ${where}${moved.copies ? ` (not counted: ${moved.copies} interrupted-move cop${moved.copies === 1 ? 'y' : 'ies'} (${formatBytes(moved.bytes)}) — see the Move lines)` : ''}`, `built ${iso(builtMs)}${Number.isFinite(builtMs) ? ` (${ago(builtMs, now)})` : ''}`,
|
|
138
|
+
signed ? `signature verified ${iso(Date.parse(signature.verifiedAt))}` : 'signature NOT verified', `corpus ${tag ? (tag.length > 28 ? `${tag.slice(0, 26)}…` : tag) : 'unknown'}`].join(' · ');
|
|
139
|
+
const knowledgeIssues = [...knowledgeProblems, ...knowledgeAdvice];
|
|
140
|
+
lines.push(line('knowledge', 'Knowledge', knowledgeProblems.length ? 'fail' : knowledgeAdvice.length ? 'warn' : 'ok',
|
|
141
|
+
knowledgeIssues.length ? `${knowledgeDetail} — ${knowledgeIssues.map(([p]) => p).join('; ')}` : knowledgeDetail, knowledgeIssues[0]?.[1]));
|
|
142
|
+
|
|
143
|
+
// Interrupted --move-brain leftovers: one line each, reported, never removed. The set-aside original is ✗ when
|
|
144
|
+
// it is the only copy (the next step is to put it back); otherwise ! with the delete that finishes the move.
|
|
145
|
+
for (const i of footprint.items.filter((x) => x.kind === 'move-leftover')) {
|
|
146
|
+
lines.push(line('move-leftover', 'Move', i.onlyCopy ? 'fail' : 'warn', `${i.reason}${i.onlyCopy ? ' — the ONLY copy of the Brain' : ''}: ${i.path}`, i.fix));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// In use
|
|
150
|
+
const records = (readiness || readReadiness(roots.brainHome)).filter((r) => r.state === 'ready');
|
|
151
|
+
const ledger = path.join(env.XDG_CACHE_HOME ? path.join(env.XDG_CACHE_HOME, 'ruvnet-brain') : roots.brainHome, 'token-ledger.jsonl');
|
|
152
|
+
const answered = lastAnswerMs(ledger);
|
|
153
|
+
const answerText = answered ? `last answer ${ago(answered, now)}` : 'no answer recorded yet';
|
|
154
|
+
const opened = records.filter((r) => r.kbDir);
|
|
155
|
+
const elsewhere = opened.filter((r) => physical(r.kbDir) !== roots.kbDir);
|
|
156
|
+
if (elsewhere.length) lines.push(line('in-use', 'In use', 'fail', `search worker pid ${elsewhere[0].pid} opened ${elsewhere[0].kbDir}, not ${roots.kbDir}; ${answerText}`, 'restart Claude Code / Codex (the worker re-opens the live KB)'));
|
|
157
|
+
else if (opened.length) lines.push(line('in-use', 'In use', 'ok', `search worker pid ${opened.map((r) => r.pid).join(', ')} opened this copy; ${answerText}`));
|
|
158
|
+
else lines.push(line('in-use', 'In use', 'unknown', `${records.length ? 'a search worker is ready but does not report its KB path' : 'no search worker is running right now'}; ${answerText}`));
|
|
159
|
+
|
|
160
|
+
// Footprint
|
|
161
|
+
const parts = Object.entries(footprint.breakdown).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${formatBytes(v)}`);
|
|
162
|
+
const foreign = footprint.unowned.reduce((n, i) => n + (i.bytes || 0), 0);
|
|
163
|
+
lines.push(line('footprint', 'Footprint', footprint.withinBudget ? 'ok' : 'fail',
|
|
164
|
+
`${formatBytes(footprint.totalBytes)} of ${formatBytes(footprint.budgetBytes)} budget (${parts.join(' · ')})${foreign ? `; not counted: ${formatBytes(foreign)} owned by other tools` : ''}`, CLEAN));
|
|
165
|
+
|
|
166
|
+
// No cruft
|
|
167
|
+
const cruft = footprint.cruft;
|
|
168
|
+
const count = (pred) => cruft.filter(pred).length;
|
|
169
|
+
const tally = [`${count((i) => /kb-copy|quarantine/.test(i.kind))} extra KB copies`,
|
|
170
|
+
`${count((i) => /install-stage|forge-candidate/.test(i.kind))} abandoned builds`,
|
|
171
|
+
`${count((i) => i.kind === 'npx-copy')} old installers`, `${count((i) => /plugin-generation|spine/.test(i.kind))} stale plugin generations`,
|
|
172
|
+
`${count((i) => /log/.test(i.kind))} over-cap logs`,
|
|
173
|
+
`${count((i) => !/kb-copy|quarantine|install-stage|forge-candidate|npx-copy|plugin-generation|spine|log/.test(i.kind))} scratch/backup leftovers`];
|
|
174
|
+
const removable = cruft.filter((i) => ['remove', 'remove-if-proven', 'rotate', 'truncate', 'collect', 'prune'].includes(i.action));
|
|
175
|
+
const fix = removable.length ? CLEAN : (cruft.find((i) => i.fix)?.fix || CLEAN);
|
|
176
|
+
lines.push(line('cruft', 'No cruft', cruft.length ? 'fail' : 'ok', `${tally.join(' · ')}${cruft.length ? ` — ${cruft.slice(0, 3).map((i) => path.basename(i.path)).join(', ')}${cruft.length > 3 ? ', …' : ''}` : ''}`, fix));
|
|
177
|
+
|
|
178
|
+
const ok = lines.every((l) => l.state !== 'fail');
|
|
179
|
+
return { schemaVersion: 1, kind: 'ruvnet-brain-confirmation', ok, checkedAt: new Date(now).toISOString(), lines,
|
|
180
|
+
location: roots.location || null,
|
|
181
|
+
footprint: { kbCopies: footprint.kbCopies, kbCopyFix: footprint.kbCopyFix || null, totalBytes: footprint.totalBytes, budgetBytes: footprint.budgetBytes,
|
|
182
|
+
breakdown: footprint.breakdown, cruft: cruft.map(({ path: p, kind, bytes, reason, action }) => ({ path: p, kind, bytes, reason, action })),
|
|
183
|
+
unowned: footprint.unowned.map(({ path: p, kind, bytes, reason }) => ({ path: p, kind, bytes, reason })) } };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const MARK = { ok: '✓', fail: '✗', warn: '!', unknown: '○' };
|
|
187
|
+
/** The human block. `color` is an optional { green, red, yellow, dim, bold } painter. `summary:false` omits the
|
|
188
|
+
* closing line, for a caller (`--doctor`) that prints the ONE verdict itself from the same `ok`. */
|
|
189
|
+
export function formatConfirmation(result, { color = null, summary = true } = {}) {
|
|
190
|
+
const paint = (state, text) => (color ? (state === 'ok' ? color.green(text) : state === 'fail' ? color.red(text)
|
|
191
|
+
: state === 'warn' ? (color.yellow || color.dim)(text) : color.dim(text)) : text);
|
|
192
|
+
const out = [' Positive confirmation'];
|
|
193
|
+
for (const l of result.lines) {
|
|
194
|
+
out.push(` ${paint(l.state, MARK[l.state] || '○')} ${l.label.padEnd(10)} ${l.detail}`);
|
|
195
|
+
if (l.fix) out.push(` fix: ${l.fix}`);
|
|
196
|
+
}
|
|
197
|
+
const advisories = result.lines.filter((l) => l.state === 'warn').length;
|
|
198
|
+
if (summary) {
|
|
199
|
+
out.push(` ${!result.ok ? 'Not green — run the fix named on each ✗ line.'
|
|
200
|
+
: advisories ? `Green — ${advisories} advisory line(s) marked ! do not block; each names its fix.` : 'All checks that can be proven here are green.'}`);
|
|
201
|
+
}
|
|
202
|
+
return out.join('\n');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* THE ONE VERDICT (review S5). `--doctor` text, `--doctor --json` and the exit code all come from this:
|
|
207
|
+
* failing iff ANY line is ✗ — the positive-confirmation lines plus the doctor's own checks (each a line
|
|
208
|
+
* `{ id, label, state, detail, fix }`). '!' lines are advisory (currency) and never fail it.
|
|
209
|
+
*/
|
|
210
|
+
export function doctorVerdict(confirmation, checks = []) {
|
|
211
|
+
const lines = [...(confirmation?.lines || []), ...checks];
|
|
212
|
+
const failing = lines.filter((l) => l.state === 'fail').map((l) => l.id);
|
|
213
|
+
return { ...confirmation, kind: 'ruvnet-brain-doctor', lines, ok: failing.length === 0, failing,
|
|
214
|
+
advisories: lines.filter((l) => l.state === 'warn').map((l) => l.id), exitCode: failing.length ? 1 : 0 };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Is there a signature record bound to the bytes live now? */
|
|
218
|
+
export function signatureRecordValid({ brainHome, kbDir }) {
|
|
219
|
+
const signature = readJson(path.join(brainHome, SIGNATURE_RECORD));
|
|
220
|
+
const coverage = sha256File(path.join(kbDir, 'COVERAGE.json'));
|
|
221
|
+
return Boolean(signature?.coverageSha256 && coverage && signature.coverageSha256 === coverage && signature.bundleSha256);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Evidence that the bytes live now were signature-verified, from this machine's own refresh receipts: a
|
|
226
|
+
* SUCCEEDED run whose updater APPLIED a bundle (the updater refuses unsigned or mis-signed bundles, exit
|
|
227
|
+
* 3/4) and whose recorded coverage digest equals the live COVERAGE.json. Used by `--update` when it applies
|
|
228
|
+
* nothing, so "fix: --update" on a missing record is a fix that works. null when no such run exists.
|
|
229
|
+
*/
|
|
230
|
+
export function signatureEvidenceFromReceipts({ brainHome, kbDir }) {
|
|
231
|
+
const coverage = sha256File(path.join(kbDir, 'COVERAGE.json'));
|
|
232
|
+
if (!coverage) return null;
|
|
233
|
+
const dir = path.join(brainHome, 'refresh-runs');
|
|
234
|
+
let names = [];
|
|
235
|
+
try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort().reverse(); } catch { return null; }
|
|
236
|
+
for (const name of names) {
|
|
237
|
+
const receipt = readJson(path.join(dir, name));
|
|
238
|
+
if (receipt?.status !== 'SUCCEEDED' || !Array.isArray(receipt.phases)) continue;
|
|
239
|
+
const phase = (id) => receipt.phases.find((p) => p?.phase === id && p.status === 'PASS')?.evidence || null;
|
|
240
|
+
const bundleSha256 = phase('bundle-assembly')?.bundleSha256;
|
|
241
|
+
if (phase('update')?.terminalVerdict === 'applied' && /^[a-f0-9]{64}$/.test(bundleSha256 || '')
|
|
242
|
+
&& phase('coverage-generation')?.coverageSha256 === coverage) return { bundleSha256, runId: receipt.runId || name, receipt: path.join(dir, name) };
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** SessionStart: ONE line, only when the footprint itself is wrong (currency has its own line). */
|
|
248
|
+
export function footprintAlarm(result) {
|
|
249
|
+
if (result.location?.state === 'unmounted') {
|
|
250
|
+
const k = result.lines.find((l) => l.id === 'knowledge');
|
|
251
|
+
return `${FOOTPRINT_LINE_PREFIX}BRAIN VOLUME NOT MOUNTED] ${k.detail}. Fix: ${k.fix}.`;
|
|
252
|
+
}
|
|
253
|
+
const bad = result.lines.filter((l) => l.state === 'fail' && ['cruft', 'footprint', 'in-use'].includes(l.id));
|
|
254
|
+
const copies = result.footprint.kbCopies;
|
|
255
|
+
if (copies !== 1) bad.unshift({ detail: `${copies} knowledge-base copies on disk (must be exactly 1)`, fix: result.footprint.kbCopyFix || CLEAN });
|
|
256
|
+
if (!bad.length) return '';
|
|
257
|
+
return `${FOOTPRINT_LINE_PREFIX}NOT CLEAN] ${bad.map((l) => l.detail).join('; ')}. Fix: ${bad[0].fix} (verify: npx ruvnet-brain --doctor).`;
|
|
258
|
+
}
|