ruvnet-brain 4.4.1 → 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.
Files changed (102) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +612 -113
  3. package/console/app.js +178 -81
  4. package/console/index.html +1 -1
  5. package/console/install-architecture.html +1 -0
  6. package/console/scope.css +4 -1
  7. package/console/style.css +13 -0
  8. package/console/tips.html +4 -4
  9. package/kb/brain-profile.mjs +1 -0
  10. package/kb/corpus-release-identity.mjs +1 -1
  11. package/kb/forge-update.mjs +41 -17
  12. package/kb/model-requirements.mjs +4 -1
  13. package/kb/update-storage-transaction.mjs +79 -0
  14. package/kb/zip-extract.mjs +22 -0
  15. package/package.json +1 -1
  16. package/plugin/.claude-plugin/plugin.json +1 -1
  17. package/plugin/.codex-plugin/plugin.json +1 -1
  18. package/plugin/commands/brain-console.md +5 -4
  19. package/plugin/commands/configure.md +5 -4
  20. package/plugin/commands/rnb-brief.md +41 -0
  21. package/plugin/commands/rnb.md +80 -0
  22. package/plugin/commands/rnbc.md +80 -0
  23. package/plugin/commands/rvbc.md +5 -4
  24. package/plugin/commands/rvcb.md +5 -4
  25. package/plugin/commands/whats-new.md +4 -4
  26. package/plugin/mcp/server.mjs +10 -2
  27. package/plugin/scripts/advocacy-route.mjs +59 -23
  28. package/plugin/scripts/anticipate.sh +4 -0
  29. package/plugin/scripts/brain-confirmation.mjs +258 -0
  30. package/plugin/scripts/brain-footprint.mjs +494 -0
  31. package/plugin/scripts/brain-location.mjs +47 -0
  32. package/plugin/scripts/capability-registry.mjs +11 -1
  33. package/plugin/scripts/continuity-brief.mjs +324 -0
  34. package/plugin/scripts/continuity-events.mjs +327 -0
  35. package/plugin/scripts/continuity-journal.mjs +500 -0
  36. package/plugin/scripts/decision-gate.mjs +56 -3
  37. package/plugin/scripts/footprint-io.mjs +186 -0
  38. package/plugin/scripts/ground-before-write.sh +8 -1
  39. package/plugin/scripts/ground-ruvnet.sh +103 -12
  40. package/plugin/scripts/grounding-answer.mjs +2 -1
  41. package/plugin/scripts/grounding-stamp.sh +3 -0
  42. package/plugin/scripts/grounding-substance.mjs +1 -1
  43. package/plugin/scripts/grounding-turn-evidence.mjs +17 -2
  44. package/plugin/scripts/hook-input.mjs +78 -4
  45. package/plugin/scripts/kb-copy-proof.mjs +148 -0
  46. package/plugin/scripts/lesson-bridge.mjs +6 -2
  47. package/plugin/scripts/nightly-controller.mjs +8 -1
  48. package/plugin/scripts/node-sqlite.mjs +41 -0
  49. package/plugin/scripts/package-cards.json +797 -0
  50. package/plugin/scripts/package-cards.rvf +0 -0
  51. package/plugin/scripts/package-cards.rvf.idmap.json +1 -0
  52. package/plugin/scripts/package-cards.rvf.meta.json +1 -0
  53. package/plugin/scripts/package-recommender-client.mjs +138 -0
  54. package/plugin/scripts/package-recommender-flag.mjs +30 -0
  55. package/plugin/scripts/package-recommender.mjs +391 -0
  56. package/plugin/scripts/project-progression-outbox.mjs +26 -8
  57. package/plugin/scripts/project-progression-reader.mjs +4 -2
  58. package/plugin/scripts/protect-brain-state.sh +4 -1
  59. package/plugin/scripts/session-snapshot-hook.mjs +27 -3
  60. package/plugin/scripts/session-start-budget.mjs +1 -0
  61. package/plugin/scripts/session-start-core.mjs +34 -4
  62. package/plugin/scripts/session-start-health.mjs +7 -1
  63. package/plugin/scripts/session-start-update-plane.mjs +35 -0
  64. package/plugin/scripts/turn-outcome-capture.mjs +12 -1
  65. package/plugin/scripts/unprompted-runtime.mjs +2 -2
  66. package/plugin/skills/brain-console/SKILL.md +3 -3
  67. package/plugin/skills/rnbc/SKILL.md +24 -0
  68. package/plugin/skills/rvbc/SKILL.md +2 -2
  69. package/scripts/approved-runtime.mjs +2 -2
  70. package/scripts/ci/warm-brain-models.mjs +28 -0
  71. package/scripts/codex-hook-trust.mjs +94 -0
  72. package/scripts/console-runtime-identity.mjs +5 -0
  73. package/scripts/corpus-canary.mjs +46 -6
  74. package/scripts/corpus-dispatch-decision.mjs +2 -2
  75. package/scripts/corpus-promotion.mjs +1 -1
  76. package/scripts/hook-qualify-hosts.mjs +15 -3
  77. package/scripts/host-install-matrix.mjs +63 -2
  78. package/scripts/human-approval-phrases.mjs +46 -0
  79. package/scripts/installed-brain-health.mjs +53 -0
  80. package/scripts/move-brain.mjs +310 -0
  81. package/scripts/onboarding-console.mjs +93 -10
  82. package/scripts/oracle/abstain-threshold-sweep.mjs +62 -0
  83. package/scripts/oracle/abstain-trace.mjs +139 -0
  84. package/scripts/oracle/doc2query-generate.mjs +162 -0
  85. package/scripts/oracle/doc2query-reach.mjs +110 -0
  86. package/scripts/oracle/judge-train.mjs +158 -0
  87. package/scripts/oracle/need-set-split.mjs +48 -0
  88. package/scripts/oracle/sona-query-adapter-eval.mjs +139 -0
  89. package/scripts/package-cards.mjs +374 -0
  90. package/scripts/publication-receipt.mjs +37 -9
  91. package/scripts/recommendation-e2e.mjs +110 -0
  92. package/scripts/recommendation-eval.mjs +105 -0
  93. package/scripts/recommendation-floor.mjs +56 -0
  94. package/scripts/recommendation-judge-score.mjs +74 -0
  95. package/scripts/recommendation-latency.mjs +95 -0
  96. package/scripts/recommendation-real-host-score.mjs +76 -0
  97. package/scripts/recommendation-real-host.mjs +137 -0
  98. package/scripts/release-channel-kind.mjs +1 -1
  99. package/scripts/release-environment-policy.mjs +33 -0
  100. package/scripts/single-source-check.mjs +15 -10
  101. package/scripts/sync-commands.mjs +5 -2
  102. 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
