ruvnet-brain 4.3.21 → 4.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +275 -60
  3. package/console/app.js +141 -9
  4. package/console/index.html +51 -24
  5. package/console/scope.css +137 -0
  6. package/console/scope.html +144 -0
  7. package/console/scope.js +209 -0
  8. package/console/tips.html +1 -0
  9. package/kb/corpus-release-identity.mjs +239 -0
  10. package/kb/update-storage-transaction.mjs +20 -3
  11. package/package.json +9 -2
  12. package/plugin/.claude-plugin/plugin.json +2 -2
  13. package/plugin/.codex-plugin/plugin.json +1 -1
  14. package/plugin/commands/checkpoint.md +61 -0
  15. package/plugin/hooks/codex-hooks.json +64 -1
  16. package/plugin/hooks/hook-contracts.json +299 -6
  17. package/plugin/hooks/hooks.json +81 -1
  18. package/plugin/mcp/server.mjs +23 -0
  19. package/plugin/scripts/advocacy-catalog.mjs +245 -0
  20. package/plugin/scripts/advocacy-route.mjs +460 -0
  21. package/plugin/scripts/continuation-gate.mjs +25 -2
  22. package/plugin/scripts/continuation-objective.mjs +7 -1
  23. package/plugin/scripts/continuity-hook-policy.mjs +190 -15
  24. package/plugin/scripts/coverage-integrity.mjs +7 -0
  25. package/plugin/scripts/gates.mjs +113 -10
  26. package/plugin/scripts/grounding-turn-gate.mjs +167 -0
  27. package/plugin/scripts/grounding-turn-mark.mjs +91 -0
  28. package/plugin/scripts/hook-shim.mjs +14 -0
  29. package/plugin/scripts/nightly-scheduler.mjs +37 -4
  30. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  31. package/plugin/scripts/project-progression-contract.mjs +16 -0
  32. package/plugin/scripts/project-progression-hook.mjs +3 -0
  33. package/plugin/scripts/project-progression-producer.mjs +252 -0
  34. package/plugin/scripts/project-progression-reader.mjs +271 -0
  35. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  36. package/plugin/scripts/project-progression-sources.mjs +220 -0
  37. package/plugin/scripts/project-progression-store.mjs +106 -13
  38. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  39. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  40. package/plugin/scripts/session-start-budget.mjs +59 -0
  41. package/plugin/scripts/session-start-core.mjs +234 -457
  42. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  43. package/plugin/scripts/session-start-health.mjs +64 -0
  44. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  45. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  46. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  47. package/plugin/scripts/session-start-signals.mjs +73 -0
  48. package/plugin/scripts/session-start-trace.mjs +86 -0
  49. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  50. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  51. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  52. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  53. package/scripts/adr-072-completion.mjs +1 -1
  54. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  55. package/scripts/approved-runtime.mjs +197 -0
  56. package/scripts/brain-novice-50.mjs +16 -1
  57. package/scripts/brain-score.mjs +23 -5
  58. package/scripts/build-bundle.mjs +971 -530
  59. package/scripts/build-concepts.mjs +36 -116
  60. package/scripts/console-engine.test.mjs +8 -7
  61. package/scripts/console-runtime-identity.mjs +4 -0
  62. package/scripts/corpus-aggregates.mjs +94 -77
  63. package/scripts/corpus-candidate.mjs +475 -222
  64. package/scripts/corpus-next-seed.mjs +225 -0
  65. package/scripts/corpus-promotion.mjs +58 -0
  66. package/scripts/corpus-reconcile.mjs +411 -105
  67. package/scripts/doc-currency.mjs +16 -1
  68. package/scripts/dual-host-deliberation.mjs +25 -2
  69. package/scripts/dual-host-suggest.mjs +17 -1
  70. package/scripts/falsify.mjs +13 -3
  71. package/scripts/gist-receipts.mjs +482 -87
  72. package/scripts/github-health-watch.mjs +12 -2
  73. package/scripts/handoff-asset.mjs +34 -0
  74. package/scripts/hook-retirement-check.mjs +8 -1
  75. package/scripts/host-registry.mjs +1 -1
  76. package/scripts/ingest-gists.mjs +74 -101
  77. package/scripts/job-heartbeat.sh +77 -14
  78. package/scripts/learning-replay-execution.mjs +10 -4
  79. package/scripts/nightly-gists.sh +27 -13
  80. package/scripts/nightly-two-run-proof.mjs +1 -1
  81. package/scripts/nightly-watchdog.mjs +61 -4
  82. package/scripts/onboarding-console.mjs +319 -27
  83. package/scripts/oracle/produce-questions.mjs +293 -0
  84. package/scripts/oracle/producer-hosts.mjs +235 -0
  85. package/scripts/oracle/repo-recall.mjs +448 -0
  86. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  87. package/scripts/oracle/source-tree.mjs +165 -0
  88. package/scripts/oracle/source-units.mjs +391 -0
  89. package/scripts/oracle/spike-run.mjs +98 -0
  90. package/scripts/oracle/unit-inventory.mjs +141 -0
  91. package/scripts/oracle/unit-sampling.mjs +128 -0
  92. package/scripts/oracle/validate-labels.mjs +250 -0
  93. package/scripts/private-overlay.mjs +248 -0
  94. package/scripts/product-integrity-contract.mjs +1 -1
  95. package/scripts/proxy/claude-proxied.sh +6 -0
  96. package/scripts/proxy/proxy-revert.sh +5 -0
  97. package/scripts/proxy/proxy-up.sh +6 -0
  98. package/scripts/proxy/proxy-verify.mjs +4 -0
  99. package/scripts/public-inputs.mjs +409 -0
  100. package/scripts/public-verification-inputs.mjs +112 -26
  101. package/scripts/public-verification-lane.mjs +1 -1
  102. package/scripts/published-surface-probe.mjs +34 -4
  103. package/scripts/qe/card-lane-gate.mjs +16 -1
  104. package/scripts/qe/session-start-gate.mjs +16 -1
  105. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  106. package/scripts/record-lesson.mjs +4 -1
  107. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  108. package/scripts/release-abort-stale.mjs +5 -1
  109. package/scripts/release-authority.mjs +104 -12
  110. package/scripts/release-channel-kind.mjs +86 -0
  111. package/scripts/release-convergence-watchdog.mjs +7 -2
  112. package/scripts/release-projection.mjs +177 -72
  113. package/scripts/release-transaction-provider.mjs +23 -6
  114. package/scripts/release.mjs +252 -17
  115. package/scripts/retrieval-canary.mjs +87 -0
  116. package/scripts/rvf-index-audit.mjs +573 -13
  117. package/scripts/rvf-wire.mjs +269 -0
  118. package/scripts/seal-gist-receipt.mjs +65 -0
  119. package/scripts/selfcheck.mjs +42 -21
  120. package/scripts/source-coverage.mjs +253 -24
  121. package/scripts/status-honesty.mjs +25 -0
  122. package/scripts/sync-census.mjs +0 -0
  123. package/scripts/sync-version.mjs +2 -0
  124. package/scripts/trismart.mjs +42 -0
  125. package/scripts/updater-manifest.mjs +162 -0
  126. package/scripts/verify-channels.mjs +17 -5
  127. package/scripts/wired-check.mjs +48 -10
  128. package/tri-smart-skill/QUICKSTART.md +37 -0
  129. package/tri-smart-skill/README.md +92 -0
  130. package/tri-smart-skill/install.cmd +14 -0
  131. package/tri-smart-skill/install.command +13 -0
  132. package/tri-smart-skill/install.mjs +51 -0
  133. package/tri-smart-skill/install.sh +9 -0
  134. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  135. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  136. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  137. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  138. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  139. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  140. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  141. package/scripts/corpus-seed-publish.mjs +0 -110
@@ -39,6 +39,7 @@ import os from 'node:os';
39
39
  import { spawnSync, execFileSync } from 'node:child_process';
40
40
  import { fileURLToPath, pathToFileURL } from 'node:url';
41
41
  import { NIGHTLY_LABEL, schedulerStatus } from '../plugin/scripts/nightly-scheduler.mjs';
42
+ import { oldestIncompleteProgress } from '../kb/shard-progress.mjs';
42
43
 
43
44
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
44
45
  const REGISTRY = process.env.WATCHDOG_REGISTRY || path.join(ROOT, 'config', 'scheduled-jobs.json');
@@ -51,6 +52,28 @@ export const MISSING = 'MISSING';
51
52
  export const NEVER_RAN = 'NEVER-RAN';
52
53
  export const STALE = 'STALE';
53
54
  export const FAILING = 'FAILING';
55
+ export const STALLED = 'STALLED';
56
+
57
+ /**
58
+ * Per-shard progress evidence for a job that declares `progressGlob` + `stallMinutes` in the
59
+ * registry (see kb/shard-progress.mjs, written by kb/forge-big.mjs's `embed`/`shard-all` modes).
60
+ *
61
+ * WHY THIS EXISTS (2026-09-11): a live wrapper pid proves the WRAPPER survived a run; it proves
62
+ * NOTHING about whether the work inside it is still moving — an 8-shard gists embed once sat at 0%
63
+ * CPU for six hours with `judge()`'s existing pid-liveness check reporting it OK the entire time
64
+ * (the pid genuinely was alive; it just was not doing anything). `job.stallMinutes` is deliberately
65
+ * independent of `job.maxAgeHours`/the 6h long-run branch below — a job stuck at 0% progress for 20
66
+ * minutes must be flagged well before its overall schedule budget or the long-run grace period would
67
+ * ever trip.
68
+ */
69
+ export function readJobProgress(job, { root = ROOT } = {}) {
70
+ if (!job.progressGlob) return null;
71
+ const full = path.join(root, job.progressGlob);
72
+ const dir = path.dirname(full);
73
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
74
+ const pattern = new RegExp(`^${path.basename(full).split('*').map(escapeRegExp).join('.*')}$`);
75
+ return oldestIncompleteProgress(dir, pattern);
76
+ }
54
77
 
55
78
  /** Labels currently loaded in launchd. A job that isn't here CANNOT fire, whatever its plist says. */