- const acceptNamed = (id) => new RegExp(`\\b(use|add|wire|set ?up|install|try|go with|switch to)\\b[^.!?]{0,30}\\b${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
200
- const declineNamed = (id) => new RegExp(`\\b(no|not|don'?t|skip|drop|without|forget)\\b[^.!?]{0,30}\\b${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
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 cap = String(offer.capability || '');
237
+ const { names: caps, set } = offerNames(offer); // a candidate set never resolves on a bare yes/no
231
238
  let action = null;
232
- if (cap && declineNamed(cap).test(text)) action = ACTIONS.DISMISSED;
233
- else if (cap && acceptNamed(cap).test(text)) action = ACTIONS.APPLIED;
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
- for (const capId of Object.keys(CAPABILITIES)) {
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: `${FINDING_PREFIX}${capId}` });
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 match = classify(prompt);
366
- if (!match) return { candidate: null, reason: 'no-intent' };
367
- const st = state || readState();
368
- const offers = offersOf(st, sessionId);
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 === match.capability)) return { candidate: null, reason: 'already-offered' };
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 = buildCandidate({ prompt, match, availability: availabilityOf(match.capability) });
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: match.capability, intent: match.intent.id,
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 { candidate } = decide({ prompt, sessionId, startedAt });
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
+ }