56
79
  export function loadedLabels(run = () => spawnSync('launchctl', ['list'], { encoding: 'utf8' }).stdout || '') {
@@ -67,7 +90,7 @@ export function loadedLabels(run = () => spawnSync('launchctl', ['list'], { enco
67
90
  * Judge one job from its receipt. `hb === null` means NO RECEIPT EXISTS — which is NEVER-RAN, never OK.
68
91
  * This single line is the whole lesson of 2026-07-13: absence of evidence is not evidence of health.
69
92
  */
70
- export function judge(job, hb, loaded, now) {
93
+ export function judge(job, hb, loaded, now, { root = ROOT } = {}) {
71
94
  if (!loaded) {
72
95
  return { state: MISSING, detail: 'declared in the registry but NOT LOADED in launchd — it can never fire' };
73
96
  }
@@ -80,6 +103,20 @@ export function judge(job, hb, loaded, now) {
80
103
  }
81
104
  const ageHours = (now - stamp) / HOUR;
82
105
 
106
+ // STALLED: checked BEFORE the long-run pid-liveness branch below, and independent of it — a live
107
+ // pid is not proof of progress. Only applies when the job declares a stall budget; absence of a
108
+ // progress file is never treated as a stall (the job may not have reached embedding yet).
109
+ if (hb.state === 'running' && job.stallMinutes) {
110
+ const progress = readJobProgress(job, { root });
111
+ if (progress) {
112
+ const staleMinutes = (now - progress.updatedAt) / 60_000;
113
+ if (staleMinutes > job.stallMinutes) {
114
+ return { state: STALLED, ageHours,
115
+ detail: `${progress.detail} has not advanced in ${staleMinutes.toFixed(0)}m — its stall budget is ${job.stallMinutes}m. The wrapper pid may be alive; the work is not.` };
116
+ }
117
+ }
118
+ }
119
+
83
120
  // Started and never finished? DERIVE it from process liveness, not wall-clock alone (2026-07-19):
84
121
  // a full corpus rebuild legitimately runs 12h+ (five changed repos = a long day), and the old
85
122
  // ">6h running ⇒ FAILING" rule false-alarmed on exactly that — a 13.5h nightly whose worker was
@@ -110,8 +147,12 @@ export function judge(job, hb, loaded, now) {
110
147
  if (hb.state === 'skipped') {
111
148
  return { state: NEVER_RAN, ageHours, detail: 'only a lock-skip receipt exists — no real run has ever recorded a result' };
112
149
  }
113
- if (hb.state === 'failed' || (typeof hb.exit_code === 'number' && hb.exit_code !== 0)) {
114
- return { state: FAILING, ageHours, detail: `last run FAILED with exit ${hb.exit_code} (${ageHours.toFixed(1)}h ago)` };
150
+ if (hb.state === 'failed' || hb.state === 'killed' || (typeof hb.exit_code === 'number' && hb.exit_code !== 0)) {
151
+ // job-heartbeat.sh distinguishes "failed" (plain non-zero exit) from "killed" (died to a
152
+ // signal — the wrapper's own trap, or a direct kill of the child; POSIX 128+signal) — surface
153
+ // which one it was rather than collapsing both into one undifferentiated exit code.
154
+ const how = hb.state === 'killed' ? `KILLED by signal ${hb.signal ?? '?'}` : `FAILED with exit ${hb.exit_code}`;
155
+ return { state: FAILING, ageHours, detail: `last run ${how} (${ageHours.toFixed(1)}h ago)` };
115
156
  }
116
157
  if (hb.state === 'running') {
117
158
  return { state: OK, ageHours, detail: `running right now (started ${ageHours.toFixed(1)}h ago)` };
@@ -153,7 +194,7 @@ export function checkAll(now, { registry = REGISTRY, loaded = loadedLabels(), hb
153
194
  const { jobs } = JSON.parse(fs.readFileSync(registry, 'utf8'));
154
195
  return jobs.map((job) => {
155
196
  const hb = readHeartbeat(job.label, hbDir) || readLegacyLog(job, root);
156
- const verdict = judge(job, hb, loaded.has(job.label), now);
197
+ const verdict = judge(job, hb, loaded.has(job.label), now, { root });
157
198
  if (hb?._tier2 && verdict.state === OK) verdict.detail += ' (via its log — no receipt yet; the next run writes one)';
158
199
  return { ...job, ...verdict };
159
200
  });
@@ -162,6 +203,22 @@ export function checkAll(now, { registry = REGISTRY, loaded = loadedLabels(), hb
162
203
  export function productSchedulerVerdict(status) {
163
204
  const base = { label: NIGHTLY_LABEL, what: 'Refreshes the installed Brain corpus and converges every detected host',
164
205
  schedule: 'daily 03:47', maxAgeHours: 30, required: true };
206
+ // A 'degraded' adapter whose only defect is a NON-ZERO LAST EXIT is a job that is loaded, verified
207
+ // and FIRED — the exact opposite of MISSING ("not loaded — it can never fire", top of this file).
208
+ // Its truth is the receipt. Measured 2026-09-12 against two FAILED receipts: this projected as
209
+ // MISSING with the detail "LaunchAgent is loaded and runner digest verified, but last exited 1" —
210
+ // a contradiction in one line, and the receipt's reason never surfaced.
211
+ if (status?.state === 'degraded' && Number.isInteger(status.lastExitCode) && status.lastExitCode !== 0) {
212
+ const run = status.runHealth || null;
213
+ if (run?.state === 'stale') return { ...base, state: STALE, detail: run.evidence };
214
+ if (!run || run.state === 'never-ran') {
215
+ return { ...base, state: FAILING, detail: `runner last exited ${status.lastExitCode} and left no receipt — it fired and died before recording a run` };
216
+ }
217
+ if (run.state === 'ok' || run.state === 'running') {
218
+ return { ...base, state: FAILING, detail: `runner last exited ${status.lastExitCode} although its latest receipt reads ${run.state} — the run after that receipt died without recording one (${run.evidence})` };
219
+ }
220
+ return { ...base, state: FAILING, detail: run.evidence };
221
+ }
165
222
  if (status?.state !== 'on') return { ...base, state: MISSING, detail: status?.evidence || 'scheduler is not registered' };
166
223
  const run = status.runHealth || { state: 'never-ran', evidence: 'No nightly refresh receipt exists yet.' };
167
224
  const mapped = run.state === 'ok' || run.state === 'running' ? OK
@@ -33,6 +33,7 @@ import { planFor } from './remedy-registry.mjs';
33
33
  import { auditAll as capabilityAuditAll } from './capability-registry.mjs';
34
34
  import { getVersion } from './version.mjs';
35
35
  import { consoleRuntimeDigest } from './console-runtime-identity.mjs';
36
+ import { isCorpusReleaseTag } from '../kb/corpus-release-identity.mjs';
36
37
  // L5 (ADR-028): the audit is the one place that observes live capability state, so it is where an
37
38
  // OFFERED-then-now-`on` transition becomes an APPLIED — the numerator of the precision metric that
38
39
  // tells the owner whether advocacy is landing or nagging. Both are pure reads/appends and never throw.
@@ -422,6 +423,27 @@ function wiringSurvey() {
422
423
  if (mech) sites.push({ scope: 'project', project: projName, file: '.mcp.json', event: 'MCP', matcher: name, spec: full.slice(0, 160), mechanism: mech });
423
424
  }
424
425
  }
426
+ // THE MACHINE-WIDE FILE (console audit 2026-09-11). Plugins are switched on in ~/.claude/settings.json
427
+ // and nowhere else, and this survey never read that file — so the card said `plugin: 0` on a machine
428
+ // with 14 enabled, ruvnet-brain among them. Each enabled plugin is one global PLUGIN site. The file's
429
+ // own hooks go through the same classifier the project scan uses, so a machine-wide npx or
430
+ // global-binary hook is counted in the same words as a project one.
431
+ const machine = readJSON(path.join(CONSOLE_ROOT, '.claude', 'settings.json'));
432
+ if (machine) {
433
+ for (const [event, groups] of Object.entries(machine.hooks || {})) {
434
+ const list = Array.isArray(groups) ? groups : [groups];
435
+ for (const g of list) {
436
+ const hookArr = Array.isArray(g?.hooks) ? g.hooks : (g?.command ? [g] : []);
437
+ for (const h of hookArr) {
438
+ const mech = classifyCommand(h?.command);
439
+ if (mech) sites.push({ scope: 'global', file: '~/.claude/settings.json', event, matcher: g?.matcher ?? '*', spec: String(h.command).slice(0, 160), mechanism: mech });
440
+ }
441
+ }
442
+ }
443
+ for (const [name, on] of Object.entries(machine.enabledPlugins || {})) {
444
+ if (on === true) sites.push({ scope: 'global', file: '~/.claude/settings.json', event: 'plugin', matcher: name, spec: name, mechanism: 'PLUGIN' });
445
+ }
446
+ }
425
447
  return { sites, summary: summarizeWiring(sites) };
426
448
  }
427
449
 
@@ -444,25 +466,41 @@ function sessionHookExists() {
444
466
  //
445
467
  // So the probe stops guessing and asks the filesystem: consider both known names, and use whichever
446
468
  // actually holds rows. That is correct on their machine and on mine without knowing why they differ.
469
+ //
470
+ // THE SECOND HALF OF THAT LESSON (console audit 2026-09-11). "Whichever holds rows" was implemented
471
+ // as "whichever is LARGER", and on a machine where BOTH hold rows that picked the wrong one: the
472
+ // card scored ruvnet-brain on `.swarm/agentdb-memory.db` — 15,824 rows, the MCP coordination store
473
+ // the global CLAUDE.md warns is a DIFFERENT container — and rendered "15824 entries, 100% embedded"
474
+ // while the canonical `.swarm/memory.db`, the file `ruflo memory store` writes, held 5. The #127
475
+ // reporter's cases (canonical absent, or present but empty) still resolve to the coordination store;
476
+ // a canonical store that holds rows is now always the one scored, and BOTH files are reported so the
477
+ // page can say which it scored and which it did not.
447
478
  const MEMORY_DB_NAMES = ['memory.db', 'agentdb-memory.db'];
479
+ // Row count that never throws and never hangs on a live store: null means "unreadable this instant".
480
+ function memoryRows(file) {
481
+ if (!fs.existsSync(file)) return null;
482
+ const r = robustRead(file, 'SELECT count(*) FROM memory_entries;');
483
+ return r.ok ? (r.value === null ? 0 : parseInt(r.value, 10)) : null;
484
+ }
485
+ export function memoryStores(projectDir) {
486
+ const [canonicalPath, coordinationPath] = MEMORY_DB_NAMES.map((name) => path.join(projectDir, '.swarm', name));
487
+ const canonical = { path: canonicalPath, exists: fs.existsSync(canonicalPath), rows: memoryRows(canonicalPath) };
488
+ const coordination = { path: coordinationPath, exists: fs.existsSync(coordinationPath), rows: memoryRows(coordinationPath) };
489
+ // A canonical store that holds rows — or is busy being written this instant (rows null) — is the
490
+ // one scored. Only an absent or EMPTY canonical store hands the score to the coordination file.
491
+ const scored = canonical.exists && (canonical.rows === null || canonical.rows > 0) ? canonical.path
492
+ : coordination.exists ? coordination.path
493
+ : canonical.path; // canonical name for the "absent" message
494
+ return { canonical, coordination, scored };
495
+ }
448
496
  export function resolveMemoryDb(projectDir) {
449
- const candidates = MEMORY_DB_NAMES
450
- .map((name) => path.join(projectDir, '.swarm', name))
451
- .filter((file) => fs.existsSync(file));
452
- if (!candidates.length) return path.join(projectDir, '.swarm/memory.db'); // canonical name for the "absent" message
453
- if (candidates.length === 1) return candidates[0];
454
- // Both present: prefer the one with real content. Size is a proxy for rows that costs no query and
455
- // cannot throw on a locked or WAL-mode database — this runs inside a UI probe that must never hang.
456
- return candidates.sort((a, b) => {
457
- const sa = (() => { try { return fs.statSync(a).size; } catch { return 0; } })();
458
- const sb = (() => { try { return fs.statSync(b).size; } catch { return 0; } })();
459
- return sb - sa;
460
- })[0];
497
+ return memoryStores(projectDir).scored;
461
498
  }
462
499
 
463
500
  function probeMemory(projectDir, { now = Date.now() } = {}) {
464
- const db = resolveMemoryDb(projectDir);
465
- const probes = {};
501
+ const stores = memoryStores(projectDir);
502
+ const db = stores.scored;
503
+ const probes = { stores };
466
504
  // compaction survival + session surfacing are filesystem facts, always checkable
467
505
  const snapshot = inspectSessionSnapshots(projectDir, { now });
468
506
  const snapshotDetail = snapshot.kind === 'canonical'
@@ -503,8 +541,10 @@ function probeMemory(projectDir, { now = Date.now() } = {}) {
503
541
  const liveNote = integ.mode === 'immutable=1' ? ' and in active use' : '';
504
542
  if (integrity !== 'ok') probes.liveness = { status: 'fail', detail: `store is corrupt (integrity_check: ${integrity})` };
505
543
  else if (total === null) probes.liveness = { status: 'notTested', detail: 'store opened but counts were unavailable this instant' };
506
- else if (total > 0) probes.liveness = { status: 'ok', detail: `store is live${liveNote}, integrity ok, ${total} entries${embedded != null && total ? `, ${Math.round((embedded / total) * 100)}% embedded` : ''} (read-only)` };
507
- else probes.liveness = { status: 'warn', detail: 'store exists but is empty' };
544
+ // The file is NAMED in the sentence, so "15824 entries" can never again be read as the canonical
545
+ // store's count when it was the coordination store's.
546
+ else if (total > 0) probes.liveness = { status: 'ok', detail: `${path.basename(db)} is live${liveNote}, integrity ok, ${total} entries${embedded != null && total ? `, ${Math.round((embedded / total) * 100)}% embedded` : ''} (read-only)` };
547
+ else probes.liveness = { status: 'warn', detail: `${path.basename(db)} exists but is empty` };
508
548
 
509
549
  const cp = robustRead(db, "SELECT max(updated_at) FROM memory_entries WHERE key LIKE 'project-state-current%';");
510
550
  if (cp.ok && cp.value) {
@@ -542,12 +582,20 @@ function gatherMemory(cwd, { fleet = true } = {}) {
542
582
  const scope = projectDirectory({ cwd });
543
583
  const project = fs.existsSync(path.join(scope, '.swarm/memory.db')) ? scope : REPO;
544
584
  const projName = project.replace(CONSOLE_ROOT + '/Code/', '').replace(CONSOLE_ROOT + '/', '~/');
545
- const health = scoreMemoryHealth({ project: projName, probes: probeMemory(project) });
585
+ const probes = probeMemory(project);
586
+ const health = scoreMemoryHealth({ project: projName, probes });
587
+ // Which file the score is about, and which file it deliberately is not — both with row counts.
588
+ const short = (p) => String(p).replace(CONSOLE_ROOT, '~');
589
+ health.stores = probes.stores ? {
590
+ scored: short(probes.stores.scored),
591
+ canonical: { ...probes.stores.canonical, path: short(probes.stores.canonical.path) },
592
+ coordination: { ...probes.stores.coordination, path: short(probes.stores.coordination.path) },
593
+ } : null;
546
594
  return { fleet: fleet ? scanFleet() : null, health };
547
595
  }
548
596
 
549
597
  // ── Savings ledger (receipts only) ────────────────────────────────────────────────────────────────
550
- function gatherSavings() {
598
+ function gatherSavings({ repo = REPO } = {}) {
551
599
  // Primary source is the real routing-receipts ledger written by scripts/route-cheap.mjs.
552
600
  const files = [
553
601
  path.join(SYSTEM_HOME, '.claude/metaharness/routing-receipts.jsonl'),
@@ -555,7 +603,9 @@ function gatherSavings() {
555
603
  // Canonical user-level ledger (issue #36 — the hooks no longer scatter per-CWD copies).
556
604
  path.join(SYSTEM_HOME, '.cache/ruvnet-brain/token-ledger.jsonl'),
557
605
  // Legacy location, still read so an existing user's history is not orphaned by the move.
558
- path.join(REPO, 'plugin/scripts/.ruvnet-brain/token-ledger.jsonl'),
606
+ // `repo` is injectable because on a developer checkout this file is REAL history (2 rows from
607
+ // 2026-07-14 on the owner's machine) and HOME isolation alone cannot keep it out of a fixture.
608
+ path.join(repo, 'plugin/scripts/.ruvnet-brain/token-ledger.jsonl'),
559
609
  ];
560
610
  const receipts = [];
561
611
  let baselineUsd = 0;
@@ -591,10 +641,16 @@ function gatherSavings() {
591
641
  }
592
642
  }
593
643
  const usdSaved = +receipts.reduce((a, r) => a + (r.measuredUsd || 0), 0).toFixed(4);
644
+ const timed = receipts.filter((r) => r.measuredMs !== null);
594
645
  const totals = receipts.length ? {
595
646
  count: receipts.length,
596
647
  usdSaved,
648
+ // SIGNED, and it stays signed. A net-slower ledger sums negative and is REPORTED negative — the
649
+ // page once turned that into a dash (console audit 2026-09-11: −46,737 ms across 25 timed
650
+ // routes, 23 of them slower than baseline, rendered as "—"). A loss is a finding about the
651
+ // router; `timedCount` says how many routes the number is even about.
597
652
  msSaved: receipts.reduce((a, r) => a + (r.measuredMs || 0), 0),
653
+ timedCount: timed.length,
598
654
  baselineUsd: +baselineUsd.toFixed(4),
599
655
  pctSaved: baselineUsd > 0 ? Math.round((usdSaved / baselineUsd) * 100) : null,
600
656
  } : null;
@@ -643,11 +699,42 @@ function unsupportedConfigControls() {
643
699
  * off, and the Settings form shows the shipped default beside it as a recommendation rather than as
644
700
  * a fact about their machine.
645
701
  */
702
+ /**
703
+ * NIGHTLY REFRESH IS THREE FACTS, NOT ONE BOOLEAN (console audit 2026-09-11). The toggle rendered
704
+ * `nightly: true` because the LaunchAgent was loaded, while ~/.claude/ruvnet-brain/config.json said
705
+ * `nightly: false` and the job had never completed a run. Three independently measured things —
706
+ * the CHOICE the user saved (config.json), the ENFORCEMENT the scheduler adapter reports (loaded,
707
+ * off, degraded), and the OUTCOME of the last completed run (its receipt) — had been collapsed into
708
+ * the one the user did not set. A single boolean is emitted only when choice and enforcement agree;
709
+ * otherwise the field is null (never chosen, or in disagreement) and `facts` carries all three so
710
+ * the page can print them side by side. Pure, so the rule is testable without a scheduler.
711
+ */
712
+ export function reconcileNightly({ choice, schedule }) {
713
+ const enforcement = schedule?.state ?? 'unknown';
714
+ const run = schedule?.runHealth ?? null;
715
+ const enforced = enforcement === 'on' ? true : enforcement === 'off' ? false : null;
716
+ const chosen = choice === true || choice === false ? choice : null;
717
+ const agree = chosen === null ? null : (enforced !== null && chosen === enforced);
718
+ return {
719
+ value: agree === true ? chosen : null,
720
+ facts: {
721
+ choice: chosen,
722
+ enforcement,
723
+ enforcementEvidence: schedule?.evidence ?? null,
724
+ lastRun: run?.state ?? 'unknown',
725
+ lastRunEvidence: run?.evidence ?? null,
726
+ lastRunAt: run?.receipt?.finishedAt ?? run?.receipt?.startedAt ?? null,
727
+ agree,
728
+ },
729
+ };
730
+ }
731
+
646
732
  function gatherConfig() {
647
733
  const cfg = readJSON(CONFIG_PATH) || {};
648
734
  const credential = openRouterCredentialStatus({ cwd: process.cwd() });
649
735
  const schedule = nightlyStatus();
650
736
  const bool = (v) => (v === true ? true : v === false ? false : null);
737
+ const nightly = reconcileNightly({ choice: bool(cfg.nightly), schedule });
651
738
  const unavailable = unsupportedConfigControls();
652
739
  if (!schedule.artifact.supported) {
653
740
  unavailable.push({
@@ -662,7 +749,7 @@ function gatherConfig() {
662
749
  values: {
663
750
  openrouterKey: credential.configured, // boolean only — never the secret itself
664
751
  provider: typeof cfg.provider === 'string' && cfg.provider ? cfg.provider : null,
665
- nightly: schedule.state === 'on' ? true : schedule.state === 'off' ? false : null,
752
+ nightly: nightly.value,
666
753
  routing: cfg.routing === 'off' ? 'off' : cfg.routing === 'auto' ? 'auto' : null,
667
754
  qeFleet: bool(cfg.qeFleet),
668
755
  },
@@ -675,7 +762,7 @@ function gatherConfig() {
675
762
  unavailable,
676
763
  runtime: {
677
764
  openrouterKey: credential,
678
- nightly: schedule,
765
+ nightly: { ...schedule, facts: nightly.facts },
679
766
  },
680
767
  };
681
768
  }
@@ -791,8 +878,21 @@ function gatherBrainProfile() {
791
878
  : installed.stores.length === 1
792
879
  ? PROFILE_RUVECTOR
793
880
  : PROFILE_COMPLETE;
881
+ // THE "— stores · 0 MB" LIE (console audit 2026-09-11). `choices.complete` was rendered from
882
+ // `source` — the restore BUNDLE at COMPLETE_BRAIN_SOURCE, which on an installed host resolves to
883
+ // <runtime>/dist/ruvnet-brain and never exists after install — while `installed`, measured three
884
+ // lines up (184 stores, 979 MB on the owner's machine), went unused. discoverStoreFamilies()
885
+ // swallows ENOENT into [], so a complete brain rendered as zero and wore an `active` badge over it.
886
+ // Two different questions are now answered separately: what is INSTALLED here, and by which path
887
+ // Apply→complete could RESTORE it here. The path order mirrors saveBrainProfile()'s own branches
888
+ // (local bundle, then the signed forge-update download, then nothing), so the card describes the
889
+ // exact mechanism Apply would take — never a hopeful one, never a number nobody measured.
794
890
  const source = measureBrainProfile(COMPLETE_BRAIN_SOURCE);
891
+ const bundlePresent = source.stores.includes(PROFILE_RUVECTOR) && source.storeCount > 1;
795
892
  const updaterAvailable = fs.existsSync(path.join(INSTALLED_KB, 'forge-update.mjs'));
893
+ const restoreVia = bundlePresent ? 'local-bundle' : updaterAvailable ? 'signed-download' : null;
894
+ const completeInstalled = actual === PROFILE_COMPLETE;
895
+ const restorePath = COMPLETE_BRAIN_SOURCE.replace(CONSOLE_ROOT, '~');
796
896
  return {
797
897
  path: INSTALLED_KB.replace(CONSOLE_ROOT, '~'),
798
898
  values: { brainProfile: actual },
@@ -803,10 +903,20 @@ function gatherBrainProfile() {
803
903
  installed,
804
904
  choices: {
805
905
  complete: {
806
- available: (source.stores.includes(PROFILE_RUVECTOR) && source.storeCount > 1)
807
- || updaterAvailable,
808
- storeCount: source.storeCount,
809
- bytes: source.bytes,
906
+ available: restoreVia !== null,
907
+ // From the INSTALLED brain when complete is what is installed; from the local bundle when one
908
+ // exists to restore from; otherwise null — never a 0 that nothing measured.
909
+ storeCount: completeInstalled ? installed.storeCount : bundlePresent ? source.storeCount : null,
910
+ bytes: completeInstalled ? installed.bytes : bundlePresent ? source.bytes : null,
911
+ installed: completeInstalled ? { storeCount: installed.storeCount, bytes: installed.bytes } : null,
912
+ restoreBundle: {
913
+ present: bundlePresent,
914
+ path: restorePath,
915
+ storeCount: bundlePresent ? source.storeCount : null,
916
+ bytes: bundlePresent ? source.bytes : null,
917
+ },
918
+ updaterAvailable,
919
+ restoreVia,
810
920
  },
811
921
  ruvector: {
812
922
  available: installed.stores.includes(PROFILE_RUVECTOR)
@@ -1960,6 +2070,34 @@ function readInstallChannel() {
1960
2070
  * practice, because the check that did exist only ever triggered a blocking recompute rather than an
1961
2071
  * honest stale-serve.
1962
2072
  */
2073
+ /**
2074
+ * THREE VERSIONS ON ONE PAGE (console audit 2026-09-11): the header chip said v4.3.21 (running
2075
+ * brain), the Install-channel row said v4.3.22 (plugin cache), the installed KB's RVF-GENERATIONS.json
2076
+ * said 4.3.10 — and nothing said they differed. Each is read from its own file and LABELED. `agree`
2077
+ * is true only when every measured one is identical, null when fewer than two could be read. A
2078
+ * version that cannot be read is null, never guessed.
2079
+ */
2080
+ export function versionFacts({ release = null } = {}) {
2081
+ const gen = readJSON(path.join(INSTALLED_KB, 'RVF-GENERATIONS.json'));
2082
+ const kbGeneration = typeof gen?.brainVersion === 'string' && gen.brainVersion ? gen.brainVersion.replace(/^v/, '') : null;
2083
+ const runningBrain = brainVersionOnDisk();
2084
+ let installedPlugin = null;
2085
+ try { installedPlugin = readInstallChannel().version || null; } catch { installedPlugin = null; }
2086
+ // A CORPUS RELEASE IS NOT A VERSION (ADR-086 step 16). `releases/latest` can now carry a
2087
+ // `corpus-sha256-<64 hex>` content address, and stripping a leading "v" off one yields a 78-char
2088
+ // string that this panel would have printed in the "latest release" slot beside `4.3.22`-shaped
2089
+ // numbers — in the one panel whose whole job is to make version disagreement visible. That is not
2090
+ // a version disagreement; it is a category error, and showing it as one would be the panel lying.
2091
+ // So the two identities are reported in two fields, each labeled for what it actually is.
2092
+ const rawTag = typeof release?.tag === 'string' && release.tag ? release.tag : null;
2093
+ const corpusTag = isCorpusReleaseTag(rawTag) ? rawTag : null;
2094
+ const latestRelease = rawTag && !corpusTag ? rawTag.replace(/^v/, '') : null;
2095
+ const known = [kbGeneration, runningBrain, installedPlugin].filter(Boolean);
2096
+ const agree = known.length < 2 ? null : known.every((x) => x === known[0]);
2097
+ return { kbGeneration, runningBrain, installedPlugin, latestRelease, latestCorpusRelease: corpusTag,
2098
+ agree, known: known.length };
2099
+ }
2100
+
1963
2101
  async function gatherTrust() {
1964
2102
  // COLD ONLY: no successful release read has ever landed, so there is nothing to withhold or serve
1965
2103
  // stale — the one exception serveCached() itself carves out for its own caches.
@@ -1971,7 +2109,7 @@ async function gatherTrust() {
1971
2109
  const generatedAt = new Date().toISOString();
1972
2110
  const data = { generatedAt, release };
1973
2111
  if (release.ok) { TRUST_CACHE = { at: Date.parse(generatedAt), data }; saveConsoleCache(); }
1974
- return { ...data, channel: readInstallChannel(), sbom: readSbom(), ...freshnessOf(generatedAt) };
2112
+ return { ...data, channel: readInstallChannel(), sbom: readSbom(), versions: versionFacts({ release }), ...freshnessOf(generatedAt) };
1975
2113
  }
1976
2114
 
1977
2115
  // WARM — including over-ceiling. Never await the network here; hand back what we measured, say
@@ -1980,7 +2118,7 @@ async function gatherTrust() {
1980
2118
  if (fresh.stale) kickTrustRefresh();
1981
2119
  // Disk facts stay live even when the release read is served from cache — the SBOM file and install
1982
2120
  // channel can change (a fresh `npm run sbom`, a plugin update) between two calls inside the ceiling.
1983
- return { ...TRUST_CACHE.data, channel: readInstallChannel(), sbom: readSbom(), ...fresh };
2121
+ return { ...TRUST_CACHE.data, channel: readInstallChannel(), sbom: readSbom(), versions: versionFacts({ release: TRUST_CACHE.data.release }), ...fresh };
1984
2122
  }
1985
2123
 
1986
2124
  // ── Assemble the read-models ─────────────────────────────────────────────────────────────────────
@@ -2864,6 +3002,10 @@ function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = f
2864
3002
  if (req.method === 'GET' && url === '/api/lessons') return sendJSON(res, 200, gatherLessons());
2865
3003
  if (req.method === 'GET' && url === '/api/trust') return sendJSON(res, 200, await gatherTrust());
2866
3004
  if (req.method === 'GET' && url === '/tips') { req.url = '/tips.html'; return serveStatic(req, res); }
3005
+ // What's in the brain: derived from the installed root's own receipts on every request (read-only,
3006
+ // no network, ~ms) — a coverage listing served from a stale cache would defeat its one purpose.
3007
+ if (req.method === 'GET' && url === '/api/scope') return sendJSON(res, 200, gatherScope());
3008
+ if (req.method === 'GET' && url === '/scope') { req.url = '/scope.html'; return serveStatic(req, res); }
2867
3009
  if (req.method === 'POST') {
2868
3010
  const body = await readBody(req);
2869
3011
  if (body.token !== TOKEN) return sendJSON(res, 403, { error: 'bad or missing token' });
@@ -3046,7 +3188,155 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('onboarding-consol
3046
3188
  else { console.log(`\n onboarding-console — the RuvNet Brain configure page\n\n --serve [--open] start or safely replace the scoped local server\n --runtime-status print candidate, receipt, and live runtime status\n --print-state print the read-only state JSON and exit (for tests)\n --print-stack print the stack audit JSON and exit\n`); }
3047
3189
  }
3048
3190
 
3191
+ // ── "What's in the brain" (the scope page) ─────────────────────────────────────────────────────────
3192
+ // Every verdict comes from the installed brain's OWN receipts, never from the shipped `status` word.
3193
+ // Measured 2026-09-11 (KB audit): release-projection.mjs:80 stamps every seeded row CURRENT — 63 real
3194
+ // rows had sourceCommit ≠ upstream.sha. GitHub `updatedAt` is metadata (222/227 repos moved on it in
3195
+ // one week) and `pushedAt` leads the default-branch HEAD on 39/227; only `committedAt` dates the commit
3196
+ // the brain ingests. Gists have no branches, so their `updatedAt` IS the content date — and the shipped
3197
+ // COVERAGE row carries no sourceCommit for them (479/479 null); the gist receipt set
3198
+ // (ruv-gists.sources.json → versionSha) is the brain's record of which revision it holds.
3199
+ //
3200
+ // The truth per row is `brainSha === upstreamSha`. A store with no recorded source commit is
3201
+ // "unverified" — never promoted to current on the strength of a date or a label. A store whose
3202
+ // .big.rvf is not at the installed root is "not in the brain", whatever the record says.
3203
+ const SCOPE_SENTENCE = "If rUv's last change to a repo is on or before the date the brain read it, everything in that repo is in the brain. If it's after, the brain is behind on that repo by the difference.";
3204
+ const SCOPE_DAY_MS = 86_400_000;
3205
+ const SCOPE_STALE_DAYS = 7;
3206
+ // A row's `desc` is what the thing DOES, so the page's search answers "is the stuff on Federation in
3207
+ // Ruflo?" — a description question — not only name lookups. Bounded so a README pasted into a card
3208
+ // cannot bloat 700 rows.
3209
+ const SCOPE_DESC_MAX = 200;
3210
+ const scopeBounded = (s) => (s.length > SCOPE_DESC_MAX ? `${s.slice(0, SCOPE_DESC_MAX - 1)}…` : s);
3211
+
3212
+ /**
3213
+ * Pure: the installed brain's capability-cards.md → Map(lowercased repo/store name → first line of
3214
+ * its card, bounded). The card file is the ONLY per-repo description that ships to a customer
3215
+ * machine (data/ruvnet-registry.json is repo-side and never installs — measured 2026-09-12). Format,
3216
+ * measured on 182 sections: `## <name>`, one line saying what it does, one auto-derivation note.
3217
+ */
3218
+ function parseCapabilityCards(text) {
3219
+ const cards = new Map();
3220
+ if (typeof text !== 'string') return cards;
3221
+ const lines = text.split('\n');
3222
+ for (let i = 0; i < lines.length; i++) {
3223
+ const h = /^##\s+(\S.*?)\s*$/.exec(lines[i]);
3224
+ if (!h) continue;
3225
+ let j = i + 1;
3226
+ while (j < lines.length && !lines[j].trim()) j += 1;
3227
+ const first = j < lines.length && !lines[j].startsWith('#') ? lines[j].trim() : '';
3228
+ if (first) cards.set(h[1].toLowerCase(), scopeBounded(first));
3229
+ }
3230
+ return cards;
3231
+ }
3232
+
3233
+ function scopeDesc(row, { isGist, store, gistId, gistSources, cards }) {
3234
+ if (isGist) {
3235
+ // A gist's only words are its file names (COVERAGE upstream.files; the receipt's files as fallback).
3236
+ const files = row.upstream?.files ?? gistSources?.gists?.[gistId]?.files ?? null;
3237
+ const names = Array.isArray(files) ? files.map((f) => (typeof f === 'string' ? f : f?.filename)).filter(Boolean) : [];
3238
+ return names.length ? scopeBounded(names.join(' · ')) : null;
3239
+ }
3240
+ const byName = cards?.get(String(row.name ?? '').toLowerCase());
3241
+ if (byName) return byName;
3242
+ return (store && cards?.get(String(store).toLowerCase())) || null;
3243
+ }
3244
+
3245
+ function scopeRow(row, { generations, gistSources, installedStores, cards }) {
3246
+ const isGist = row.kind === 'gist';
3247
+ const store = row.artifact?.store ?? null;
3248
+ const ledger = store ? (generations?.stores?.[store] ?? null) : null;
3249
+ const gistId = isGist ? String(row.key ?? '').replace(/^gist:/, '') : null;
3250
+ const upstreamSha = row.upstream?.sha ?? null;
3251
+ const desc = scopeDesc(row, { isGist, store, gistId, gistSources, cards });
3252
+
3253
+ let brainSha = row.artifact?.sourceCommit ?? null;
3254
+ if (brainSha == null) brainSha = isGist ? (gistSources?.gists?.[gistId]?.versionSha ?? null) : (ledger?.sourceCommit ?? null);
3255
+
3256
+ const ruvChangedAt = (isGist ? row.upstream?.updatedAt : row.upstream?.committedAt) ?? null;
3257
+ const brainReadAt = row.artifact?.ingestedAt ?? ledger?.builtUtc ?? null;
3258
+
3259
+ let installed = Boolean(store) && installedStores.has(store);
3260
+ // With a gist receipt set present, a gist the set does not enumerate is not in the store.
3261
+ if (installed && isGist && gistSources?.gists && !(gistId in gistSources.gists)) installed = false;
3262
+
3263
+ let bucket;
3264
+ if (!installed) bucket = 'not-in-brain';
3265
+ else if (brainSha && upstreamSha) bucket = brainSha === upstreamSha ? 'current' : 'behind';
3266
+ else bucket = 'unverified';
3267
+
3268
+ let behindDays = null;
3269
+ if (bucket === 'behind' && ruvChangedAt && brainReadAt) {
3270
+ const d = (Date.parse(ruvChangedAt) - Date.parse(brainReadAt)) / SCOPE_DAY_MS;
3271
+ behindDays = Number.isFinite(d) ? Math.max(0, Math.round(d)) : null;
3272
+ }
3273
+ return { kind: row.kind, name: row.name, url: row.url ?? null, store, desc, bucket, ruvChangedAt, brainReadAt, behindDays, brainSha, upstreamSha };
3274
+ }
3275
+
3276
+ const scopeNewestFirst = (a, b) => {
3277
+ if (a.ruvChangedAt == null && b.ruvChangedAt == null) return String(a.name).localeCompare(String(b.name));
3278
+ if (a.ruvChangedAt == null) return 1;
3279
+ if (b.ruvChangedAt == null) return -1;
3280
+ return a.ruvChangedAt < b.ruvChangedAt ? 1 : a.ruvChangedAt > b.ruvChangedAt ? -1 : String(a.name).localeCompare(String(b.name));
3281
+ };
3282
+
3283
+ /** Pure: coverage + ledger + gist receipts + the set of installed store names → the page's payload. */
3284
+ function computeScope({ coverage, generations, gistSources, installedStores, root, cards = new Map(), now = Date.now() }) {
3285
+ const empty = () => ({ total: 0, current: 0, behind: 0, unverified: 0, notInBrain: 0, ineligible: 0 });
3286
+ const counts = { repos: empty(), gists: empty() };
3287
+ const out = { repos: [], gists: [] };
3288
+ const covered = new Set();
3289
+ for (const row of coverage.rows ?? []) {
3290
+ const kind = row.kind === 'gist' ? 'gists' : 'repos';
3291
+ if (row.artifact?.store) covered.add(row.artifact.store);
3292
+ counts[kind].total += 1;
3293
+ if (row.disposition !== 'eligible') { counts[kind].ineligible += 1; continue; }
3294
+ const r = scopeRow(row, { generations, gistSources, installedStores, cards });
3295
+ counts[kind][r.bucket === 'not-in-brain' ? 'notInBrain' : r.bucket] += 1;
3296
+ out[kind].push(r);
3297
+ }
3298
+ out.repos.sort(scopeNewestFirst);
3299
+ out.gists.sort(scopeNewestFirst);
3300
+ const observedAt = typeof coverage.observedAt === 'string' ? coverage.observedAt : null;
3301
+ const ageMs = observedAt ? now - Date.parse(observedAt) : NaN;
3302
+ const ageDays = Number.isFinite(ageMs) ? Math.round((ageMs / SCOPE_DAY_MS) * 10) / 10 : null;
3303
+ return {
3304
+ available: true,
3305
+ root,
3306
+ observedAt,
3307
+ ageDays,
3308
+ stale: ageDays == null || ageDays > SCOPE_STALE_DAYS,
3309
+ brainVersion: generations?.brainVersion ?? null,
3310
+ releaseTag: generations?.releaseTag ?? null,
3311
+ sentence: SCOPE_SENTENCE,
3312
+ counts,
3313
+ repos: out.repos,
3314
+ gists: out.gists,
3315
+ installedStoreCount: installedStores.size,
3316
+ installedOutsideCoverage: [...installedStores].filter((s) => !covered.has(s)).sort(),
3317
+ };
3318
+ }
3319
+
3320
+ /** Read the installed root and compute. Read-only; no network; returns `available: false` rather than a guess. */
3321
+ function gatherScope() {
3322
+ const readJson = (rel) => { try { return JSON.parse(fs.readFileSync(path.join(INSTALLED_KB, rel), 'utf8')); } catch { return null; } };
3323
+ const coverage = readJson('COVERAGE.json');
3324
+ if (!coverage || !Array.isArray(coverage.rows)) {
3325
+ return { available: false, reason: `no readable COVERAGE.json at ${INSTALLED_KB}`, root: INSTALLED_KB, sentence: SCOPE_SENTENCE, repos: [], gists: [], counts: {}, installedOutsideCoverage: [], installedStoreCount: 0 };
3326
+ }
3327
+ let installedStores = new Set();
3328
+ try { installedStores = new Set(fs.readdirSync(INSTALLED_KB).filter((f) => f.endsWith('.big.rvf')).map((f) => f.slice(0, -'.big.rvf'.length))); }
3329
+ catch { /* unreadable root reads as nothing installed — every row then says "not in the brain" */ }
3330
+ let cardsText = null;
3331
+ try { cardsText = fs.readFileSync(path.join(INSTALLED_KB, 'capability-cards.md'), 'utf8'); } catch { /* no cards → every desc is null, never invented */ }
3332
+ return computeScope({ coverage, generations: readJson('RVF-GENERATIONS.json'), gistSources: readJson('ruv-gists.sources.json'),
3333
+ installedStores, root: INSTALLED_KB, cards: parseCapabilityCards(cardsText) });
3334
+ }
3335
+
3049
3336
  export {
3337
+ gatherScope,
3338
+ computeScope,
3339
+ parseCapabilityCards,
3050
3340
  gatherState,
3051
3341
  gatherStack,
3052
3342
  gatherTrust,
@@ -3063,6 +3353,8 @@ export {
3063
3353
  saveBrainProfile,
3064
3354
  gatherRouterEngine,
3065
3355
  autoEligibleIds,
3356
+ gatherConfig,
3357
+ gatherSavings,
3066
3358
  };
3067
3359
  // Exported for the cross-project cache-isolation test (console-cache-scope.test.mjs). serveCached's
3068
3360
  // scopeKey is the guard that stops one project's cached state being served for another.