ruvnet-brain 3.9.134-dev → 4.0.2

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 (218) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +5 -5
  3. package/bin/install.mjs +382 -36
  4. package/console/CONTRACT.md +172 -0
  5. package/console/activity.js +753 -0
  6. package/console/app.js +4189 -0
  7. package/console/architecture.html +1221 -0
  8. package/console/assets/depth-1.webp +0 -0
  9. package/console/assets/depth-2.webp +0 -0
  10. package/console/assets/depth-3.webp +0 -0
  11. package/console/assets/harness-vs-plain.svg +259 -0
  12. package/console/assets/hero.webp +0 -0
  13. package/console/assets/memory.webp +0 -0
  14. package/console/assets/metaharness.svg +247 -0
  15. package/console/index.html +777 -0
  16. package/console/install-architecture.html +162 -0
  17. package/console/install-mockup.html +543 -0
  18. package/console/style.css +2144 -0
  19. package/console/tips.css +926 -0
  20. package/console/tips.html +858 -0
  21. package/console/tips.js +128 -0
  22. package/docs/RELEASE-NOTES-4.0.md +88 -0
  23. package/kb/model-requirements.mjs +37 -6
  24. package/kb/zip-extract.mjs +53 -14
  25. package/keys/ruvnet-brain-signing.pub.pem +3 -0
  26. package/package.json +14 -22
  27. package/plugin/.claude-plugin/marketplace.json +14 -0
  28. package/plugin/.claude-plugin/plugin.json +22 -0
  29. package/plugin/.codex-plugin/plugin.json +21 -0
  30. package/plugin/.mcp.json +8 -0
  31. package/plugin/commands/brain-console.md +16 -0
  32. package/plugin/commands/configure.md +33 -0
  33. package/plugin/commands/rvbc.md +79 -0
  34. package/plugin/commands/rvcb.md +16 -0
  35. package/plugin/commands/whats-new.md +57 -0
  36. package/plugin/hooks/codex-hooks.json +160 -0
  37. package/plugin/hooks/hook-contracts.json +77 -0
  38. package/plugin/hooks/hooks.json +202 -0
  39. package/plugin/mcp/managed-cli-interface.mjs +47 -4
  40. package/plugin/mcp/server.mjs +56 -6
  41. package/plugin/scripts/anticipate.sh +534 -0
  42. package/plugin/scripts/codex-hook-adapter.mjs +96 -0
  43. package/plugin/scripts/continuation-gate.mjs +267 -0
  44. package/plugin/scripts/design-wall.sh +137 -0
  45. package/plugin/scripts/detach.mjs +182 -0
  46. package/plugin/scripts/first-session-worker.mjs +38 -0
  47. package/plugin/scripts/gate-receipt.sh +35 -0
  48. package/plugin/scripts/ground-before-write.sh +199 -0
  49. package/plugin/scripts/ground-ruvnet.sh +517 -0
  50. package/plugin/scripts/grounding-stamp.sh +113 -0
  51. package/plugin/scripts/grounding-substance.mjs +595 -0
  52. package/plugin/scripts/hijack-ruvnet.sh +81 -0
  53. package/plugin/scripts/hook-input.mjs +558 -0
  54. package/plugin/scripts/hook-shim-bash.mjs +55 -0
  55. package/plugin/scripts/hook-shim.mjs +303 -0
  56. package/plugin/scripts/host-update.mjs +58 -0
  57. package/plugin/scripts/kling-preflight.sh +146 -0
  58. package/plugin/scripts/learn-capture.sh +173 -0
  59. package/plugin/scripts/learn-flush.mjs +155 -0
  60. package/plugin/scripts/lesson-hooks.sh +213 -0
  61. package/plugin/scripts/md-stamp.mjs +219 -0
  62. package/plugin/scripts/protect-brain-state.sh +84 -0
  63. package/plugin/scripts/route-dispatch.sh +147 -0
  64. package/plugin/scripts/routing-outcome-capture.mjs +89 -0
  65. package/plugin/scripts/runtime-preferences.mjs +269 -0
  66. package/plugin/scripts/session-start-core.mjs +477 -0
  67. package/plugin/scripts/session-start.sh +13 -0
  68. package/plugin/scripts/signal-watch.mjs +193 -0
  69. package/plugin/scripts/unprompted-runtime.mjs +377 -0
  70. package/plugin/scripts/update-apply.mjs +419 -0
  71. package/plugin/scripts/verify-interface.sh +53 -0
  72. package/plugin/scripts/version-bump-gate.sh +112 -0
  73. package/plugin/skills/brain-build/SKILL.md +123 -0
  74. package/plugin/skills/brain-console/SKILL.md +22 -0
  75. package/plugin/skills/brain-prompt/SKILL.md +83 -0
  76. package/plugin/skills/brain-score/SKILL.md +101 -0
  77. package/plugin/skills/release-proof/SKILL.md +81 -0
  78. package/plugin/skills/release-proof/agents/openai.yaml +4 -0
  79. package/plugin/skills/release-proof/references/receipt-contract.md +38 -0
  80. package/plugin/skills/release-proof/scripts/release-proof.mjs +210 -0
  81. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +121 -0
  82. package/plugin/skills/ruvnet-brain/SKILL.md +234 -0
  83. package/plugin/skills/rvbc/SKILL.md +23 -0
  84. package/plugin/skills/savings/SKILL.md +46 -0
  85. package/plugin/skills/whats-new/SKILL.md +22 -0
  86. package/scripts/adr-backfill.mjs +107 -0
  87. package/scripts/advocacy-outcomes.mjs +808 -0
  88. package/scripts/agentdb-context.mjs +216 -0
  89. package/scripts/agentdb-fleet-doctor.mjs +101 -0
  90. package/scripts/ascii-drift.mjs +236 -0
  91. package/scripts/behavioral-l1-l4.mjs +210 -0
  92. package/scripts/brain-capability-check.mjs +72 -0
  93. package/scripts/brain-grade-groundtruth.mjs +100 -0
  94. package/scripts/brain-latency-50.mjs +227 -0
  95. package/scripts/brain-novice-50.mjs +189 -0
  96. package/scripts/brain-stamp.mjs +94 -0
  97. package/scripts/brain-state.mjs +212 -0
  98. package/scripts/build-bundle.mjs +522 -0
  99. package/scripts/build-concepts.mjs +132 -0
  100. package/scripts/build-l2.mjs +71 -0
  101. package/scripts/build-primer.mjs +73 -0
  102. package/scripts/build-symbols.mjs +68 -0
  103. package/scripts/calibrate-router.mjs +97 -0
  104. package/scripts/capability-audit.mjs +321 -0
  105. package/scripts/capability-registry.mjs +876 -0
  106. package/scripts/check-indexation.mjs +108 -0
  107. package/scripts/check-legibility.mjs +189 -0
  108. package/scripts/ci/build-fixture-kb.mjs +67 -0
  109. package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
  110. package/scripts/ci/learning-replay-recorder.mjs +59 -0
  111. package/scripts/ci/mutate-hook-timeout.mjs +70 -0
  112. package/scripts/ci/stranger-fixture-stage.mjs +17 -0
  113. package/scripts/ci/stranger-scenario.mjs +228 -0
  114. package/scripts/ci/stranger-timeout.mjs +25 -0
  115. package/scripts/ci-verdict.mjs +29 -0
  116. package/scripts/claims-verify.mjs +710 -0
  117. package/scripts/clear-claude-tmp.sh +31 -0
  118. package/scripts/console-engine.mjs +434 -0
  119. package/scripts/console-engine.test.mjs +125 -0
  120. package/scripts/corpus-qa.mjs +250 -0
  121. package/scripts/correction-detect-embed.mjs +346 -0
  122. package/scripts/correction-detect-measure.mjs +270 -0
  123. package/scripts/correction-detect.mjs +686 -0
  124. package/scripts/count-chunks.mjs +54 -0
  125. package/scripts/described-questions.json +30 -0
  126. package/scripts/design-grade.mjs +58 -0
  127. package/scripts/dev-plugin-link.sh +105 -0
  128. package/scripts/distill-project.mjs +200 -0
  129. package/scripts/doc-currency.mjs +801 -0
  130. package/scripts/eval-brain.mjs +244 -0
  131. package/scripts/fix-metaharness-memretrieve.mjs +121 -0
  132. package/scripts/full-hints.mjs +87 -0
  133. package/scripts/gate.sh +39 -0
  134. package/scripts/gates.mjs +146 -0
  135. package/scripts/gen-console-images.mjs +54 -0
  136. package/scripts/gen-images.mjs +47 -0
  137. package/scripts/git-clone-refresh.mjs +52 -0
  138. package/scripts/git-hooks/pre-push +126 -0
  139. package/scripts/goal-match.mjs +398 -0
  140. package/scripts/goldie-research.mjs +223 -0
  141. package/scripts/goldie-weekly.sh +67 -0
  142. package/scripts/health-repair.mjs +250 -0
  143. package/scripts/helix-scenario-questions.json +10 -0
  144. package/scripts/ingest-gists.mjs +230 -0
  145. package/scripts/ingest-meeting.mjs +115 -0
  146. package/scripts/ingest-repo.mjs +79 -0
  147. package/scripts/install-npx-witness.sh +49 -0
  148. package/scripts/issue-fix.mjs +639 -0
  149. package/scripts/issue-watch.mjs +276 -0
  150. package/scripts/issue4-close-note.md +31 -0
  151. package/scripts/key-canary.mjs +91 -0
  152. package/scripts/latency-to-surface.mjs +233 -0
  153. package/scripts/learning-enable.mjs +380 -0
  154. package/scripts/learning-replay.mjs +1570 -0
  155. package/scripts/learnings.mjs +62 -0
  156. package/scripts/lesson-gate.mjs +680 -0
  157. package/scripts/lesson-lifecycle.mjs +449 -0
  158. package/scripts/lesson-promote.mjs +262 -0
  159. package/scripts/lesson-ratify.mjs +98 -0
  160. package/scripts/lesson-seed.mjs +252 -0
  161. package/scripts/lesson-store.mjs +447 -0
  162. package/scripts/loop-checkpoint.mjs +86 -0
  163. package/scripts/memdb-health.sh +14 -0
  164. package/scripts/memory-doctor.mjs +271 -0
  165. package/scripts/model-catalog.mjs +79 -0
  166. package/scripts/nightly-controller.mjs +66 -0
  167. package/scripts/nightly-gists.sh +72 -0
  168. package/scripts/nightly-wrapper.sh +180 -0
  169. package/scripts/notify.sh +12 -0
  170. package/scripts/npx-witness.sh +56 -0
  171. package/scripts/onboarding-console.mjs +2749 -0
  172. package/scripts/private-fence.mjs +69 -0
  173. package/scripts/proactivity-metrics.mjs +118 -0
  174. package/scripts/proof-questions.json +56 -0
  175. package/scripts/prove.mjs +95 -0
  176. package/scripts/proxy/claude-proxied.sh +57 -0
  177. package/scripts/proxy/proxy-revert.sh +59 -0
  178. package/scripts/proxy/proxy-up.sh +60 -0
  179. package/scripts/proxy/proxy-verify.mjs +142 -0
  180. package/scripts/published-surface-probe.mjs +241 -0
  181. package/scripts/qe/card-lane-gate.mjs +162 -0
  182. package/scripts/qe/session-start-gate.mjs +229 -0
  183. package/scripts/qe/ux-suite.mjs +323 -0
  184. package/scripts/reconcile-project.mjs +0 -0
  185. package/scripts/record-lesson.mjs +113 -0
  186. package/scripts/refresh-model-catalog.mjs +99 -0
  187. package/scripts/release-proof.mjs +9 -0
  188. package/scripts/release-vector.mjs +281 -0
  189. package/scripts/release.mjs +395 -0
  190. package/scripts/remedy-registry.mjs +247 -0
  191. package/scripts/rerank-cap-eval.mjs +265 -0
  192. package/scripts/rerank-cap-warm-ab.mjs +129 -0
  193. package/scripts/route-cheap.mjs +20 -15
  194. package/scripts/router-utilization.mjs +182 -0
  195. package/scripts/routing-flywheel.mjs +596 -0
  196. package/scripts/rvf-generation.mjs +104 -0
  197. package/scripts/rvf-index-audit.mjs +138 -0
  198. package/scripts/self-update.mjs +508 -0
  199. package/scripts/selfcheck.mjs +7 -1
  200. package/scripts/sign-bundle.mjs +69 -0
  201. package/scripts/signal-watch.mjs +171 -0
  202. package/scripts/stack-sync.mjs +469 -0
  203. package/scripts/stamp-existing-rvf-generations.mjs +53 -0
  204. package/scripts/stamp-sweep.mjs +144 -0
  205. package/scripts/status-honesty.mjs +102 -0
  206. package/scripts/sync-version.mjs +217 -0
  207. package/scripts/token-report.mjs +102 -0
  208. package/scripts/top100-benchmark.mjs +479 -0
  209. package/scripts/top100-corpus.mjs +112 -0
  210. package/scripts/top100-semantic-assertions.mjs +449 -0
  211. package/scripts/update-apply.mjs +9 -0
  212. package/scripts/upgrade-notice.mjs +14 -0
  213. package/scripts/verify-bundle.mjs +51 -0
  214. package/scripts/verify-channels.mjs +184 -0
  215. package/scripts/verify-model-catalog.mjs +104 -0
  216. package/scripts/verify-nightly-close-issue4.sh +31 -0
  217. package/scripts/version.mjs +40 -0
  218. package/scripts/wired-check.mjs +864 -0
@@ -0,0 +1,2749 @@
1
+ #!/usr/bin/env node
2
+ // onboarding-console.mjs — the Onboarding Console server (ADR-0013 / DDD-0002).
3
+ //
4
+ // A locally-served page that renders RuvNet Brain's view of YOUR machine from real, measured state,
5
+ // and — only when you explicitly click, and only after telling you in plain words what it does —
6
+ // applies reversible fixes.
7
+ //
8
+ // The design law, encoded here rather than promised:
9
+ // • READ-ONLY BY DEFAULT. Serving the page and building /api/state writes nothing. (The stack
10
+ // audit reaches the npm registry over the network but mutates no user file.) Provable by running
11
+ // against a read-only filesystem: nothing in the render path opens a file for writing.
12
+ // • THE ONLY WRITER is the apply/save path, reached only by an authenticated POST the user triggered.
13
+ // • RE-VERIFY BEFORE WRITE. Apply re-measures the world and refuses any item that is no longer true
14
+ // (already fixed, or the machine moved) — the stale-read-then-write pattern that clobbered a memory
15
+ // checkpoint on 2026-07-12 is structurally avoided.
16
+ // • RECORD THE INVERSE FIRST. The undo is journalled before the mutation runs.
17
+ // • NEVER RE-IMPLEMENT A MUTATION. Every machine change dispatches to a script that already backs up,
18
+ // verifies against disk, and is idempotent (stack-sync.mjs --sync, reconcile-project.mjs --apply).
19
+ // • Bind 127.0.0.1 only; mint a random per-launch token; every mutating POST must echo it (else 403).
20
+
21
+ import http from 'node:http';
22
+ import fs from 'node:fs';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import crypto from 'node:crypto';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { spawnSync, execFileSync, spawn } from 'node:child_process';
28
+
29
+ import { auditModel, installedVersion } from './stack-sync.mjs';
30
+ import { findStores, diagnose } from './memory-doctor.mjs';
31
+ import { buildStackRecommendations, buildWiringRecommendations, summarizeWiring, scoreMemoryHealth, buildHealthRecommendations, buildCapabilityRecommendations } from './console-engine.mjs';
32
+ import { planFor } from './remedy-registry.mjs';
33
+ import { auditAll as capabilityAuditAll } from './capability-registry.mjs';
34
+ import { getVersion } from './version.mjs';
35
+ // L5 (ADR-028): the audit is the one place that observes live capability state, so it is where an
36
+ // OFFERED-then-now-`on` transition becomes an APPLIED — the numerator of the precision metric that
37
+ // tells the owner whether advocacy is landing or nagging. Both are pure reads/appends and never throw.
38
+ import { reconcileApplied, reconcileIgnored, pendingOffers, precision as advocacyPrecision } from './advocacy-outcomes.mjs';
39
+ import { recordObservation as recordCapabilityStates } from './latency-to-surface.mjs';
40
+ import { loadCatalog as engineCatalog, catalogSource as engineCatalogSource, loadProfile as engineProfile, applyProfile, PROFILE_PATH } from './model-router-engine.mjs';
41
+ import { effectivePrices, loadLabelledRows, MIN_LABELS, OUTCOMES } from './metaharness-router.mjs';
42
+ import { utilization } from './router-utilization.mjs';
43
+ import { loadCatalog, detectProvider, frontierFor } from './model-catalog.mjs';
44
+ import { learnings } from './learnings.mjs';
45
+ import { gatesSurvey } from './gates.mjs';
46
+ // The write-safety primitives, borrowed rather than re-implemented. See saveConfig for why.
47
+ import { withLock, writeAtomic, LOCK_WAIT_MS, loadSettings, saveSettings, SETTINGS_SCHEMA as USER_SETTINGS_SCHEMA } from './user-settings.mjs';
48
+ // The brain on/off switch (ADR-054). The sentinel is the enforcement artifact; settings.json holds
49
+ // only a mirror. The console is the ONE surface allowed to flip it — protect-brain-state.sh walls
50
+ // the file off from agent edits — so both halves of the write live here, in saveBrainPower().
51
+ import { isBrainOff, readOffState, setBrainOff, setBrainOn, disagreement } from './brain-state.mjs';
52
+ import {
53
+ PROFILE_COMPLETE,
54
+ PROFILE_RUVECTOR,
55
+ applyBrainProfile,
56
+ discoverStoreFamilies,
57
+ measureBrainProfile,
58
+ restoreCompleteProfile,
59
+ } from '../kb/brain-profile.mjs';
60
+ // Lessons: read model + the two user verbs. Every mutation goes through lesson-store's own
61
+ // updateLessons/ratify/demote/restore — this file adds a SURFACE, never a second writer.
62
+ import { loadLessons, updateLessons, ratify, demote, restore, pending, weightOf, TRIGGERS, ENFORCEMENT, ORIGIN, STATUS } from './lesson-store.mjs';
63
+ import {
64
+ openRouterCredentialStatus,
65
+ saveOpenRouterCredential,
66
+ } from '../plugin/scripts/runtime-preferences.mjs';
67
+ import { applyNightlyChoice, nightlyStatus } from './nightly-controller.mjs';
68
+
69
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
70
+ const REPO = path.dirname(__dirname);
71
+ const CONSOLE_DIR = path.join(REPO, 'console');
72
+ const HOME = os.homedir();
73
+ const NPM_PREFIX = path.join(HOME, '.npm-global');
74
+ const CONFIG_DIR = path.join(HOME, '.claude/ruvnet-brain');
75
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
76
+ const UNDO_JOURNAL = path.join(HOME, '.cache/ruvnet-brain/console-undo.jsonl');
77
+ const INSTALLED_KB = process.env.RUVNET_BRAIN_KB
78
+ || path.join(HOME, '.cache', 'ruvnet-brain', 'kb');
79
+ const COMPLETE_BRAIN_SOURCE = process.env.RUVNET_BRAIN_COMPLETE_SOURCE
80
+ || path.join(REPO, 'dist', 'ruvnet-brain');
81
+ const TOKEN = crypto.randomBytes(24).toString('hex');
82
+
83
+ const NPX_RUV = /npx\s+(?:-y\s+|--yes\s+)?(?:@claude-flow\/[\w-]+|claude-flow|ruflo|ruvector|ruv-swarm|flow-nexus|metaharness|@metaharness\/[\w-]+|agentic-qe|aqe)(?:@[\w.-]+)?/;
84
+
85
+ // ── tiny read helpers (all read-only) ────────────────────────────────────────────────────────────
86
+ const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
87
+ function readJSON(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }
88
+ // Read-only sqlite scalar with a WAL-safe fallback. A database being actively WRITTEN right now — the
89
+ // current project's OWN store, mid-session — can refuse a plain read-only open with SQLITE_CANTOPEN(14)
90
+ // because it cannot set up the -wal/-shm shared memory read-only. That is a sign of a LIVE, in-use
91
+ // store, NOT a broken one (misreading it as "broken" is the exact false-alarm memory-doctor's header
92
+ // warns against). So we retry with immutable=1, which reads the main file directly without WAL/SHM,
93
+ // and only give up if BOTH fail. Never throws, never writes. Returns { ok, value, mode }.
94
+ function robustRead(db, sql) {
95
+ let lastErr = null;
96
+ for (const mode of ['mode=ro', 'immutable=1']) {
97
+ try {
98
+ const uri = `file:${encodeURI(db)}?${mode}`;
99
+ const v = execFileSync('sqlite3', [uri, sql], { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
100
+ return { ok: true, value: v === '' ? null : v, mode };
101
+ } catch (e) { lastErr = e; }
102
+ }
103
+ return { ok: false, value: null, mode: null, err: String(lastErr && lastErr.message || 'unreadable') };
104
+ }
105
+ // Row-returning sibling of robustRead: same WAL-safe two-mode ladder, `sqlite3 -json` output.
106
+ function robustReadJSON(db, sql) {
107
+ for (const mode of ['mode=ro', 'immutable=1']) {
108
+ try {
109
+ const uri = `file:${encodeURI(db)}?${mode}`;
110
+ const v = execFileSync('sqlite3', ['-json', uri, sql], { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
111
+ return { ok: true, rows: v ? JSON.parse(v) : [], mode };
112
+ } catch { /* try next mode */ }
113
+ }
114
+ return { ok: false, rows: [], mode: null };
115
+ }
116
+
117
+ // ── Wiring survey (read-only): how do this machine's projects launch rUv tools? ───────────────────
118
+ // Directories that are somebody else's code sitting on your disk. Their hook wiring is not YOUR
119
+ // wiring: you will never "fix" it, and counting it makes the card describe a machine you don't have.
120
+ // `ruvnet-repos` was the expensive omission — 98 of 768 sites (13% of the card) came from clones of
121
+ // rUv's OWN repos, including a directory literally named tests/init-test, and 18 of the 21 npx call
122
+ // sites the card warned about were his test fixtures rather than anything Stuart configured.
123
+ const VENDOR = ['/clones/', '/node_modules/', '/vendor/', '/upstream/', '.claude-backup', '_snapshots',
124
+ '/ruvnet-repos/', '/ruvnet_repos/'];
125
+
126
+ // ── Candidate scan roots (issue #19) ────────────────────────────────────────────────────────────
127
+ // A single hardcoded `~/Code` silently reports "0" on any machine that keeps projects somewhere
128
+ // else (a reporter's `~/source`, `~/dev`, `~/work`, …) — a confident zero that just means "didn't
129
+ // look in the right place". Scan every root that actually exists on THIS machine, plus a user
130
+ // override in config.json (`scanRoots`, absolute paths or relative to $HOME) when present.
131
+ const DEFAULT_SCAN_ROOTS = ['Code', 'code', 'src', 'source', 'projects', 'dev', 'work'];
132
+ function candidateRoots() {
133
+ const cfg = readJSON(CONFIG_PATH) || {};
134
+ const configured = Array.isArray(cfg.scanRoots) && cfg.scanRoots.length > 0
135
+ ? cfg.scanRoots.map((r) => (path.isAbsolute(r) ? r : path.join(HOME, r)))
136
+ : DEFAULT_SCAN_ROOTS.map((d) => path.join(HOME, d));
137
+ const seen = new Set();
138
+ const roots = [];
139
+ for (const r of configured) {
140
+ const resolved = path.resolve(r);
141
+ if (seen.has(resolved)) continue;
142
+ seen.add(resolved);
143
+ try { if (fs.statSync(resolved).isDirectory()) roots.push(resolved); } catch { /* doesn't exist on this machine — skip silently */ }
144
+ }
145
+ return roots;
146
+ }
147
+ function findProjects(root) {
148
+ const out = new Set();
149
+ const walk = (dir, depth) => {
150
+ if (depth > 4) return;
151
+ let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
152
+ for (const e of ents) {
153
+ const p = path.join(dir, e.name);
154
+ if (VENDOR.some((m) => (p + '/').includes(m))) continue;
155
+ if (e.isDirectory()) {
156
+ if (e.name === '.claude') { out.add(dir); continue; }
157
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
158
+ walk(p, depth + 1);
159
+ } else if (e.name === '.mcp.json') out.add(dir);
160
+ }
161
+ };
162
+ walk(root, 0);
163
+ return [...out].sort();
164
+ }
165
+ // Text that PRINTS the word npx is not an npx call site. Two of the sites this card warned about were
166
+ // `echo "Session ended. Run: npx aqe learn status"` — advice being displayed to the user, matched as
167
+ // though the machine were executing it. Strip quoted echo/printf payloads before classifying.
168
+ const stripPrinted = (cmd) => String(cmd)
169
+ .replace(/\b(?:echo|printf)\s+(['"])(?:\\.|(?!\1)[\s\S])*?\1/g, ' ')
170
+ .replace(/\b(?:echo|printf)\s+[^|;&]*/g, ' ');
171
+ function classifyCommand(cmd) {
172
+ if (typeof cmd !== 'string' || !cmd.trim()) return null;
173
+ if (NPX_RUV.test(stripPrinted(cmd))) return 'NPX';
174
+ if (/\.npm-global\/bin\/(ruflo|ruvector|ruv-swarm|flow-nexus)/.test(cmd) || /hook-handler\.cjs/.test(cmd)) return 'GLOBAL_BINARY';
175
+ if (/CLAUDE_PLUGIN_ROOT/.test(cmd)) return 'PLUGIN';
176
+ return null;
177
+ }
178
+ function wiringSurvey() {
179
+ const sites = [];
180
+ // Scan every candidate root (issue #19), de-duped by resolved path — a symlinked or nested root
181
+ // must never count the same project twice.
182
+ const seenProjects = new Set();
183
+ const projects = [];
184
+ for (const root of candidateRoots()) {
185
+ for (const proj of findProjects(root)) {
186
+ const resolved = path.resolve(proj);
187
+ if (seenProjects.has(resolved)) continue;
188
+ seenProjects.add(resolved);
189
+ projects.push({ proj, root });
190
+ }
191
+ }
192
+ for (const { proj, root } of projects) {
193
+ // Relative to the root it was actually found under, so "myproj" stays "myproj" instead of
194
+ // becoming an ugly full path when the machine only has one root (the common case).
195
+ const projName = path.relative(root, proj);
196
+ for (const f of ['.claude/settings.json', '.claude/settings.local.json']) {
197
+ const s = readJSON(path.join(proj, f));
198
+ if (!s?.hooks) continue;
199
+ for (const [event, groups] of Object.entries(s.hooks)) {
200
+ const list = Array.isArray(groups) ? groups : [groups];
201
+ for (const g of list) {
202
+ const hookArr = Array.isArray(g?.hooks) ? g.hooks : (g?.command ? [g] : []);
203
+ for (const h of hookArr) {
204
+ const mech = classifyCommand(h?.command);
205
+ if (mech) sites.push({ scope: 'project', project: projName, file: f, event, matcher: g?.matcher ?? '*', spec: String(h.command).slice(0, 160), mechanism: mech });
206
+ }
207
+ }
208
+ }
209
+ }
210
+ const mcp = readJSON(path.join(proj, '.mcp.json'));
211
+ for (const [name, v] of Object.entries(mcp?.mcpServers || {})) {
212
+ const full = [v.command, ...(v.args || [])].join(' ');
213
+ const mech = NPX_RUV.test(full) ? 'NPX' : (/\bnpx\b/.test(full) ? null : 'MCP');
214
+ if (mech) sites.push({ scope: 'project', project: projName, file: '.mcp.json', event: 'MCP', matcher: name, spec: full.slice(0, 160), mechanism: mech });
215
+ }
216
+ }
217
+ return { sites, summary: summarizeWiring(sites) };
218
+ }
219
+
220
+ // ── Memory health (read-only probes for the project the console was launched from) ────────────────
221
+ function sessionHookExists() {
222
+ return fs.existsSync(path.join(HOME, '.claude/hooks/agentdb-ensure.sh')) || fs.existsSync(path.join(HOME, '.claude/hooks'));
223
+ }
224
+ function probeMemory(projectDir) {
225
+ const db = path.join(projectDir, '.swarm/memory.db');
226
+ const probes = {};
227
+ // compaction survival + session surfacing are filesystem facts, always checkable
228
+ const snap = fs.existsSync(path.join(projectDir, 'agentdb-sessions.jsonl')) || fs.existsSync(path.join(projectDir, '.swarm/agentdb-sessions.jsonl'));
229
+ probes.compactionSurvival = snap ? { status: 'ok', detail: 'a PreCompact snapshot file is present' } : { status: 'warn', detail: 'no PreCompact snapshot found for this project yet' };
230
+ probes.sessionSurfacing = sessionHookExists() ? { status: 'ok', detail: 'the global SessionStart hook surfaces project state at launch' } : { status: 'warn', detail: 'no SessionStart recall hook found' };
231
+ // recall quality: honestly NOT probed at render (a true probe needs an embedding query; left for an explicit deep test)
232
+ probes.recallQuality = { status: 'notTested', detail: 'not checked this session — a real recall probe needs an embedding round-trip, which render deliberately avoids' };
233
+
234
+ if (!fs.existsSync(db)) {
235
+ probes.liveness = { status: 'fail', detail: 'this project has no memory store (.swarm/memory.db) yet' };
236
+ probes.coverage = { status: 'warn', detail: 'no checkpoint — no store has been created here' };
237
+ return probes;
238
+ }
239
+ // Liveness from a WAL-safe read. Existing-but-unopenable means the store is being written RIGHT NOW
240
+ // (a live store) — reported as "not checked this instant", never as a capping failure. Only a real
241
+ // corruption (integrity_check ≠ ok) is a fail.
242
+ const integ = robustRead(db, 'PRAGMA integrity_check;');
243
+ if (!integ.ok) {
244
+ probes.liveness = { status: 'notTested', detail: 'store is in active use right now — could not open a read-only snapshot this instant (normal for a live database being written; not a failure)' };
245
+ probes.coverage = { status: 'notTested', detail: 'store busy this instant — checkpoint presence not checked' };
246
+ return probes;
247
+ }
248
+ const integrity = (integ.value || '').split('\n')[0] || 'unknown';
249
+ const totalR = robustRead(db, 'SELECT count(*) FROM memory_entries;');
250
+ const embR = robustRead(db, "SELECT count(*) FROM memory_entries WHERE embedding IS NOT NULL AND length(embedding)>0;");
251
+ const total = totalR.ok ? (totalR.value === null ? 0 : parseInt(totalR.value, 10)) : null;
252
+ const embedded = embR.ok && embR.value !== null ? parseInt(embR.value, 10) : null;
253
+ const liveNote = integ.mode === 'immutable=1' ? ' and in active use' : '';
254
+ if (integrity !== 'ok') probes.liveness = { status: 'fail', detail: `store is corrupt (integrity_check: ${integrity})` };
255
+ else if (total === null) probes.liveness = { status: 'notTested', detail: 'store opened but counts were unavailable this instant' };
256
+ 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)` };
257
+ else probes.liveness = { status: 'warn', detail: 'store exists but is empty' };
258
+
259
+ const cp = robustRead(db, "SELECT max(updated_at) FROM memory_entries WHERE key LIKE 'project-state-current%';");
260
+ if (cp.ok && cp.value) {
261
+ const ageH = (Date.now() - Number(cp.value) * (String(cp.value).length <= 10 ? 1000 : 1)) / 3.6e6;
262
+ probes.coverage = Number.isFinite(ageH) && ageH < 48
263
+ ? { status: 'ok', detail: `project checkpoint present, ~${Math.max(0, ageH).toFixed(0)}h old` }
264
+ : { status: 'warn', detail: 'project checkpoint present but stale (>2 days)' };
265
+ } else if (cp.ok) {
266
+ probes.coverage = { status: 'warn', detail: 'no project-state checkpoint found in this store' };
267
+ } else {
268
+ probes.coverage = { status: 'notTested', detail: 'store busy this instant — checkpoint presence not checked' };
269
+ }
270
+ return probes;
271
+ }
272
+ // The fleet-wide scan opens and queries every memory store on the machine — ~90ms each, and a real
273
+ // machine has 100+. That is far too slow to sit on the page's first paint, so it is its own endpoint
274
+ // (/api/memory) and hydrates late, exactly like the stack audit does.
275
+ function scanFleet() {
276
+ // memory-doctor.mjs's findStores() defaults to ~/Code and cannot be edited here (issue #19) — so
277
+ // pass it every candidate root explicitly and de-dupe (it also always appends a couple of known
278
+ // extra paths regardless of root, which the Set below folds together instead of duplicating).
279
+ const seen = new Set();
280
+ const stores = [];
281
+ for (const root of candidateRoots()) {
282
+ for (const db of findStores(root)) {
283
+ const resolved = path.resolve(db);
284
+ if (seen.has(resolved)) continue;
285
+ seen.add(resolved);
286
+ stores.push(db);
287
+ }
288
+ }
289
+ const fleet = [];
290
+ for (const db of stores) {
291
+ const d = diagnose(db);
292
+ if (d.unreadable || d.schemaless) { fleet.push({ name: d.name, unreadable: d.unreadable || 'no memory schema', total: 0, learns: false, findings: d.findings }); continue; }
293
+ if ((d.total || 0) === 0) continue;
294
+ fleet.push({ name: d.name, total: d.total, embedded: d.embedded, coverPct: +(d.cover * 100).toFixed(1), patterns: d.patterns ?? 0, learns: !!d.learns, findings: d.findings });
295
+ }
296
+ fleet.sort((a, b) => (b.total || 0) - (a.total || 0));
297
+ return fleet;
298
+ }
299
+ function gatherMemory(cwd, { fleet = true } = {}) {
300
+ // health = for the project the console was launched from (fall back to this repo)
301
+ const project = fs.existsSync(path.join(cwd, '.swarm/memory.db')) ? cwd : REPO;
302
+ const projName = project.replace(HOME + '/Code/', '').replace(HOME + '/', '~/');
303
+ const health = scoreMemoryHealth({ project: projName, probes: probeMemory(project) });
304
+ return { fleet: fleet ? scanFleet() : null, health };
305
+ }
306
+
307
+ // ── Savings ledger (receipts only) ────────────────────────────────────────────────────────────────
308
+ function gatherSavings() {
309
+ // Primary source is the real routing-receipts ledger written by scripts/route-cheap.mjs.
310
+ const files = [
311
+ path.join(HOME, '.claude/metaharness/routing-receipts.jsonl'),
312
+ path.join(HOME, '.cache/ruvnet-brain/metaharness-receipts.jsonl'),
313
+ // Canonical user-level ledger (issue #36 — the hooks no longer scatter per-CWD copies).
314
+ path.join(HOME, '.cache/ruvnet-brain/token-ledger.jsonl'),
315
+ // Legacy location, still read so an existing user's history is not orphaned by the move.
316
+ path.join(REPO, 'plugin/scripts/.ruvnet-brain/token-ledger.jsonl'),
317
+ ];
318
+ const receipts = [];
319
+ let baselineUsd = 0;
320
+ let skippedUnmeasured = 0; // rows with neither a $ nor a time saving — counted so labels can say so
321
+ for (const f of files) {
322
+ if (!fs.existsSync(f)) continue;
323
+ for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
324
+ if (!line.trim()) continue;
325
+ const r = (() => { try { return JSON.parse(line); } catch { return null; } })();
326
+ if (!r) continue;
327
+ // MEASURED $ saved: explicit field, else frontier cost minus chosen cost.
328
+ let usd = Number(r.measuredUsd ?? r.savedUsd ?? r.usd ?? r.saved);
329
+ if (!Number.isFinite(usd) && Number.isFinite(Number(r.est_frontier_cost)) && Number.isFinite(Number(r.est_cost))) {
330
+ usd = Number(r.est_frontier_cost) - Number(r.est_cost);
331
+ }
332
+ // MEASURED time saved: explicit field, else baseline duration minus routed duration.
333
+ let ms = Number(r.measuredMs ?? r.savedMs ?? r.ms);
334
+ if (!Number.isFinite(ms) && Number.isFinite(Number(r.baseline_duration_ms)) && Number.isFinite(Number(r.duration_ms))) {
335
+ ms = Number(r.baseline_duration_ms) - Number(r.duration_ms);
336
+ }
337
+ if (!Number.isFinite(usd) && !Number.isFinite(ms)) { skippedUnmeasured += 1; continue; }
338
+ const base = Number(r.est_frontier_cost);
339
+ if (Number.isFinite(base)) baselineUsd += base;
340
+ receipts.push({
341
+ at: r.at ?? r.ts ?? null,
342
+ capability: r.capability ?? r.tool ?? r.source ?? 'routing',
343
+ task: r.task ?? r.label ?? '',
344
+ chosenTier: r.chosenTier ?? r.tier ?? r.model ?? '',
345
+ baselineTier: r.baselineTier ?? r.baseline ?? r.frontier_ref ?? '',
346
+ measuredMs: Number.isFinite(ms) ? ms : null,
347
+ measuredUsd: Number.isFinite(usd) ? usd : null,
348
+ });
349
+ }
350
+ }
351
+ const usdSaved = +receipts.reduce((a, r) => a + (r.measuredUsd || 0), 0).toFixed(4);
352
+ const totals = receipts.length ? {
353
+ count: receipts.length,
354
+ usdSaved,
355
+ msSaved: receipts.reduce((a, r) => a + (r.measuredMs || 0), 0),
356
+ baselineUsd: +baselineUsd.toFixed(4),
357
+ pctSaved: baselineUsd > 0 ? Math.round((usdSaved / baselineUsd) * 100) : null,
358
+ } : null;
359
+ return { totals, note: 'receipts only — no modelled, projected, or “up to” savings', skippedUnmeasured, receipts: receipts.slice(-25).reverse() };
360
+ }
361
+
362
+ // ── Config (user-level) ──────────────────────────────────────────────────────────────────────────
363
+ const CONFIG_SCHEMA = [
364
+ { key: 'openrouterKey', label: 'OpenRouter API key', type: 'secret', secret: true, help: 'Unlocks cheap-model routing and the self-improvement loop. Stored only in your user folder.' },
365
+ { key: 'provider', label: 'Your model house', type: 'enum', options: ['auto', 'anthropic', 'openai', 'codex', 'google', 'xai'], help: 'Which stack is yours? Sets your frontier model + savings baseline — Claude → Fable 5, ChatGPT → GPT-5.6 Sol, Codex → Sol, Gemini → 3.1 Pro, Grok → 4.5. “auto” detects from your keys.' },
366
+ { key: 'nightly', label: 'Nightly brain refresh', type: 'bool', help: 'Rebuild the knowledge base from pinned versions overnight so answers stay current.' },
367
+ { key: 'routing', label: 'Token-smart routing', type: 'enum', options: ['auto', 'off'], help: 'Send cheap, mechanical tasks to smaller, cheaper models automatically.' },
368
+ { key: 'qeFleet', label: 'On-demand QE test fleet', type: 'bool', help: 'Let RuvNet Brain spin up an Agentic-QE test fleet when you ask it to.' },
369
+ ];
370
+
371
+ // Every field below has a runtime owner. Platform-specific controls (currently the macOS nightly
372
+ // scheduler) are removed from the editable schema when that owner is unavailable and are reported
373
+ // honestly through `unavailable`.
374
+ const CONFIG_CONTROL_SUPPORT = Object.freeze({});
375
+
376
+ function unsupportedConfigControls() {
377
+ return CONFIG_SCHEMA
378
+ .filter((field) => Object.hasOwn(CONFIG_CONTROL_SUPPORT, field.key))
379
+ .map((field) => ({ key: field.key, label: field.label, reason: CONFIG_CONTROL_SUPPORT[field.key] }));
380
+ }
381
+ /**
382
+ * NOT-CHOSEN IS ITS OWN ANSWER, and collapsing it into "on" was this file's version of the exact lie
383
+ * the whole console was built to kill.
384
+ *
385
+ * `nightly: cfg.nightly !== false` and `routing: cfg.routing === 'off' ? 'off' : 'auto'` both derive
386
+ * ON from the ABSENCE of a key. On a machine with no config file at all — the empty-first case the
387
+ * bar names explicitly — that produced three contradictory answers to one question in a single render:
388
+ *
389
+ * Savings card -> green chip "✓ Smart routing: ON"
390
+ * Capabilities card -> "cheap-model-routing: absent — agentic-flow is not installed and no
391
+ * routing receipts exist"
392
+ * That card's own
393
+ * subtitle -> "Off by default — rUv would rather you chose it"
394
+ *
395
+ * Worse, these two keys are PREFERENCES, not switches: nothing outside this console reads either one
396
+ * (grepped — the only readers are gatherConfig and the savings CTA). So "ON" was not even reporting a
397
+ * setting that did something; it was reporting a default the user had never seen, about a feature
398
+ * that was not installed.
399
+ *
400
+ * `null` means the person has not chosen. It is rendered as "not chosen", never as on and never as
401
+ * off, and the Settings form shows the shipped default beside it as a recommendation rather than as
402
+ * a fact about their machine.
403
+ */
404
+ function gatherConfig() {
405
+ const cfg = readJSON(CONFIG_PATH) || {};
406
+ const credential = openRouterCredentialStatus({ cwd: process.cwd() });
407
+ const schedule = nightlyStatus();
408
+ const bool = (v) => (v === true ? true : v === false ? false : null);
409
+ const unavailable = unsupportedConfigControls();
410
+ if (!schedule.artifact.supported) {
411
+ unavailable.push({
412
+ key: 'nightly',
413
+ label: 'Nightly brain refresh',
414
+ reason: schedule.evidence,
415
+ });
416
+ }
417
+ return {
418
+ path: CONFIG_PATH.replace(HOME, '~'),
419
+ exists: fs.existsSync(CONFIG_PATH),
420
+ values: {
421
+ openrouterKey: credential.configured, // boolean only — never the secret itself
422
+ provider: typeof cfg.provider === 'string' && cfg.provider ? cfg.provider : null,
423
+ nightly: schedule.state === 'on' ? true : schedule.state === 'off' ? false : null,
424
+ routing: cfg.routing === 'off' ? 'off' : cfg.routing === 'auto' ? 'auto' : null,
425
+ qeFleet: bool(cfg.qeFleet),
426
+ },
427
+ // What the project would pick FOR you, kept separate from what you actually picked. The form can
428
+ // then say "recommended: on" without ever claiming that is the current state.
429
+ defaults: { provider: 'auto', nightly: true, routing: 'auto', qeFleet: false },
430
+ schema: CONFIG_SCHEMA.filter((field) =>
431
+ !Object.hasOwn(CONFIG_CONTROL_SUPPORT, field.key)
432
+ && (field.key !== 'nightly' || schedule.artifact.supported)),
433
+ unavailable,
434
+ runtime: {
435
+ openrouterKey: credential,
436
+ nightly: schedule,
437
+ },
438
+ };
439
+ }
440
+
441
+ /**
442
+ * ── The advocacy dial (user-settings.mjs) — a SEPARATE store from config.json, on purpose ─────────
443
+ *
444
+ * `advocacy` lives in ~/.config/ruvnet-brain/settings.json (user-settings.mjs STORE_PATH), not this
445
+ * console's own CONFIG_PATH — because anticipate.sh, the one emitter that gates on it, reads that
446
+ * exact file. Folding it into CONFIG_SCHEMA/saveConfig would give the console its own copy of the
447
+ * value, free to drift from the one the emitter actually reads. So this reads and writes through
448
+ * user-settings.mjs's own `loadSettings`/`saveSettings` — the same functions its CLI (`node
449
+ * user-settings.mjs`) and its test suite already exercise — rather than growing a second writer.
450
+ *
451
+ * All four ordinary user preferences are served now. Their consumers are: learning capture/flush,
452
+ * the advocacy hook, the console's guarded remedy loop, and SessionStart project-default seeding.
453
+ */
454
+ const LIVE_USER_SETTING_KEYS = Object.freeze(['learningScope', 'advocacy', 'autoApply', 'newProjectDefaults']);
455
+ const LIVE_USER_FIELDS = USER_SETTINGS_SCHEMA.filter((field) => LIVE_USER_SETTING_KEYS.includes(field.key));
456
+
457
+ function gatherAdvocacy() {
458
+ const state = loadSettings(); // validated: respects RUVNET_SETTINGS_FILE, degrades on corrupt/future files
459
+ // NOT-CHOSEN IS ITS OWN ANSWER — same rule gatherConfig() applies above. loadSettings() always hands
460
+ // back a COMPLETE values object (defaults filled in for any key the file never mentions), so the only
461
+ // way to tell "the user picked the default on purpose" apart from "the user never touched this key"
462
+ // is to peek at what was actually written, the same way gatherConfig() reads CONFIG_PATH raw.
463
+ const raw = readJSON(state.path);
464
+ const chosen = raw && typeof raw === 'object' && raw.settings && typeof raw.settings === 'object'
465
+ ? raw.settings : {};
466
+ return {
467
+ path: state.path.replace(HOME, '~'),
468
+ exists: state.exists,
469
+ values: Object.fromEntries(LIVE_USER_FIELDS.map((field) => [
470
+ field.key,
471
+ Object.hasOwn(chosen, field.key) ? state.values[field.key] : null,
472
+ ])),
473
+ defaults: Object.fromEntries(LIVE_USER_FIELDS.map((field) => [field.key, field.default])),
474
+ schema: LIVE_USER_FIELDS,
475
+ unavailable: [],
476
+ };
477
+ }
478
+
479
+ /**
480
+ * SAVE — through user-settings.mjs's own `saveSettings`, never a hand-rolled second writer. That
481
+ * function already owns the lock, the atomic rename and the backup-before-write for this exact file;
482
+ * re-implementing any of it here would be the precise duplication saveConfig's own header warns about.
483
+ *
484
+ * Takes a `values` object, the SAME shape saveConfig() takes, so the console's settings form can post
485
+ * to either endpoint with identical client code — only the URL and the target file differ.
486
+ */
487
+ function saveAdvocacy(values) {
488
+ const supplied = values && typeof values === 'object'
489
+ ? Object.fromEntries(Object.entries(values).filter(([key]) => LIVE_USER_SETTING_KEYS.includes(key)))
490
+ : {};
491
+ if (!Object.keys(supplied).length) {
492
+ return { ok: false, log: 'nothing was saved — no recognised settings were supplied' };
493
+ }
494
+ const rejected = [];
495
+ for (const [key, value] of Object.entries(supplied)) {
496
+ const field = LIVE_USER_FIELDS.find((candidate) => candidate.key === key);
497
+ if (field.type === 'bool' && typeof value !== 'boolean') {
498
+ rejected.push({ key, reason: `expected true or false, got ${JSON.stringify(value)}` });
499
+ } else if (field.type === 'enum' && !field.options.includes(value)) {
500
+ rejected.push({ key, reason: `expected one of ${field.options.join(', ')}, got ${JSON.stringify(value)}` });
501
+ }
502
+ }
503
+ if (rejected.length) {
504
+ return {
505
+ ok: false,
506
+ rejected,
507
+ log: `nothing was saved — ${rejected.map((entry) => `${entry.key}: ${entry.reason}`).join('; ')}`,
508
+ };
509
+ }
510
+ const result = saveSettings(supplied);
511
+ if (!result.ok) return { ok: false, rejected: result.errors || [], log: result.log };
512
+ publishSettingsToCache();
513
+ return {
514
+ ok: true,
515
+ backup: result.backup ? result.backup.replace(HOME, '~') : null,
516
+ values: Object.fromEntries(LIVE_USER_SETTING_KEYS.map((key) => [key, result.values[key]])),
517
+ log: result.log,
518
+ };
519
+ }
520
+
521
+ /**
522
+ * ── THE MASTER SWITCH (ADR-054) — its OWN section, deliberately not folded into the dial above ───
523
+ *
524
+ * `brainEnabled` lives in the SAME settings.json as `advocacy`, but it is served and saved
525
+ * separately, and the separation is the design rather than an accident of growth:
526
+ *
527
+ * 1. IT IS NOT A SETTING, IT IS A SWITCH. The enforcement artifact is the sentinel file
528
+ * (scripts/brain-state.mjs); the settings key is a MIRROR kept so the choice is visible where a
529
+ * user looks for their choices. Saving it therefore has to write TWO things, and a save path
530
+ * that writes two things must not be the same one that writes the ordinary dials — the moment
531
+ * it is, an unrelated dial save starts touching the switch.
532
+ * 2. THE TWO CAN LEGITIMATELY DISAGREE (an older release drops the mirror key — see gate test 1),
533
+ * so this section carries `disagreement` and the ordinary dial section has nothing like it.
534
+ * 3. IT MUST DISCLOSE WHAT KEEPS RUNNING. `notes` carries the maintenance-continues line, because
535
+ * the one thing worse than background work while "off" is UNDISCLOSED background work — GPT-5.6's
536
+ * half of the duel's single genuine disagreement.
537
+ *
538
+ * Same widget, same consent gate, same save/undo handling as the advocacy dial (the page renders it
539
+ * through the shared buildSettingsForm), just a different endpoint and a different store semantics.
540
+ */
541
+ const BRAIN_FIELD = USER_SETTINGS_SCHEMA.find((s) => s.key === 'brainEnabled');
542
+ const BRAIN_PROFILE_FIELD = USER_SETTINGS_SCHEMA.find((s) => s.key === 'brainProfile');
543
+
544
+ function gatherBrainProfile() {
545
+ const settings = loadSettings();
546
+ const installed = measureBrainProfile(INSTALLED_KB);
547
+ const actual = !installed.stores.includes(PROFILE_RUVECTOR)
548
+ ? null
549
+ : installed.stores.length === 1
550
+ ? PROFILE_RUVECTOR
551
+ : PROFILE_COMPLETE;
552
+ const source = measureBrainProfile(COMPLETE_BRAIN_SOURCE);
553
+ const updaterAvailable = fs.existsSync(path.join(INSTALLED_KB, 'forge-update.mjs'));
554
+ return {
555
+ path: INSTALLED_KB.replace(HOME, '~'),
556
+ values: { brainProfile: actual },
557
+ stored: settings.values.brainProfile,
558
+ disagreement: settings.values.brainProfile !== actual,
559
+ defaults: { brainProfile: BRAIN_PROFILE_FIELD.default },
560
+ schema: [BRAIN_PROFILE_FIELD],
561
+ installed,
562
+ choices: {
563
+ complete: {
564
+ available: (source.stores.includes(PROFILE_RUVECTOR) && source.storeCount > 1)
565
+ || updaterAvailable,
566
+ storeCount: source.storeCount,
567
+ bytes: source.bytes,
568
+ },
569
+ ruvector: {
570
+ available: installed.stores.includes(PROFILE_RUVECTOR)
571
+ || source.stores.includes(PROFILE_RUVECTOR),
572
+ storeCount: 1,
573
+ bytes: installed.byStore.ruvector ?? source.byStore.ruvector ?? null,
574
+ },
575
+ },
576
+ restoreSource: COMPLETE_BRAIN_SOURCE.replace(HOME, '~'),
577
+ };
578
+ }
579
+
580
+ function gatherBrainPower() {
581
+ const state = readOffState();
582
+ const settings = loadSettings();
583
+ return {
584
+ off: state.off,
585
+ since: state.since,
586
+ reason: state.reason,
587
+ switchPath: state.path.replace(HOME, '~'),
588
+ // The RESOLVED answer — what the machine actually does — not the mirror's opinion of it.
589
+ values: { brainEnabled: !state.off },
590
+ defaults: { brainEnabled: BRAIN_FIELD.default },
591
+ schema: [BRAIN_FIELD],
592
+ profile: gatherBrainProfile(),
593
+ disagreement: disagreement(settings.values.brainEnabled),
594
+ // Stated on the surface, not buried in a doc. Every line here is a thing that KEEPS HAPPENING
595
+ // while the brain is off; if one of them ever stops being true, this list is what has to change.
596
+ notes: state.off
597
+ ? [
598
+ 'Still running while off: version updates, the health alarm, and the open-issue banner — an off machine has to be able to receive the fix for an off-state bug.',
599
+ 'Stopped while off: retrieval from rUv\'s source, the grounding gate on your write path, everything the brain volunteers, and learning from this session.',
600
+ 'Already-running Claude Code and Codex sessions pick this up on their next hook or next search; a tool DESCRIPTION they cached at startup refreshes at their next restart.',
601
+ ]
602
+ : [
603
+ 'While the brain is on it retrieves from rUv\'s real source before answering, and its hooks watch your write path.',
604
+ 'Switching it off stops retrieval, the grounding gate, everything it volunteers, and learning — updates and health alarms keep running, and you can pause those separately.',
605
+ ],
606
+ };
607
+ }
608
+
609
+ function saveBrainProfile(values) {
610
+ const profile = values && typeof values === 'object' ? values.brainProfile : undefined;
611
+ if (![PROFILE_COMPLETE, PROFILE_RUVECTOR].includes(profile)) {
612
+ const reason = `expected complete or ruvector, got ${JSON.stringify(profile)}`;
613
+ return { ok: false, rejected: [{ key: 'brainProfile', reason }], log: `nothing was changed — ${reason}` };
614
+ }
615
+ const before = measureBrainProfile(INSTALLED_KB);
616
+ if (!before.stores.includes(PROFILE_RUVECTOR)) {
617
+ return { ok: false, log: `nothing was changed — no RuVector RVF store exists in ${INSTALLED_KB}` };
618
+ }
619
+
620
+ let changed;
621
+ try {
622
+ if (profile === PROFILE_RUVECTOR) {
623
+ changed = applyBrainProfile(INSTALLED_KB, profile);
624
+ } else {
625
+ const localComplete = measureBrainProfile(COMPLETE_BRAIN_SOURCE);
626
+ if (localComplete.storeCount > 1) {
627
+ changed = restoreCompleteProfile(INSTALLED_KB, COMPLETE_BRAIN_SOURCE);
628
+ } else {
629
+ const updater = path.join(INSTALLED_KB, 'forge-update.mjs');
630
+ if (!fs.existsSync(updater)) {
631
+ throw new Error('the complete release is not cached here and forge-update.mjs is unavailable');
632
+ }
633
+ const restored = spawnSync(process.execPath, [
634
+ updater,
635
+ '--apply',
636
+ '--restore-complete',
637
+ PROFILE_RUVECTOR,
638
+ ], {
639
+ cwd: INSTALLED_KB,
640
+ env: process.env,
641
+ encoding: 'utf8',
642
+ timeout: 30 * 60 * 1000,
643
+ maxBuffer: 10 * 1024 * 1024,
644
+ });
645
+ if (restored.status !== 0) {
646
+ const detail = String(restored.stderr || restored.stdout || `exit ${restored.status}`).trim().slice(-1200);
647
+ throw new Error(`signed complete-bundle restore failed: ${detail}`);
648
+ }
649
+ changed = { profile: PROFILE_COMPLETE, stores: discoverStoreFamilies(INSTALLED_KB) };
650
+ if (changed.stores.length < 2) {
651
+ throw new Error('the signed updater completed but the complete repository stores did not land');
652
+ }
653
+ }
654
+ }
655
+ } catch (error) {
656
+ return { ok: false, log: `nothing was changed — ${error.message}` };
657
+ }
658
+
659
+ const mirrored = saveSettings({ brainProfile: profile });
660
+ publishBrainPowerToCache();
661
+ return {
662
+ ok: true,
663
+ profile,
664
+ values: { brainProfile: profile },
665
+ stores: changed.stores,
666
+ removed: changed.removed || [],
667
+ bytesFreed: changed.bytesFreed || 0,
668
+ mirrored: mirrored.ok,
669
+ backup: mirrored.backup ? mirrored.backup.replace(HOME, '~') : null,
670
+ log: mirrored.ok
671
+ ? (profile === PROFILE_RUVECTOR
672
+ ? `RuVector Only is active — ${changed.removed.length} unselected artifact(s) removed`
673
+ : `Complete Brain is active — ${changed.stores.length} repository stores available`)
674
+ : `${profile === PROFILE_RUVECTOR ? 'RuVector Only' : 'Complete Brain'} is active on disk, but the settings mirror could not be updated (${mirrored.log})`,
675
+ };
676
+ }
677
+
678
+ /**
679
+ * PUSH THE NEW SWITCH POSITION INTO THE STATE CACHE, IMMEDIATELY.
680
+ *
681
+ * FOUND BY A LIVE HTTP SMOKE, not by a unit test, and it is worth saying which: every unit
682
+ * assertion around saveBrainPower() passed while the real server, queried over real HTTP one second
683
+ * after a successful `off` save, answered `off: false`. `/api/state` is cache-first by design (the
684
+ * 2026-07-17 outage bargain: never compute inline on a request), and nothing invalidated the cache
685
+ * on this write — so the page's own master switch would have rendered ON for a machine that was OFF
686
+ * until a background refresh happened to land. That is the exact class of statement this console
687
+ * exists to make impossible.
688
+ *
689
+ * PATCH, then back-date — not `expireCachesEmbedding`, and both halves are deliberate:
690
+ * • PATCH the value, because for THIS field "stale" is not an acceptable stand-in for "wrong".
691
+ * Back-dating alone still hands the next reader `off: false`, merely labelled old.
692
+ * • BACK-DATE anyway, so the record is honestly marked as a measurement due for replacement and
693
+ * serveCached's kickRefresh() produces a wholly fresh one. Staleness here is caused by a WRITE,
694
+ * not by time passing — the doctrine expireCachesEmbedding's own header states.
695
+ * • NOT the shared helper: it calls writeCache WITHOUT a scope, and STATE_CACHE is project-scoped.
696
+ * Dropping the scope would make the next read a scope MISMATCH, which takes the COLD path and
697
+ * computes inline — reintroducing the multi-second freeze on the very next page load.
698
+ */
699
+ function publishBrainPowerToCache() {
700
+ try {
701
+ const c = readJSON(STATE_CACHE);
702
+ if (!c || !c.data || !c.data.sections) return; // nothing cached yet — the next read is cold and correct
703
+ c.data.sections.brainPower = gatherBrainPower();
704
+ writeCache(STATE_CACHE, new Date(0).toISOString(), c.data, c.scope ?? null);
705
+ } catch { /* a cache we cannot rewrite is one the next refresh replaces anyway */ }
706
+ }
707
+
708
+ /**
709
+ * Publish the two ordinary settings read-models immediately after their writers succeed.
710
+ *
711
+ * `/api/state` is intentionally cache-first, so a successful save followed by reload otherwise
712
+ * repaints the previous choices until a background measurement lands. A live browser test caught
713
+ * exactly that failure for provider and advocacy. Patch the fields whose authoritative stores were
714
+ * just written, retain the project scope, and withdraw the surrounding measurement so the detached
715
+ * refresh still replaces every other section.
716
+ */
717
+ function publishSettingsToCache() {
718
+ try {
719
+ const c = readJSON(STATE_CACHE);
720
+ if (!c || !c.data || !c.data.sections) return;
721
+ c.data.sections.config = gatherConfig();
722
+ c.data.sections.userSettings = gatherAdvocacy();
723
+ writeCache(STATE_CACHE, new Date(0).toISOString(), c.data, c.scope ?? null);
724
+ } catch { /* the authoritative stores are already correct; refresh will replace an unreadable cache */ }
725
+ }
726
+
727
+ /**
728
+ * SAVE — the sentinel FIRST, the mirror second, and the receipt tells the truth about both.
729
+ *
730
+ * Order matters and is not arbitrary. The sentinel is what every reader enforces from; the mirror is
731
+ * a record. If the mirror write fails after the switch has flipped, the machine is in the state the
732
+ * user asked for and one display is stale — recoverable, and reported. If it were the other way
733
+ * round, a failed sentinel write would leave a settings file claiming a state the machine is not in,
734
+ * which is the console showing a toggle wired to nothing.
735
+ *
736
+ * The mirror goes through user-settings.mjs's own saveSettings — same lock, same atomic rename, same
737
+ * backup-before-write as every other key. No second writer.
738
+ */
739
+ function saveBrainPower(values) {
740
+ const value = values && typeof values === 'object' ? values.brainEnabled : undefined;
741
+ if (value === undefined) return { ok: false, log: 'nothing was saved — no recognised settings were supplied' };
742
+ if (typeof value !== 'boolean') {
743
+ const reason = `expected true or false, got ${JSON.stringify(value)}`;
744
+ return { ok: false, rejected: [{ key: 'brainEnabled', reason }], log: `nothing was saved — brainEnabled: ${reason}` };
745
+ }
746
+
747
+ const flipped = value ? setBrainOn() : setBrainOff('switched off from the console');
748
+ if (!flipped.ok) return { ok: false, log: `nothing was changed — ${flipped.log}` };
749
+
750
+ const mirrored = saveSettings({ brainEnabled: value });
751
+ const state = readOffState();
752
+ publishBrainPowerToCache();
753
+ return {
754
+ ok: true,
755
+ off: state.off,
756
+ since: state.since,
757
+ backup: mirrored.backup ? mirrored.backup.replace(HOME, '~') : null,
758
+ values: { brainEnabled: !state.off },
759
+ // Honest about the half-failure rather than reporting a clean success: the switch is what counts
760
+ // and it moved, but say so plainly if the visible record did not follow it.
761
+ log: mirrored.ok
762
+ ? (value ? 'the brain is on' : 'the brain is off — updates and health alarms keep running')
763
+ : `the brain is ${value ? 'on' : 'off'}, but your settings file could not be updated to match (${mirrored.log})`,
764
+ mirrored: mirrored.ok,
765
+ };
766
+ }
767
+
768
+ /**
769
+ * ── LESSONS: the surface the store was written for and never got ────────────────────────────────
770
+ *
771
+ * lesson-store.mjs:391 says of `pending()`: "what the management surface must show first." There was
772
+ * no management surface. Sixteen lessons — thirteen of them the owner's own words, one of them
773
+ * enforcing at BLOCK level — lived in a JSON file with a CLI over it, which is the owner's exact
774
+ * complaint: "murky things in a .claude file nobody sees." A rule you cannot SEE is a rule you
775
+ * cannot consent to, and an unconsented rule that blocks your work is the fastest route to someone
776
+ * deleting the whole product.
777
+ *
778
+ * Two honesty constraints, both learned the hard way in this repo:
779
+ *
780
+ * 1. NO JARGON IN THE PRIMARY LINE. `origin: 'user-stated'` renders as "you taught me this";
781
+ * `enforcement: 'block'` renders as what it DOES to you, not what it is called internally.
782
+ * 2. THE STATE IS READ, NEVER ASSERTED. Every field below comes from the store on this request.
783
+ */
784
+ const TRIGGER_BY_KEY = new Map(Object.values(TRIGGERS).map((t) => [t.key, t]));
785
+
786
+ // What each enforcement level actually DOES to the user — the info-bubble text. Written as
787
+ // consequence-to-you, because "checklist" is a word about our implementation, not about their day.
788
+ const ENFORCEMENT_MEANING = {
789
+ block: { label: 'Stops me', detail: 'I am interrupted at this moment and cannot continue until the check passes. This is the strongest level, and only lessons you stated yourself can reach it.' },
790
+ checklist: { label: 'Checklist', detail: 'I get a checklist item I have to tick at this moment. It does not stop me — it makes skipping it a visible choice rather than an accident.' },
791
+ review: { label: 'Reminder', detail: 'I am reminded at this moment. No stop, no checklist — it shapes what I pay attention to.' },
792
+ };
793
+
794
+ function gatherLessons() {
795
+ let all;
796
+ try { all = loadLessons(); }
797
+ catch (e) {
798
+ // TASK 3: stamped at the read that just failed, not before it was attempted.
799
+ return { ok: false, error: String(e && e.message || e), lessons: [], counts: null, ...freshnessOf(new Date().toISOString()) };
800
+ }
801
+ // TASK 3: the observation instant is loadLessons() finishing, right above — not whenever the
802
+ // .map()/.sort() derived-computation below happens to finish building the response.
803
+ const measuredAt = new Date().toISOString();
804
+
805
+ const lessons = all.map((l) => {
806
+ const trig = TRIGGER_BY_KEY.get(l.trigger);
807
+ const meaning = ENFORCEMENT_MEANING[l.enforcement] || { label: l.enforcement, detail: '' };
808
+ const userStated = l.origin === ORIGIN.USER_STATED;
809
+ return {
810
+ id: l.id,
811
+ statement: l.statement,
812
+ // The moment it fires, in the second person. This is the load-bearing column: a lesson with no
813
+ // observable moment is prose, and the store refuses to construct one (lesson-store.mjs:131).
814
+ when: trig ? `when I'm ${trig.label}` : '(no trigger — this lesson cannot fire)',
815
+ surface: trig ? trig.surface : null,
816
+ enforcement: l.enforcement,
817
+ enforcementLabel: meaning.label,
818
+ enforcementDetail: meaning.detail,
819
+ // Provenance drives trust, so it is stated plainly and never flattened into a badge colour.
820
+ origin: userStated ? 'you taught me this' : 'I inferred this from what happened',
821
+ userStated,
822
+ taughtCount: l.repeatCount || 0,
823
+ projects: Array.isArray(l.projects) ? l.projects : [],
824
+ evidence: l.evidence || null,
825
+ weight: Number(weightOf(l).toFixed(4)),
826
+ status: l.status,
827
+ ratified: l.status === STATUS.RATIFIED || l.status === STATUS.ACTIVE,
828
+ demoted: !!l.demoted,
829
+ // The one thing the user is being ASKED, as opposed to merely shown.
830
+ awaitingYou: l.status === STATUS.CANDIDATE && !l.demoted,
831
+ // Honest ceiling: ratifying a model-inferred lesson can NOT raise it to block
832
+ // (lesson-store.mjs:380). Say so before they click, not after.
833
+ canReachBlock: userStated,
834
+ intendedEnforcement: l.intendedEnforcement || null,
835
+ };
836
+ });
837
+
838
+ // Highest-consequence first — blast radius, not alphabetical. Something that STOPS me outranks a
839
+ // reminder; among equals, the one I have taught most often.
840
+ const rank = { block: 0, checklist: 1, review: 2 };
841
+ lessons.sort((a, b) => {
842
+ if (a.awaitingYou !== b.awaitingYou) return a.awaitingYou ? -1 : 1;
843
+ if (a.demoted !== b.demoted) return a.demoted ? 1 : -1;
844
+ const r = (rank[a.enforcement] ?? 9) - (rank[b.enforcement] ?? 9);
845
+ if (r) return r;
846
+ return b.taughtCount - a.taughtCount;
847
+ });
848
+
849
+ return {
850
+ ok: true,
851
+ lessons,
852
+ counts: {
853
+ total: lessons.length,
854
+ active: lessons.filter((l) => l.ratified && !l.demoted).length,
855
+ awaitingYou: lessons.filter((l) => l.awaitingYou).length,
856
+ off: lessons.filter((l) => l.demoted).length,
857
+ blocking: lessons.filter((l) => l.enforcement === 'block' && l.ratified && !l.demoted).length,
858
+ },
859
+ // TASK 2: this endpoint bypasses serveCached entirely and had NO timestamp of any kind. It is
860
+ // never cached — loadLessons() reads the live file on every call — so this is always fresh, but
861
+ // said so through the SAME envelope every other card uses rather than a bespoke "no age" shape.
862
+ ...freshnessOf(measuredAt),
863
+ };
864
+ }
865
+
866
+ /**
867
+ * The three user verbs, each one an existing lesson-store function. `updateLessons` re-reads the
868
+ * store inside its own transform, so a second console session cannot clobber this one — the same
869
+ * property saveConfig gets from withLock, obtained here from the store rather than re-built.
870
+ */
871
+ const LESSON_ACTIONS = {
872
+ ratify: { fn: ratify, past: 'turned on' },
873
+ demote: { fn: demote, past: 'turned off' },
874
+ restore: { fn: restore, past: 'restored' },
875
+ };
876
+
877
+ function setLesson(body) {
878
+ const id = body && typeof body.id === 'string' ? body.id : null;
879
+ const action = body && typeof body.action === 'string' ? body.action : null;
880
+ if (!id) return { ok: false, log: 'nothing changed — no lesson id supplied' };
881
+ const spec = LESSON_ACTIONS[action];
882
+ if (!spec) {
883
+ return { ok: false, log: `nothing changed — action must be one of ${Object.keys(LESSON_ACTIONS).join(', ')}, got ${JSON.stringify(action)}` };
884
+ }
885
+ const before = loadLessons().find((l) => l.id === id);
886
+ if (!before) return { ok: false, log: `nothing changed — no lesson with id ${id}` };
887
+
888
+ try {
889
+ updateLessons((fresh) => spec.fn(id, fresh));
890
+ } catch (e) {
891
+ return { ok: false, log: `could not save: ${String(e && e.message || e)}` };
892
+ }
893
+
894
+ // THE WRITE LANDED, SO EVERY CACHE THAT SPEAKS ABOUT LESSONS IS NOW WRONG.
895
+ //
896
+ // `capability-registry.mjs` derives two rows from this exact store — `lessons-in-force` (it reads
897
+ // ratified-vs-candidate counts) and `cross-project-lessons`. Left alone, the capabilities card
898
+ // would keep asserting the pre-click state for up to a full ceiling, one card away from the lessons
899
+ // card showing the truth, on the same screen. That is the original two-day-old incident in
900
+ // miniature, and this time WE would have caused it.
901
+ //
902
+ // Expired, not deleted — see expireCachesEmbedding for why deleting would resurrect the hang.
903
+ expireCachesEmbedding([CAPABILITY_CACHE]);
904
+
905
+ const after = loadLessons().find((l) => l.id === id);
906
+ // Report what MOVED, read back from disk. An "ok" that was never re-read is the failure mode
907
+ // user-settings.mjs was built to end: every writer returned ok:true while losing the write.
908
+ return {
909
+ ok: true,
910
+ id,
911
+ action,
912
+ log: `${id} ${spec.past}.`,
913
+ now: after ? { status: after.status, enforcement: after.enforcement, demoted: !!after.demoted } : null,
914
+ was: { status: before.status, enforcement: before.enforcement, demoted: !!before.demoted },
915
+ };
916
+ }
917
+
918
+ // ── Brain activity read-model (ADR-0018) — read-only, file reads + sqlite3 CLI only ──────────────
919
+ // Fleet scan is cached: ~50-100 stores × a CLI spawn each is fine once, not per poll.
920
+ // 2026-07-17 (Stuart: "work faster" — measured 49s cold vs 1.8s warm): the cache PERSISTS to disk and
921
+ // hydrates at boot, so a fresh server paints real data instantly with its honest "scanned at HH:MM"
922
+ // stamp. 2026-07-26 (RVBC-INSTANT-SPEC #5): the SCAN ITSELF now runs only in the detached
923
+ // --refresh-cache child. A request never scans — not inline on a first-ever run, and not via
924
+ // setImmediate either (deferring synchronous work still blocks the loop when it runs). A machine with
925
+ // no fleet scan yet is reported `warming`, never as a fabricated zero.
926
+ let ACTIVITY_MACHINE_CACHE = null;
927
+ const CONSOLE_CACHE_PATH = path.join(HOME, '.cache/ruvnet-brain/console-cache.json');
928
+
929
+ // ── Warm-cache serving (2026-07-17, the demo-hang fix) ─────────────────────────────────────────────
930
+ // Every read-model here does multi-second synchronous work: gatherState ~13s, gatherStack ~22s,
931
+ // scanFleet ~40s+ (each opens 100+ SQLite stores or walks ~/Code). Node is single-threaded, so a
932
+ // SINGLE inline compute freezes the WHOLE server — which is exactly why fresh loads returned nothing
933
+ // (curl saw 000) roughly one request in three while a scan held the event loop. setImmediate does
934
+ // NOT help: deferring synchronous work still blocks the loop when it finally runs.
935
+ // The fix: the request handler NEVER computes inline once a cache exists. It serves the last cache
936
+ // (instant, a file read) and kicks a DETACHED CHILD PROCESS (`--refresh-cache`) to recompute off the
937
+ // server's event loop entirely. A truly cold machine (no cache at all) eats ONE inline compute to
938
+ // seed the cache, then is warm forever. Caches persist across restarts, so cold is rare.
939
+ const STATE_CACHE = path.join(CONFIG_DIR, 'state-cache.json');
940
+ const STACK_CACHE = path.join(CONFIG_DIR, 'stack-audit-cache.json');
941
+ const MEMORY_CACHE = path.join(CONFIG_DIR, 'memory-cache.json');
942
+ const CAPABILITY_CACHE = path.join(CONFIG_DIR, 'capability-cache.json');
943
+
944
+ /**
945
+ * READ-AFTER-WRITE INVALIDATION — the hole a wall clock cannot close.
946
+ *
947
+ * Fable 5, 2026-07-24: age-based freshness gives you "stale by at most N minutes", which is NOT the
948
+ * product's promise. The promise is that it never lies about your machine. The gap is exact and
949
+ * demonstrable: the user toggles a lesson; `/api/lessons` re-reads live and tells the truth; and
950
+ * `/api/capabilities` goes on serving its `lessons-in-force` row from a cache that is under the
951
+ * ceiling, correctly stamped, fully compliant with the new freshness contract — and false, on the
952
+ * same screen, one card away, **caused by the user's own click.**
953
+ *
954
+ * That is the ORIGINAL incident (a cache speaking over the lesson store) reappearing inside the fix
955
+ * written for it. No ceiling short of zero closes it, because the staleness is not caused by time
956
+ * passing — it is caused by a write.
957
+ *
958
+ * EXPIRE, DO NOT DELETE. The obvious move is `unlink`. That would be a bug: with no cache file the
959
+ * next request takes the COLD path, which computes inline — reintroducing the 13-49s server freeze
960
+ * fixed one commit ago. Instead we back-date the stamp. The next reader gets the old value marked
961
+ * `stale: true` with an honest age (fast, non-blocking) and the detached refresher replaces it. The
962
+ * claim is withdrawn the instant the user's write lands, without any request paying for it.
963
+ *
964
+ * PRECISION IS PART OF THE CONTRACT: expire only caches whose payload actually embeds the mutated
965
+ * fact. Blanket-expiring everything would be cheap to write and would turn every toggle into a
966
+ * machine-wide re-scan, which is how a correctness fix becomes a performance complaint.
967
+ */
968
+ /**
969
+ * The capability read-model, computed in ONE place because it has TWO writers.
970
+ *
971
+ * It was inline in the `/api/capabilities` handler, and the background refresher did not write this
972
+ * cache at all. Adding the refresher meant either duplicating this logic or extracting it — and the
973
+ * duplicate was already half-written when the MEMORY_CACHE comment forty lines below caught it: that
974
+ * exact mistake ("a cache writer that knew about half the payload") once made a background refresh
975
+ * silently ERASE the advocacy block, so the page showed recommendations on the first request and
976
+ * none ever after. The draft here reproduced it precisely, omitting `advocacy`.
977
+ *
978
+ * One computer, two callers. A shape that cannot drift because there is only one of it.
979
+ */
980
+ function computeCapabilities() {
981
+ let rows = [];
982
+ let reconciled = [];
983
+ let reconciledIgnored = [];
984
+ try {
985
+ rows = capabilityAuditAll();
986
+ // Credit APPLIED for anything we offered that the user has since switched on. Derived from this
987
+ // live audit, never guessed; safe on a read (idempotent — a resolved offer is no longer pending).
988
+ reconciled = reconcileApplied(rows);
989
+ // THE DENOMINATOR'S MISSING THIRD (ADR-028 L5): an offer that has sat PENDING, still off, for a
990
+ // full day is `ignored`. Runs AFTER reconcileApplied so a capability the user just switched on is
991
+ // never miscounted as ignored in the same pass.
992
+ reconciledIgnored = reconcileIgnored(findStaleOffers(rows));
993
+ // LATENCY-TO-SURFACE's missing half (ADR-028:103, "the single best summary metric"). The
994
+ // registry is a pure detector with no memory: it can say "this is off", never "this has been off
995
+ // since Tuesday" — so the subtraction had no left-hand side and the metric was uncomputable.
996
+ // Appending state TRANSITIONS here, on the audit that already runs, supplies it.
997
+ //
998
+ // Deliberately inside the try and deliberately non-fatal: a capability audit must never fail
999
+ // because a metric could not be written. recordObservation() already swallows its own IO errors
1000
+ // and returns [] — this is the second belt, because the console rendering is load-bearing for
1001
+ // the user and the measurement is not.
1002
+ try { recordCapabilityStates(rows); } catch { /* the metric is never worth breaking the page for */ }
1003
+ } catch (e) {
1004
+ // A failed audit must NOT render as "everything is off" — the precise lie this surface kills.
1005
+ rows = [{ key: 'audit', label: 'Capability audit', state: 'unknown', scope: 'machine',
1006
+ whatItBuysYou: 'a clear picture of what you own and what is switched on',
1007
+ evidence: `the audit could not run: ${String(e && e.message || e).slice(0, 160)}` }];
1008
+ }
1009
+ // THE CAPABILITY ⇄ RECOMMENDATION BRIDGE. `recId` is stamped by the SERVER, and only when
1010
+ // buildCapabilityRecommendations() actually constructed a schema-gated rec for this row — never
1011
+ // guessed, never derived client-side. This is the field console/app.js's capCheckboxEligible() reads
1012
+ // to decide whether a row earns a checkbox at all; see console-engine.mjs's header on that function
1013
+ // for why the bar is proven-undo, not merely has-a-command. Wrapped in its own try/catch so a bug in
1014
+ // the bridge degrades to "no checkbox anywhere" (recId: null everywhere), never a broken page — the
1015
+ // same non-fatal discipline every other enrichment in this function already holds to.
1016
+ try {
1017
+ const wantIds = new Set(buildCapabilityRecommendations({ capabilities: rows }).map((r) => r.id));
1018
+ for (const row of rows) row.recId = wantIds.has(`enable:${row.key}`) ? `enable:${row.key}` : null;
1019
+ } catch { for (const row of rows) row.recId = null; }
1020
+ // null (not 0) until enough offers have resolved — an honest "not yet judgeable", never a
1021
+ // fabricated score. Computed AFTER both reconciles so a freshly-resolved outcome is reflected.
1022
+ const prec = advocacyPrecision();
1023
+ return { at: new Date().toISOString(), data: { rows, advocacy: { precision: prec, reconciled, reconciledIgnored } } };
1024
+ }
1025
+
1026
+ export function expireCachesEmbedding(files) {
1027
+ for (const f of files) {
1028
+ try {
1029
+ const j = readJSON(f);
1030
+ if (!j || !j.data) continue;
1031
+ j.at = new Date(0).toISOString(); // epoch ⇒ unambiguously past any ceiling
1032
+ // SCOPE SURVIVES THE EXPIRY (fixed 2026-07-26, RVBC-INSTANT-SPEC #3). This was
1033
+ // `writeCache(f, j.at, j.data)` — three arguments — and writeCache's fourth parameter defaults
1034
+ // to null, so every expiry silently erased WHICH PROJECT the measurement belonged to. Against a
1035
+ // project-scoped read that null is a scope MISMATCH, and a mismatch is treated as cold. So the
1036
+ // helper written to avoid the freeze ("EXPIRE, DO NOT DELETE — with no cache file the next
1037
+ // request takes the COLD path") reintroduced the cold path by another door: not by deleting the
1038
+ // file, by deleting its identity. Cold no longer computes inline, but a de-scoped cache still
1039
+ // throws away a perfectly good measurement and blanks the page until the child lands.
1040
+ writeCache(f, j.at, j.data, j.scope ?? null);
1041
+ } catch { /* a cache we cannot rewrite is one the next reader will recompute anyway */ }
1042
+ }
1043
+ }
1044
+
1045
+ /**
1046
+ * THE THIRD OUTCOME, WIRED (ADR-028 L5). `ignored` had ZERO callers: precision = applied ÷
1047
+ * (applied+dismissed+ignored) silently shrank its own denominator, the mirror image of the
1048
+ * "record only the applies" fabrication advocacy-outcomes.mjs's own header names. This is the
1049
+ * caller advocacy-outcomes.mjs's own docs ask for: it computes staleness (the ledger deliberately
1050
+ * does not — see reconcileIgnored()'s header), this file only ever verifies the staleness ledger
1051
+ * already has evidence for.
1052
+ *
1053
+ * THE RULE, AND WHY IT IS THE CHEAP ONE TO DEFEND: an offer is `ignored` once it has been PENDING
1054
+ * (never applied nor dismissed) for at least `IGNORE_AFTER_MS` AND the audit, run again right now,
1055
+ * still finds the capability `off`. Both halves are load-bearing:
1056
+ * - "still off" rules out the one honest reason silence could mean something OTHER than a miss —
1057
+ * the user already acted and reconcileApplied() simply has not been called yet in THIS request
1058
+ * (it always runs first, immediately above, in the same audit pass).
1059
+ * - "pending ≥ IGNORE_AFTER_MS" is wall-clock time, not a session count, because THIS endpoint has
1060
+ * no session concept of its own (it is a cached HTTP read-model, polled on whatever cadence the
1061
+ * console page happens to be open) — inventing a session counter here would be evidence this
1062
+ * file does not have. 24h is a full day of the capability sitting there, in the one place a user
1063
+ * would see it (the console, `/api/capabilities`'s own consumer), still off, with no dismiss and
1064
+ * no apply — long enough that "hasn't looked yet" stops being the more likely explanation.
1065
+ * A day is also symmetric with anticipate.sh's own once-per-project-per-day fallback session key, so
1066
+ * the two surfaces do not disagree about what "already had a fair chance to react" means.
1067
+ *
1068
+ * PURE (besides the ledger read `pendingOffers()` performs) — `now` is a parameter so a test can
1069
+ * pass a fixed instant instead of asserting against a moving `Date.now()`.
1070
+ */
1071
+ const IGNORE_AFTER_MS = 24 * 60 * 60 * 1000;
1072
+ export function findStaleOffers(rows, { file, now = Date.now() } = {}) {
1073
+ const stillOff = new Set((Array.isArray(rows) ? rows : []).filter((r) => r && r.state === 'off').map((r) => r.key));
1074
+ return pendingOffers({ file })
1075
+ .filter((p) => stillOff.has(p.id) && typeof p.at === 'string' && (now - Date.parse(p.at)) >= IGNORE_AFTER_MS)
1076
+ .map((p) => p.id);
1077
+ }
1078
+
1079
+ const SELF = fileURLToPath(import.meta.url);
1080
+ let LAST_REFRESH_KICK = 0;
1081
+ /**
1082
+ * TEMP-THEN-RENAME — every cache writer in this file goes through this, never a bare writeFileSync.
1083
+ *
1084
+ * A bare writeFileSync truncates the target before the new bytes land. This file writes each cache
1085
+ * from at least two independent code paths per refresh cycle (the detached `--refresh-cache` child,
1086
+ * PLUS gatherState()/gatherStack() self-caching whenever called directly — see those two functions),
1087
+ * and a crash or kill mid-write leaves a TORN, half-written JSON file behind. readJSON()'s JSON.parse
1088
+ * then throws on that file, which is indistinguishable from "no cache yet" to every `!c || !c.data`
1089
+ * cold-path check in this file — so a torn cache silently demotes the NEXT request into the exact
1090
+ * expensive inline compute (13-49s) this caching exists to avoid.
1091
+ *
1092
+ * NOT hand-rolled: this reuses `writeAtomic` from user-settings.mjs (already imported above, line 46)
1093
+ * rather than growing a second copy of open/write/rename — it already does temp-then-rename PLUS an
1094
+ * fsync before the rename (a rename alone can land while the new bytes are still in the page cache;
1095
+ * without the flush, a power loss can leave an atomically-renamed but EMPTY file). Matches
1096
+ * lesson-store.mjs's saveLessons() in spirit, the store this class of fix was hardened for after a
1097
+ * real data-loss incident (see that file's own header).
1098
+ */
1099
+ function atomicWriteJSON(file, obj) {
1100
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1101
+ writeAtomic(file, JSON.stringify(obj));
1102
+ }
1103
+ function writeCache(file, at, data, scope = null) {
1104
+ try { atomicWriteJSON(file, { at, data, scope }); }
1105
+ catch { /* a cache write must never break a response */ }
1106
+ }
1107
+ /**
1108
+ * Spawn the detached `--refresh-cache` child that does ALL the measuring.
1109
+ *
1110
+ * @param {{force?: boolean}} opts `force` bypasses the 15s time debounce. It exists for the two
1111
+ * callers where a debounced no-op would be a LIE to the user: a COLD/scope-mismatched read (there
1112
+ * is nothing to serve, so "we'll get to it within 15 seconds" means a blank page for 15 seconds)
1113
+ * and the explicit Refresh button (it must answer "yes, I started one", not silently drop the
1114
+ * click and still return ok — RVBC-INSTANT-SPEC #3).
1115
+ * @returns {boolean} whether a child was actually started. Reported to the page rather than
1116
+ * swallowed: a refresh that did not start must not render as one that did.
1117
+ *
1118
+ * ONE SCAN AT A TIME, force or not. The page opens four heavy endpoints at once and then polls, so a
1119
+ * force that ignored an in-flight child would fan out into six concurrent full-machine scans — the
1120
+ * cure becoming the disease. The in-flight guard has its own expiry (a child that has not exited in
1121
+ * five minutes is presumed wedged, not working) so one bad scan can never disable refreshing for the
1122
+ * life of the server.
1123
+ */
1124
+ let REFRESH_CHILD = null;
1125
+ const REFRESH_WEDGED_MS = 5 * 60 * 1000;
1126
+ function kickRefresh({ force = false } = {}) {
1127
+ if (process.env.RUVNET_CONSOLE_DISABLE_BACKGROUND_REFRESH === '1') return false;
1128
+ const now = Date.now();
1129
+ if (REFRESH_CHILD && now - LAST_REFRESH_KICK < REFRESH_WEDGED_MS) return false; // one at a time
1130
+ if (!force && now - LAST_REFRESH_KICK < 15000) return false; // debounce: at most one background refresh / 15s
1131
+ LAST_REFRESH_KICK = now;
1132
+ try {
1133
+ // cwd = the SERVED project, NOT REPO. This was `cwd: REPO` and it was a real console-honesty bug
1134
+ // (found 2026-07-24): the refresh child calls gatherState(process.cwd()), so with cwd=REPO it
1135
+ // recomputed PROJECT-SCOPED capabilities (memory-distillation, workflow-pattern-learning) for the
1136
+ // PLUGIN's own directory and wrote them to the shared cache — meaning that after the first 15s
1137
+ // refresh, /api/capabilities and /api/state reported the wrong directory's state for whatever
1138
+ // project the user actually opened. That is precisely the "looks on but isn't" failure this
1139
+ // console exists to prevent. The server's process.cwd() IS the served project, so the child must
1140
+ // inherit it. Machine-level caches (stack/activity/trust) don't depend on cwd, so this is safe
1141
+ // for them; it only fixes the project-scoped ones. NOT a change to the withhold-vs-recompute
1142
+ // contract (the 2026-07-17 outage) — only to which project the background compute is about.
1143
+ const child = spawn(process.execPath, [SELF, '--refresh-cache'], { detached: true, stdio: 'ignore', cwd: process.cwd() });
1144
+ REFRESH_CHILD = child;
1145
+ // unref() only releases the event-loop hold; these listeners still fire while the server lives.
1146
+ child.on('exit', () => { REFRESH_CHILD = null; });
1147
+ child.on('error', () => { REFRESH_CHILD = null; });
1148
+ child.unref(); // let it outlive this request; it writes the caches and exits on its own
1149
+ return true;
1150
+ } catch { REFRESH_CHILD = null; return false; /* a failed spawn just means the cache ages until the next kick */ }
1151
+ }
1152
+
1153
+ /**
1154
+ * COMPLETION SIGNAL — "it's live, take a look at your page."
1155
+ *
1156
+ * The cold path prints "first run — scanning… ~15 seconds", then the detached refresh child
1157
+ * (kickRefresh, stdio:'ignore') does the scanning and the parent NEVER learns when it finished — so
1158
+ * the page just quietly filled in and nothing in the terminal ever said "done". The owner asked for
1159
+ * exactly this, verbatim: "a countdown or something that then eventually tells them, okay it's live,
1160
+ * take a look at your page." This supplies it, honestly: "live" is defined as "the state cache the
1161
+ * page paints first now exists, written by THIS launch" — an observed fact, not a guess or a fixed
1162
+ * timer. We watch STATE_CACHE's mtime (the same file the refresh child writes and the page reads
1163
+ * first) and print one line when it lands, or a still-scanning line if it runs long. Never holds the
1164
+ * process open (unref) and never fires on the warm path — a warm re-open paints instantly and needs
1165
+ * no signal.
1166
+ */
1167
+ function announceWhenLive(url) {
1168
+ const startedAt = Date.now();
1169
+ const deadline = startedAt + 45000; // generous: a cold gatherState is ~13s; fleet longer
1170
+ let lastPrint = startedAt; // for the countdown ticks
1171
+ const timer = setInterval(() => {
1172
+ let landed = false;
1173
+ try { landed = fs.existsSync(STATE_CACHE) && fs.statSync(STATE_CACHE).mtimeMs >= startedAt - 1000; } catch { /* not yet */ }
1174
+ const now = Date.now();
1175
+ const waited = Math.round((now - startedAt) / 1000);
1176
+ if (landed) {
1177
+ clearInterval(timer);
1178
+ console.log(` ✓ it's live — open ${url} (or refresh the tab) to see your machine · ${waited}s\n`);
1179
+ } else if (now >= deadline) {
1180
+ clearInterval(timer);
1181
+ console.log(` still scanning after ${waited}s — the page fills in as data lands · ${url}`);
1182
+ } else if (now - lastPrint >= 2000) {
1183
+ // The owner asked for "a COUNTDOWN or something" — one start line then silence reads as a hang
1184
+ // on a slow scan. A tick every ~2s keeps the terminal alive (never a silent gap > 3s) and tells
1185
+ // the user the scan is still moving, until the honest "it's live" lands. Measured 2026-07-24: the
1186
+ // UX QE suite's max-dead-air WARN fired at ~3s with only start+end lines; a 2s tick on a 500ms
1187
+ // poll fires at ~2s (not the old 3s boundary), keeping every gap safely under the 3s bar.
1188
+ lastPrint = now;
1189
+ console.log(` …scanning (${waited}s)`);
1190
+ }
1191
+ }, 500);
1192
+ timer.unref?.(); // the server keeps the loop alive; never hold it open just for this announcer
1193
+ }
1194
+ // Serve <file>'s cached data instantly; on a cold miss, compute once via <compute>, seed the cache,
1195
+ // and serve that. Always kicks a background refresh so the next reader gets fresher data.
1196
+ /* HARD CEILING ON CACHED TRUTH.
1197
+ *
1198
+ * Measured 2026-07-24: this function served a capability cache stamped 2026-07-22T04:52Z — TWO DAYS
1199
+ * OLD — as the present-tense state of the user's machine. It reported "all 12 recorded lessons are
1200
+ * still candidates … none of them can influence anything yet" while the live store held 16 lessons
1201
+ * with 13 ratified and in force. The registry was right the whole time; the cache spoke over it.
1202
+ *
1203
+ * The defect was structural, not a wrong number: there was NO age limit. Any cache file that existed
1204
+ * was served, forever, with a background refresh that only ever helped the NEXT visitor. So a user
1205
+ * could open the console, read a confident sentence about their own machine, and be told something
1206
+ * false — which is the single failure this product cannot survive, because every other claim it
1207
+ * makes is then worth nothing.
1208
+ *
1209
+ * Stale data is still useful (a 49s cold scan is why the cache exists). What is not acceptable is
1210
+ * stale data WEARING THE COSTUME OF FRESH DATA.
1211
+ *
1212
+ * ── CORRECTED, SAME DAY, BEFORE IT REACHED ANYONE (Fable 5, 2026-07-24) ──────────────────────────
1213
+ *
1214
+ * The first version of this fix said: "over the ceiling, refuse to serve it and measure again,
1215
+ * IN-BAND, even though that costs the user a slow page. A slow honest page beats a fast lying one."
1216
+ *
1217
+ * That reintroduced the outage this very file documents forty lines above (see "the demo-hang fix",
1218
+ * 2026-07-17): every read-model here does multi-second SYNCHRONOUS work — gatherState ~13s,
1219
+ * gatherStack ~22s, scanFleet ~40s+ — and Node is single-threaded, so one inline compute freezes the
1220
+ * WHOLE server. `curl` saw 000 on roughly one request in three. The rule established then was
1221
+ * absolute: THE REQUEST HANDLER NEVER COMPUTES INLINE ONCE A CACHE EXISTS.
1222
+ *
1223
+ * And the console is opened occasionally, not polled — so "older than the ceiling" is the COMMON
1224
+ * case, not the rare one. The first version therefore made the documented hang the DEFAULT path,
1225
+ * while every other endpoint, POST and static file on the server froze behind it.
1226
+ *
1227
+ * The error was treating "honest" and "fast" as the only two options and picking honest. There is a
1228
+ * third, and this repo's own DDD-0011 had already named it: INV-4 makes WITHHOLDING a first-class
1229
+ * outcome, and the domain-event table says MeasurementExpired triggers "re-measure OR withhold."
1230
+ *
1231
+ * So: past the ceiling we serve the value with `stale: true` and its real age — the claim is
1232
+ * WITHDRAWN, not disguised — and kick the detached refresher. The renderer's job is to present a
1233
+ * withdrawn claim as withdrawn ("last measured 2 hours ago, re-measuring now"), never as current.
1234
+ * Honest AND non-blocking. Inline compute survives for exactly one case: no prior measurement
1235
+ * exists at all, where there is nothing to withhold and nothing older to serve. */
1236
+ const CACHE_MAX_AGE_MS = 15 * 60 * 1000;
1237
+
1238
+ /**
1239
+ * ONE ceiling, ONE shape, for every JSON response that carries measured machine state.
1240
+ *
1241
+ * GPT-5.6-Sol's review of the fix above, verbatim: "Four freshness policies is zero freshness
1242
+ * policies." serveCached() got a real ceiling while ACTIVITY_MACHINE_CACHE, TRUST_CACHE, and the
1243
+ * always-live /api/lessons read each kept (or lacked) a PRIVATE one — so two cards on the same page,
1244
+ * both "compliant" with their own rule, could disagree about whether a given age counts as current.
1245
+ * A user cannot tell which promise a card is making, which is the same failure as making none.
1246
+ *
1247
+ * Pure function of a timestamp the CALLER already took — never `Date.now()` computed in here — so it
1248
+ * can never paper over a stamp taken at the wrong moment (see gatherStack()/gatherActivity()/
1249
+ * gatherLessons() below, where THAT bug lived). A missing or unparseable `measuredAt` reads as
1250
+ * maximally stale, not silently fresh: a card that cannot prove its own age must never claim to be
1251
+ * current.
1252
+ */
1253
+ function freshnessOf(measuredAt) {
1254
+ const t = typeof measuredAt === 'string' ? Date.parse(measuredAt) : NaN;
1255
+ const ageMs = Number.isFinite(t) ? Date.now() - t : Infinity;
1256
+ return {
1257
+ measuredAt: typeof measuredAt === 'string' ? measuredAt : null,
1258
+ ageMs: Number.isFinite(ageMs) ? ageMs : null,
1259
+ stale: !(ageMs <= CACHE_MAX_AGE_MS),
1260
+ };
1261
+ }
1262
+
1263
+ /**
1264
+ * THE WARMING ANSWER — one shape, every endpoint, always instant.
1265
+ *
1266
+ * `warming: true` is a first-class response, not an error and not an empty payload: "no measurement
1267
+ * exists for this project yet; one is being taken; ask again in a moment." The page reads it and
1268
+ * KEEPS its skeletons — it must never render a warming answer as empty sections, which would say
1269
+ * "you have nothing configured" to someone whose machine simply has not been looked at yet.
1270
+ *
1271
+ * `stale: true` is deliberate and not a contradiction: there is no current measurement here, so
1272
+ * every consumer of the shared freshness contract must treat this as "do not present as fact."
1273
+ */
1274
+ function warmingAnswer(scopeKey, kicked) {
1275
+ return { warming: true, scope: scopeKey ?? null, kicked, fromCache: false, measuredAt: null, ageMs: null, stale: true };
1276
+ }
1277
+
1278
+ function serveCached(res, file, decorate = (d) => d, scopeKey = null) {
1279
+ const c = readJSON(file);
1280
+
1281
+ // WRONG PROJECT IS AS GOOD AS NO DATA. When a caller passes a scopeKey (the served project, for the
1282
+ // project-specific caches), a cached record computed for a DIFFERENT project must never be served —
1283
+ // that is the cross-project "looks on but isn't" bug (found 2026-07-24: two consoles, or one opened
1284
+ // in project B after A, sharing a user-level cache file).
1285
+ const scopeMismatch = scopeKey != null && (!c || c.scope !== scopeKey);
1286
+
1287
+ // ── COLD, OR THE WRONG PROJECT: ANSWER IN MICROSECONDS, MEASURE IN A CHILD ──────────────────────
1288
+ //
1289
+ // THE THREE MINUTES OF DEAD AIR (owner, 2026-07-26/27) ENDED ON THIS BRANCH. It used to read
1290
+ // "this one request eats the compute to seed the cache — the 2026-07-17 bargain", and the bargain
1291
+ // was mispriced on two counts:
1292
+ //
1293
+ // 1. COLD IS NOT RARE, IT IS THE SECOND PROJECT. The caches are single user-level files keyed by
1294
+ // one `scope`. Open the console in project B after project A and B is a scope mismatch —
1295
+ // i.e. cold — every time. "Cold once, then warm forever" was only ever true of a machine with
1296
+ // exactly one project on it.
1297
+ // 2. IT WAS NEVER ONE REQUEST. Node is single-threaded: an inline gather freezes the WHOLE
1298
+ // server — the static page, every other endpoint, the token check, all of it — and the page
1299
+ // opens four heavy endpoints at once, so the freezes queued end to end. Measured on this
1300
+ // file's own fixture at the moment of the fix: /api/stack alone answered cold in 23,640 ms.
1301
+ //
1302
+ // The child already computes every one of these caches (see `--refresh-cache` at the bottom of
1303
+ // this file) and writes each one the moment it is ready. So there is nothing for a request handler
1304
+ // to do here except say so and get out of the way. There is no longer a `compute` parameter to
1305
+ // pass: the guarantee is now structural rather than a rule someone has to remember, and the
1306
+ // duplicate compute closures the handlers used to carry (which had ALREADY drifted from the
1307
+ // child's copies once — see the MEMORY_CACHE note in --refresh-cache) are gone with it.
1308
+ if (!c || !c.data || scopeMismatch) {
1309
+ return sendJSON(res, 200, warmingAnswer(scopeKey, kickRefresh({ force: true })));
1310
+ }
1311
+
1312
+ // WARM — including over-ceiling. Never compute inline here; hand back what we measured, say when,
1313
+ // and let the detached child produce the next one.
1314
+ const fresh = freshnessOf(c.at);
1315
+ kickRefresh();
1316
+ return sendJSON(res, 200, {
1317
+ ...decorate(c.data),
1318
+ fromCache: true,
1319
+ cachedAt: c.at, // legacy alias — `fresh.measuredAt` (from `...fresh` below) is the contract name (DDD-0011)
1320
+ ...fresh,
1321
+ });
1322
+ }
1323
+ /**
1324
+ * @returns {boolean} whether anything was actually restored — the caller uses this to decide
1325
+ * whether to warn a first-run user that the page starts empty. It used to return undefined, so a
1326
+ * truthiness check on it was always false; reporting what it really did keeps the caller honest.
1327
+ */
1328
+ function loadConsoleCache() {
1329
+ let restored = false;
1330
+ try {
1331
+ const j = JSON.parse(fs.readFileSync(CONSOLE_CACHE_PATH, 'utf8'));
1332
+ if (j.activity && j.activity.at) { ACTIVITY_MACHINE_CACHE = j.activity; restored = true; }
1333
+ if (j.trust && j.trust.at) { TRUST_CACHE = j.trust; restored = true; }
1334
+ } catch { /* no cache yet — first ever boot */ }
1335
+ return restored;
1336
+ }
1337
+ function saveConsoleCache() {
1338
+ // Same torn-write risk as every other cache in this file (Task 4) — routed through the same atomic
1339
+ // helper rather than its own bare writeFileSync.
1340
+ //
1341
+ // MERGE, DON'T CLOBBER (2026-07-26). This file holds TWO independent measurements and, since the
1342
+ // fleet scan moved off the request path, TWO independent writers: the server (which has a TRUST_CACHE
1343
+ // and, after adoptFleetFromDisk, a fleet) and the detached --refresh-cache child (which scans the
1344
+ // fleet and has no trust at all). Writing the in-memory pair wholesale meant the child's fleet write
1345
+ // would have blanked the trust card's cache to null every single refresh — the same "a cache writer
1346
+ // that knew about half the payload" defect this file has already paid for once, in --refresh-cache's
1347
+ // MEMORY_CACHE. Each half now falls back to what is already on disk.
1348
+ try {
1349
+ const prev = readJSON(CONSOLE_CACHE_PATH) || {};
1350
+ atomicWriteJSON(CONSOLE_CACHE_PATH, {
1351
+ activity: ACTIVITY_MACHINE_CACHE ?? prev.activity ?? null,
1352
+ trust: TRUST_CACHE ?? prev.trust ?? null,
1353
+ });
1354
+ } catch { /* cache persistence must never break a read */ }
1355
+ }
1356
+
1357
+ /**
1358
+ * Pick up a fleet scan performed by ANOTHER process (the detached --refresh-cache child).
1359
+ *
1360
+ * STRICTLY NEWER ONLY. Adopting an equal-or-older record would let a slow child's write walk a live
1361
+ * server backwards to a scan it has already superseded. Cheap: one small JSON read, no scan.
1362
+ */
1363
+ function adoptFleetFromDisk() {
1364
+ try {
1365
+ const j = readJSON(CONSOLE_CACHE_PATH);
1366
+ if (j && j.activity && j.activity.at && (!ACTIVITY_MACHINE_CACHE || j.activity.at > ACTIVITY_MACHINE_CACHE.at)) {
1367
+ ACTIVITY_MACHINE_CACHE = j.activity;
1368
+ }
1369
+ } catch { /* no cache yet — the caller kicks a child and reports `warming` */ }
1370
+ }
1371
+ function refreshFleetCache() {
1372
+ const projects = [];
1373
+ let total = 0;
1374
+ const seen = new Set();
1375
+ // Scan every candidate root (issue #19) — this is what made machine-wide totals read 0 on a
1376
+ // machine whose projects live under ~/source instead of ~/Code.
1377
+ for (const root of candidateRoots()) {
1378
+ for (const s of findMemoryStores(root)) {
1379
+ const resolved = path.resolve(s.project);
1380
+ if (seen.has(resolved)) continue; // a project visible under two roots (e.g. a symlink) counts once
1381
+ seen.add(resolved);
1382
+ const n = Number(robustRead(s.db, "SELECT COUNT(*) FROM memory_entries WHERE status='active'").value || 0);
1383
+ if (n > 0) {
1384
+ // MAX(updated_at) = when this project was last actively worked — the memory store doubles
1385
+ // as the attention signal (relevance ordering, Stuart 2026-07-15).
1386
+ const lastTouched = Number(robustRead(s.db, 'SELECT MAX(updated_at) FROM memory_entries').value || 0);
1387
+ // rel = the root-relative path — the SAME key reconcile:<id> recommendations use (wiringSurvey
1388
+ // computes projName the same way, relative to whichever root the project was found under).
1389
+ projects.push({ name: path.basename(s.project), rel: path.relative(root, s.project), memories: n, lastTouched });
1390
+ total += n;
1391
+ }
1392
+ }
1393
+ }
1394
+ projects.sort((a, b) => b.memories - a.memories);
1395
+ ACTIVITY_MACHINE_CACHE = { at: Date.now(), projects, totalMemories: total };
1396
+ saveConsoleCache();
1397
+ }
1398
+ function findMemoryStores(root) {
1399
+ const out = [];
1400
+ const walk = (dir, depth) => {
1401
+ if (depth > 3) return;
1402
+ let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
1403
+ for (const e of ents) {
1404
+ if (!e.isDirectory()) continue;
1405
+ const p = path.join(dir, e.name);
1406
+ if (VENDOR.some((m) => (p + '/').includes(m))) continue;
1407
+ if (e.name === '.swarm') {
1408
+ if (fs.existsSync(path.join(p, 'memory.db'))) out.push({ project: dir, db: path.join(p, 'memory.db') });
1409
+ continue;
1410
+ }
1411
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
1412
+ walk(p, depth + 1);
1413
+ }
1414
+ };
1415
+ walk(root, 0);
1416
+ return out;
1417
+ }
1418
+ function gatherActivity(cwd) {
1419
+ const project = fs.existsSync(path.join(cwd, '.swarm/memory.db')) ? cwd : REPO;
1420
+ const db = path.join(project, '.swarm/memory.db');
1421
+ if (!fs.existsSync(db)) {
1422
+ // No store to read — the existence check above IS the entire measurement, so it IS the
1423
+ // observation instant. Stamped here, not with a value taken before the check ran.
1424
+ const measuredAt = new Date().toISOString();
1425
+ return { generatedAt: measuredAt, project: path.basename(project), hasStore: false, ...freshnessOf(measuredAt) };
1426
+ }
1427
+ const out = { project: path.basename(project), hasStore: true };
1428
+ const rows = (sql) => robustReadJSON(db, sql).rows;
1429
+ out.totals = {
1430
+ memories: Number(robustRead(db, "SELECT COUNT(*) FROM memory_entries WHERE status='active'").value || 0),
1431
+ lessons: Number(robustRead(db, "SELECT COUNT(*) FROM memory_entries WHERE namespace='lessons' AND status='active'").value || 0),
1432
+ };
1433
+ out.lessons = rows("SELECT key, access_count, date(created_at/1000,'unixepoch') AS learned, substr(content,1,600) AS excerpt FROM memory_entries WHERE namespace='lessons' AND status='active' ORDER BY created_at DESC");
1434
+ out.recent = rows("SELECT key, namespace, type, datetime(updated_at/1000,'unixepoch','localtime') AS at FROM memory_entries WHERE status='active' ORDER BY updated_at DESC LIMIT 18");
1435
+ out.breakdown = rows("SELECT namespace, COUNT(*) AS n FROM memory_entries WHERE status='active' GROUP BY namespace ORDER BY n DESC");
1436
+ out.growth = rows("SELECT date(created_at/1000,'unixepoch') AS day, COUNT(*) AS n FROM memory_entries WHERE status='active' GROUP BY 1 ORDER BY 1");
1437
+ // TASK 3: stamped HERE, after every sqlite3 shell-out above has actually returned — not at the top
1438
+ // of this function (the previous `out = { generatedAt: new Date().toISOString(), ... }`), which
1439
+ // dated the whole response before a single row of it had been read.
1440
+ const measuredAt = new Date().toISOString();
1441
+
1442
+ // MACHINE-WIDE FLEET SCAN — NEVER ON THIS THREAD (RVBC-INSTANT-SPEC #5).
1443
+ //
1444
+ // This walk opens 100+ SQLite stores across every scan root: 40s+ on a real machine. It used to run
1445
+ // here two ways, both of them on the server's only thread:
1446
+ // • `refreshFleetCache()` outright, whenever no fleet had ever been scanned — i.e. on the very
1447
+ // first open, the one moment a new user is watching a blank tab;
1448
+ // • `setImmediate(refreshFleetCache)` when the fleet was over the ceiling. setImmediate is not
1449
+ // backgrounding — deferring synchronous work still blocks the loop when it finally runs, one
1450
+ // tick later, which is a distinction this file learned the hard way in 2026-07-17 and then
1451
+ // re-introduced here.
1452
+ // Both are now the detached child's job. A request only ever READS.
1453
+ //
1454
+ // adoptFleetFromDisk() is what closes the loop: the child is a separate process, so it cannot
1455
+ // hand this one an in-memory result — it writes console-cache.json and this picks it up, strictly
1456
+ // newer only, on the next read. Without it the server would sit on `warming` forever.
1457
+ adoptFleetFromDisk();
1458
+ if (!ACTIVITY_MACHINE_CACHE) {
1459
+ // NOTHING TO WITHHOLD AND NOTHING TO FAKE: say it is being measured, and say nothing else.
1460
+ // `totalMemories: null` (not 0) on purpose — a zero here would read as "no memories anywhere on
1461
+ // your machine", which is the product's cardinal lie, told about the one number this card exists
1462
+ // for. The frontend renders `warming` as a skeleton, never as an empty fleet.
1463
+ kickRefresh();
1464
+ out.machine = { warming: true, projects: [], totalMemories: null, scannedAt: null, measuredAt: null, ageMs: null, stale: true };
1465
+ return { generatedAt: measuredAt, ...out, ...freshnessOf(measuredAt) };
1466
+ }
1467
+ if (Date.now() - ACTIVITY_MACHINE_CACHE.at > CACHE_MAX_AGE_MS) kickRefresh();
1468
+ const machineMeasuredAt = new Date(ACTIVITY_MACHINE_CACHE.at).toISOString();
1469
+ out.machine = {
1470
+ projects: ACTIVITY_MACHINE_CACHE.projects,
1471
+ totalMemories: ACTIVITY_MACHINE_CACHE.totalMemories,
1472
+ scannedAt: machineMeasuredAt, // legacy field, unchanged shape
1473
+ ...freshnessOf(machineMeasuredAt),
1474
+ };
1475
+ return { generatedAt: measuredAt, ...out, ...freshnessOf(measuredAt) };
1476
+ }
1477
+
1478
+ // ── Router engine read-model ─────────────────────────────────────────────────────────────────────
1479
+ // 2026-07-16 (Stuart: "if MetaHarness does all of this then let it do it, but let us add user-
1480
+ // selected constraints"). This panel previously displayed router-optimizer.mjs — a parallel,
1481
+ // subscription-blind re-derivation of routing strategy that bypassed the REAL engine wired on
1482
+ // 2026-07-13 (model-router-engine.mjs → @metaharness/router). The replica is deleted. This
1483
+ // read-model contains ZERO routing logic: it shows the engine's own inputs (catalog × this user's
1484
+ // profile → effective marginal prices — the ONLY thing the local layer owns) and the engine's own
1485
+ // recent decisions from its append-only log. Nothing here can disagree with what actually routes.
1486
+ function gatherRouterEngine() {
1487
+ const profile = engineProfile();
1488
+ const candidates = applyProfile(engineCatalog(), profile);
1489
+ const prices = effectivePrices(candidates, profile);
1490
+ const { rows, unusable } = loadLabelledRows();
1491
+ const installed = fs.existsSync(path.join(__dirname, '..', 'node_modules', '@metaharness', 'router', 'package.json'));
1492
+ const list = (c) => (typeof c.costPerMTok === 'number' ? c.costPerMTok
1493
+ : c.costPerMTok && typeof c.costPerMTok.in === 'number' ? +(((c.costPerMTok.in + c.costPerMTok.out) / 2).toFixed(3))
1494
+ : null);
1495
+ const decisions = [];
1496
+ try {
1497
+ const log = path.join(os.homedir(), '.claude', 'metaharness', 'routing-decisions.jsonl');
1498
+ const lines = fs.readFileSync(log, 'utf8').trim().split('\n');
1499
+ for (const l of lines.slice(-8).reverse()) {
1500
+ try { const d = JSON.parse(l); decisions.push({ ts: d.ts, model: d.model, tier: d.tier, routedBy: d.routedBy, reason: d.reason }); } catch { /* skip bad line */ }
1501
+ }
1502
+ } catch { /* no decisions yet */ }
1503
+ const cfg = readJSON(CONFIG_PATH) || {};
1504
+ // User-constraint detection (Brain-side by design — a fact about THIS user, not routing logic):
1505
+ // an OpenRouter key decides whether metered cross-provider candidates are even reachable.
1506
+ let openrouterKey = !!process.env.OPENROUTER_API_KEY;
1507
+ if (!openrouterKey) openrouterKey = !!(cfg.openrouterKey && String(cfg.openrouterKey).length > 8);
1508
+ // House (issue #21): three mechanisms used to disagree — Settings wrote config.json's `provider`,
1509
+ // but the chip strip derived "yours" from whichever pool candidate happened to be
1510
+ // subscriptionCovered first, sourced from profile.json (a file nothing in the console writes). The
1511
+ // user's Settings choice is now the single source of truth, via the SAME detectProvider() the
1512
+ // savings.utilization frontier calc already uses (config → env → catalog default) — so this and
1513
+ // the frontier calc can never disagree either.
1514
+ let house, providerKeys = {};
1515
+ try {
1516
+ const hcat = loadCatalog();
1517
+ house = detectProvider(hcat, { provider: cfg.provider });
1518
+ // Per-provider credential presence (issue #24): the old chip strip hardcoded "not detected" for
1519
+ // every provider that wasn't the current house, so it could never tell "not your house" from "no
1520
+ // key found". Read each provider's real detect_env vars — minus the CLAUDECODE / CLAUDE_CODE_ENTRYPOINT
1521
+ // run-context markers (which are not credentials), exactly as detectProvider() itself filters them —
1522
+ // so the UI's "key found / not found" is now true instead of decorative.
1523
+ const IGNORE_ENV = new Set(['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT']);
1524
+ for (const [name, p] of Object.entries(hcat.providers || {})) {
1525
+ providerKeys[name] = (p.detect_env || []).some((k) => !IGNORE_ENV.has(k) && !!process.env[k]);
1526
+ }
1527
+ } catch { house = { provider: cfg.provider && cfg.provider !== 'auto' ? cfg.provider : 'anthropic', source: 'default' }; }
1528
+ return {
1529
+ engine: {
1530
+ package: '@metaharness/router', installed,
1531
+ labels: rows.length, needed: MIN_LABELS, unusableLabels: unusable,
1532
+ mode: !installed ? 'UNAVAILABLE' : rows.length >= MIN_LABELS ? 'LEARNED' : 'COLD-START',
1533
+ outcomesLog: OUTCOMES.replace(os.homedir(), '~'),
1534
+ },
1535
+ keys: { openrouter: openrouterKey, ...providerKeys },
1536
+ // Paid seats, found at USER level. `keys` above is env-var API keys only, which is exactly why a
1537
+ // user with ChatGPT Max and Claude Max read as "auto" — neither plan puts a key in the
1538
+ // environment. These two fields are what let the UI say "you already have this" instead of
1539
+ // asking someone to paste a credential they are already paying not to need.
1540
+ subscriptions: detectSubscriptions(),
1541
+ preferredSeat: preferredSeat(detectSubscriptions()),
1542
+ profile: { present: !!profile, path: PROFILE_PATH.replace(os.homedir(), '~') },
1543
+ catalogSource: engineCatalogSource(), // 'catalog' | 'built-in-fallback' — so the UI never calls the stub a real catalog
1544
+ house,
1545
+ pool: candidates
1546
+ .map((c) => ({
1547
+ id: c.id, provider: c.provider, tier: c.tier || null, harness: c.harness || [],
1548
+ marginalPerMTok: Number.isFinite(prices[c.id]) ? prices[c.id] : null,
1549
+ listPerMTok: list(c),
1550
+ // From the profile fact, never inferred from a $0 price — a mispriced metered model must
1551
+ // not display as "yours" (exactly the bug this read-model caught on 2026-07-16).
1552
+ subscriptionCovered: (c.subscription || []).some((h) => profile?.harnesses?.[h]?.subscription === true),
1553
+ verified: c.verified || null, note: c.note || null,
1554
+ }))
1555
+ .sort((a, b) => (a.marginalPerMTok ?? Infinity) - (b.marginalPerMTok ?? Infinity)),
1556
+ decisions,
1557
+ };
1558
+ }
1559
+
1560
+ // ── Trust & provenance read-model (v3.3 preview; ADR-0013 follow-on) ─────────────────────────────
1561
+ // Two measurements are REAL today: the release bundle's published sha256, read live from the latest
1562
+ // GitHub release's .sha256 asset (read-only metadata — the same class of network touch as the stack
1563
+ // registry audit), and the local CycloneDX SBOM at sbom/ruvnet-brain.cdx.json (v3.3, `npm run sbom`)
1564
+ // when it has been generated on this machine. Install channel is read from the plugin cache on disk.
1565
+ // Advisor Mode is v3.3 and is reported as an honest empty state by the frontend — this read-model
1566
+ // never fabricates.
1567
+ const TRUST_REPO = 'stuinfla/ruvnet-brain';
1568
+ const SBOM_PATH = path.join(REPO, 'sbom', 'ruvnet-brain.cdx.json');
1569
+ // Local-file read, no network: the SBOM is generated by `npm run sbom` (CycloneDX 1.6 via
1570
+ // @cyclonedx/cyclonedx-npm, --omit dev) and committed alongside releases. Absent = honest empty
1571
+ // state, matching the "coming v3.3" language already shipped on the console card.
1572
+ function readSbom() {
1573
+ const rel = path.relative(REPO, SBOM_PATH);
1574
+ if (!fs.existsSync(SBOM_PATH)) return { present: false, path: rel };
1575
+ try {
1576
+ const j = JSON.parse(fs.readFileSync(SBOM_PATH, 'utf8'));
1577
+ const components = Array.isArray(j.components) ? j.components : [];
1578
+ return {
1579
+ present: true,
1580
+ path: rel,
1581
+ componentCount: components.length,
1582
+ specVersion: j.specVersion || null,
1583
+ bomFormat: j.bomFormat || null,
1584
+ generatedAt: (j.metadata && j.metadata.timestamp) || null,
1585
+ mainComponent: (j.metadata && j.metadata.component && j.metadata.component.name) || null,
1586
+ mainVersion: (j.metadata && j.metadata.component && j.metadata.component.version) || null,
1587
+ };
1588
+ } catch (e) {
1589
+ return { present: false, path: rel, error: String((e && e.message) || e) };
1590
+ }
1591
+ }
1592
+ let TRUST_CACHE = null; // successful release reads cached; failures are never cached
1593
+ let TRUST_REFRESHING = false;
1594
+ let LAST_TRUST_KICK = 0;
1595
+ /**
1596
+ * Background refresh for TRUST_CACHE, fired only once we are PAST CACHE_MAX_AGE_MS — see gatherTrust()
1597
+ * below. Debounced the same way kickRefresh() debounces the other caches' detached child, so a console
1598
+ * tab left open and polling /api/trust every few seconds cannot turn into a GitHub API hammer.
1599
+ *
1600
+ * Not a detached child process like kickRefresh(): fetchReleaseDigest() is network I/O, not a
1601
+ * synchronous CPU-bound scan, so it does not block the event loop the way gatherStack()/scanFleet() do
1602
+ * — an un-awaited fetch() already satisfies "never block the request that asked".
1603
+ */
1604
+ function kickTrustRefresh() {
1605
+ const now = Date.now();
1606
+ if (TRUST_REFRESHING || now - LAST_TRUST_KICK < 15000) return;
1607
+ LAST_TRUST_KICK = now;
1608
+ TRUST_REFRESHING = true;
1609
+ fetchReleaseDigest()
1610
+ .then((release) => {
1611
+ if (release.ok) {
1612
+ const generatedAt = new Date().toISOString();
1613
+ TRUST_CACHE = { at: Date.parse(generatedAt), data: { generatedAt, release } };
1614
+ saveConsoleCache();
1615
+ }
1616
+ })
1617
+ .catch(() => { /* keep serving the last good measurement — a failed refresh must not erase it */ })
1618
+ .finally(() => { TRUST_REFRESHING = false; });
1619
+ }
1620
+ async function fetchReleaseDigest() {
1621
+ const ua = { 'user-agent': 'ruvnet-brain-console' };
1622
+ const rel = await fetch(`https://api.github.com/repos/${TRUST_REPO}/releases/latest`,
1623
+ { headers: { ...ua, accept: 'application/vnd.github+json' }, signal: AbortSignal.timeout(8000) });
1624
+ if (!rel.ok) throw new Error(`GitHub answered HTTP ${rel.status}`);
1625
+ const j = await rel.json();
1626
+ const assets = Array.isArray(j.assets) ? j.assets : [];
1627
+ const shaAsset = assets.find((a) => String(a.name).endsWith('.sha256'));
1628
+ const sigAsset = assets.find((a) => String(a.name).endsWith('.sig'));
1629
+ let sha256 = null;
1630
+ let file = null;
1631
+ if (shaAsset) {
1632
+ const r2 = await fetch(shaAsset.browser_download_url, { headers: ua, redirect: 'follow', signal: AbortSignal.timeout(8000) });
1633
+ if (r2.ok) {
1634
+ const m = (await r2.text()).trim().match(/^([0-9a-f]{64})\s+\*?(\S+)/i);
1635
+ if (m) { sha256 = m[1]; file = m[2]; }
1636
+ }
1637
+ }
1638
+ return {
1639
+ ok: !!sha256,
1640
+ tag: j.tag_name || null,
1641
+ publishedAt: j.published_at || null,
1642
+ asset: file || (shaAsset ? String(shaAsset.name).replace(/\.sha256$/, '') : null),
1643
+ sha256,
1644
+ sig: !!sigAsset,
1645
+ source: `github.com/${TRUST_REPO}/releases/latest`,
1646
+ };
1647
+ }
1648
+ // The header wears the product version openly (owner, 2026-07-24: "put the version of RuvNet-Brain
1649
+ // in the heading of the console"). Plugin-cache dir first — the truth on installed machines — then
1650
+ // the repo's plugin.json for dev checkouts. null hides the chip rather than guessing.
1651
+ function brainVersionOnDisk() {
1652
+ try { const v = readInstallChannel().version; if (v) return String(v).replace(/^v/, ''); } catch { /* fall through */ }
1653
+ try { return getVersion(); } catch { return null; }
1654
+ }
1655
+ function readInstallChannel() {
1656
+ const reg = readJSON(path.join(HOME, '.claude/plugins/installed_plugins.json'));
1657
+ const entries = reg && reg.plugins && reg.plugins['ruvnet-brain@ruvnet-brain'];
1658
+ const e = Array.isArray(entries) ? entries[0] : null;
1659
+ if (!e || !e.installPath || !fs.existsSync(e.installPath)) return { installed: false };
1660
+ const km = readJSON(path.join(HOME, '.claude/plugins/known_marketplaces.json'));
1661
+ const src = km && km['ruvnet-brain'] && km['ruvnet-brain'].source;
1662
+ const pinned = !!(src && (src.ref || src.tag || src.commit)); // no pin recorded → tracking latest
1663
+ return {
1664
+ installed: true,
1665
+ version: path.basename(e.installPath) || e.version || null, // the plugin cache version dir IS the truth
1666
+ channel: pinned ? 'pinned' : 'latest',
1667
+ lastUpdated: e.lastUpdated || null,
1668
+ cacheDir: String(e.installPath).replace(HOME, '~'),
1669
+ repo: (src && src.repo) || null,
1670
+ };
1671
+ }
1672
+ /**
1673
+ * TASK 1: TRUST_CACHE now obeys the SAME ceiling (CACHE_MAX_AGE_MS) as every other cache in this
1674
+ * file, and past it we WITHHOLD rather than recompute in-band.
1675
+ *
1676
+ * The previous version's own age check (a bespoke 600000, not CACHE_MAX_AGE_MS) fell straight through
1677
+ * to `await fetchReleaseDigest()` — a GitHub network round-trip with an 8s timeout — INSIDE the
1678
+ * request that asked. That is precisely the in-band-recompute-past-the-ceiling pattern the big
1679
+ * comment above serveCached() documents as the reintroduced 2026-07-17 outage, just for a network
1680
+ * call instead of a synchronous scan. It also meant a cache RESTORED from disk at boot
1681
+ * (loadConsoleCache()), which is virtually always older than 10 minutes by the time anyone opens the
1682
+ * console, hit that path on its very first request — "restored from disk with no age check" in
1683
+ * practice, because the check that did exist only ever triggered a blocking recompute rather than an
1684
+ * honest stale-serve.
1685
+ */
1686
+ async function gatherTrust() {
1687
+ // COLD ONLY: no successful release read has ever landed, so there is nothing to withhold or serve
1688
+ // stale — the one exception serveCached() itself carves out for its own caches.
1689
+ if (!TRUST_CACHE) {
1690
+ let release;
1691
+ try { release = await fetchReleaseDigest(); }
1692
+ catch (e) { release = { ok: false, error: String((e && e.message) || e) }; }
1693
+ // TASK 3: stamped AFTER the network call above resolves, not before it — the observation instant.
1694
+ const generatedAt = new Date().toISOString();
1695
+ const data = { generatedAt, release };
1696
+ if (release.ok) { TRUST_CACHE = { at: Date.parse(generatedAt), data }; saveConsoleCache(); }
1697
+ return { ...data, channel: readInstallChannel(), sbom: readSbom(), ...freshnessOf(generatedAt) };
1698
+ }
1699
+
1700
+ // WARM — including over-ceiling. Never await the network here; hand back what we measured, say
1701
+ // when, and let a debounced background refresh (never THIS request) produce the next one.
1702
+ const fresh = freshnessOf(TRUST_CACHE.data.generatedAt);
1703
+ if (fresh.stale) kickTrustRefresh();
1704
+ // Disk facts stay live even when the release read is served from cache — the SBOM file and install
1705
+ // channel can change (a fresh `npm run sbom`, a plugin update) between two calls inside the ceiling.
1706
+ return { ...TRUST_CACHE.data, channel: readInstallChannel(), sbom: readSbom(), ...fresh };
1707
+ }
1708
+
1709
+ // ── Assemble the read-models ─────────────────────────────────────────────────────────────────────
1710
+ /**
1711
+ * PAID SUBSCRIPTIONS, detected at USER level — not project level, not from environment variables.
1712
+ *
1713
+ * WHY THIS EXISTS. A user with BOTH a ChatGPT Max plan and a Claude Max plan showed up as "auto",
1714
+ * because the only thing "auto" ever looked at was `detect_env` — API keys in environment
1715
+ * variables. Verified on a real machine 2026-07-20: `~/.codex/auth.json` reads
1716
+ * `auth_mode: "chatgpt"`, `OPENAI_API_KEY: null`, with live OAuth tokens. A genuine, paid,
1717
+ * authenticated ChatGPT subscription with no API key anywhere — completely invisible to the old
1718
+ * detector. Claude's own Max session is worse: on macOS it lives in the LOGIN KEYCHAIN, so there is
1719
+ * no file to find at all.
1720
+ *
1721
+ * WHY IT MATTERS BEYOND A WRONG LABEL. A subscription is already paid for at a flat rate; an API
1722
+ * key bills per token. Routing to a key while an authenticated seat sits idle spends money the user
1723
+ * has already spent. So a subscription always outranks a key — the key is the LAST resort, never
1724
+ * the default. (Same principle as the meta-proxy's Passthrough plane: use the subscription you are
1725
+ * already paying for, and treat metered capacity as the fallback.)
1726
+ *
1727
+ * SECRETS ARE NEVER READ. For the keychain we ask only whether the ITEM EXISTS — never `-w`, which
1728
+ * would print the secret. For token files we check for the presence of a field, never its value.
1729
+ * Nothing here is logged, transmitted, or written anywhere.
1730
+ *
1731
+ * @returns {Record<string, {subscription: boolean, apiKey: boolean, how: string}>}
1732
+ */
1733
+ export function detectSubscriptions() {
1734
+ const home = os.homedir();
1735
+ const out = {};
1736
+ const seat = (provider, subscription, apiKey, how) => { out[provider] = { subscription, apiKey, how }; };
1737
+
1738
+ // ── Anthropic (Claude Pro/Max) ────────────────────────────────────────────────────────────────
1739
+ // macOS keeps the Claude Code OAuth session in the login keychain; Linux/Windows use a file.
1740
+ // Existence only — `security find-generic-password` WITHOUT -w prints metadata, never the secret.
1741
+ let claudeSub = false; let claudeHow = 'not found';
1742
+ const credFile = path.join(home, '.claude', '.credentials.json');
1743
+ if (fs.existsSync(credFile)) { claudeSub = true; claudeHow = '~/.claude/.credentials.json'; }
1744
+ else if (process.platform === 'darwin') {
1745
+ try {
1746
+ const r = spawnSync('security', ['find-generic-password', '-s', 'Claude Code-credentials'], { encoding: 'utf8', timeout: 5000 });
1747
+ if (r.status === 0) { claudeSub = true; claudeHow = 'macOS login keychain'; }
1748
+ } catch { /* absent or locked — treated as not found, never as an error */ }
1749
+ }
1750
+ seat('anthropic', claudeSub, !!process.env.ANTHROPIC_API_KEY, claudeHow);
1751
+
1752
+ // ── OpenAI / ChatGPT (via the Codex CLI) ──────────────────────────────────────────────────────
1753
+ // auth_mode === 'chatgpt' means a ChatGPT plan is signed in; 'apikey' means metered billing.
1754
+ let oaSub = false; let oaKey = !!process.env.OPENAI_API_KEY; let oaHow = 'not found';
1755
+ const codexAuth = path.join(home, '.codex', 'auth.json');
1756
+ if (fs.existsSync(codexAuth)) {
1757
+ try {
1758
+ const j = JSON.parse(fs.readFileSync(codexAuth, 'utf8'));
1759
+ if (j.auth_mode === 'chatgpt' || (j.tokens && j.tokens.access_token)) { oaSub = true; oaHow = '~/.codex/auth.json (ChatGPT plan)'; }
1760
+ if (j.OPENAI_API_KEY) oaKey = true;
1761
+ } catch { /* unreadable/corrupt — report nothing rather than guess */ }
1762
+ }
1763
+ seat('openai', oaSub, oaKey, oaHow);
1764
+ // Codex is the same seat as the ChatGPT plan above, surfaced separately because the UI lists it
1765
+ // as its own "house" — one subscription, two labels, so never counted as two entitlements.
1766
+ seat('codex', oaSub, oaKey, oaHow === 'not found' ? 'not found' : `${oaHow} — same seat as OpenAI`);
1767
+
1768
+ // ── Google (Gemini) ───────────────────────────────────────────────────────────────────────────
1769
+ // gcloud ADC is a real authenticated credential; a bare ~/.gemini directory is NOT — it holds
1770
+ // settings and skills and exists on machines that were never signed in. Claiming a subscription
1771
+ // from a config folder would be exactly the fabricated-status this project forbids.
1772
+ const adc = path.join(home, '.config', 'gcloud', 'application_default_credentials.json');
1773
+ const gSub = fs.existsSync(adc);
1774
+ seat('google', gSub, !!(process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY), gSub ? '~/.config/gcloud (ADC)' : 'not found');
1775
+
1776
+ // ── xAI (Grok) ────────────────────────────────────────────────────────────────────────────────
1777
+ // No CLI writes a discoverable subscription credential today. Say so honestly rather than
1778
+ // inventing a detector that always returns false and looks like a real check.
1779
+ seat('xai', false, !!process.env.XAI_API_KEY, 'no detectable subscription credential');
1780
+
1781
+ return out;
1782
+ }
1783
+
1784
+ /**
1785
+ * What to actually USE, given what was found. Subscription first, always.
1786
+ * @returns {{provider: string|null, basis: 'subscription'|'api-key'|'none', detail: string}}
1787
+ */
1788
+ export function preferredSeat(subs) {
1789
+ const order = ['anthropic', 'openai', 'codex', 'google', 'xai'];
1790
+ for (const p of order) if (subs[p]?.subscription) return { provider: p, basis: 'subscription', detail: subs[p].how };
1791
+ for (const p of order) if (subs[p]?.apiKey) return { provider: p, basis: 'api-key', detail: 'environment variable' };
1792
+ return { provider: null, basis: 'none', detail: 'nothing detected' };
1793
+ }
1794
+
1795
+ function gatherState(cwd, { fleet = true } = {}) {
1796
+ const wiring = wiringSurvey();
1797
+ const memory = gatherMemory(cwd, { fleet });
1798
+ try { memory.learnings = learnings(); } catch { memory.learnings = null; }
1799
+ const savings = gatherSavings();
1800
+ const cfgNow = readJSON(CONFIG_PATH) || {};
1801
+ // issue #20: the Savings card's "Turn on smart routing" CTA must reflect what was actually saved —
1802
+ // same tri-state rule gatherConfig() uses below, so this and the Settings tab never disagree. null
1803
+ // means never chosen, and the card must say that rather than paint a green ON chip over a default.
1804
+ savings.routing = cfgNow.routing === 'off' ? 'off' : cfgNow.routing === 'auto' ? 'auto' : null;
1805
+ // A PREFERENCE IS NOT A CAPABILITY. Saving routing:'auto' records an intention; it does not install
1806
+ // agentic-flow, and the Savings card claiming "Smart routing: ON" while the Capabilities card said
1807
+ // "not installed" — in the same render — was the contradiction that made the whole page untrustworthy.
1808
+ // The card now carries the same measurement the capability row uses, so the two cannot disagree.
1809
+ savings.routingInstalled = fs.existsSync(path.join(HOME, '.npm-global/bin/agentic-flow'));
1810
+ try { savings.routerEngine = gatherRouterEngine(); } catch { savings.routerEngine = null; }
1811
+ try {
1812
+ const cat = loadCatalog();
1813
+ const det = detectProvider(cat, { provider: cfgNow.provider });
1814
+ savings.utilization = utilization({ frontier: frontierFor(cat, det.provider) });
1815
+ } catch { try { savings.utilization = utilization({}); } catch { savings.utilization = null; } }
1816
+ const config = gatherConfig();
1817
+ const userSettings = gatherAdvocacy();
1818
+ // ADR-054: its own section, never folded into userSettings — see gatherBrainPower()'s header for
1819
+ // the three reasons. A failure here must not blank the page: the switch's own surface degrading is
1820
+ // no reason to lose the rest of the machine's state.
1821
+ let brainPower = null;
1822
+ try { brainPower = gatherBrainPower(); } catch { brainPower = null; }
1823
+ let gates = null;
1824
+ try { gates = gatesSurvey({ repo: REPO }); } catch { gates = null; }
1825
+ const recommendations = buildWiringRecommendations({ sites: wiring.sites });
1826
+ // The capability ⇄ recommendation bridge (see computeCapabilities()'s recId stamp, same idea here):
1827
+ // this is what makes `#rec-enable:memory-distillation` actually exist in the DOM for jumpToRec to
1828
+ // scroll to. Advisory-only, so a bug here must degrade to "no capability recs offered", never break
1829
+ // the rest of /api/state.
1830
+ try {
1831
+ const capRows = capabilityAuditAll({ project: cwd });
1832
+ recommendations.push(...buildCapabilityRecommendations({ capabilities: capRows }));
1833
+ } catch { /* an advisory surface must never break state */ }
1834
+ // Relevance order (never alphabetical/walk-order): machine-wide first, then projects by when
1835
+ // the user last actually worked in them — read from each project's own memory store.
1836
+ {
1837
+ const touched = {};
1838
+ for (const p of (ACTIVITY_MACHINE_CACHE && ACTIVITY_MACHINE_CACHE.projects) || []) {
1839
+ if (p.rel) touched[p.rel] = p.lastTouched || 0;
1840
+ touched[p.name] = Math.max(touched[p.name] || 0, p.lastTouched || 0);
1841
+ }
1842
+ const rank = (r) => r.id.startsWith('reconcile:') ? (touched[r.id.slice('reconcile:'.length)] || 0) : Number.MAX_SAFE_INTEGER;
1843
+ recommendations.sort((a, b) => rank(b) - rank(a));
1844
+ }
1845
+ // A cheap fingerprint of the state the page is about to render. The page echoes it back on apply;
1846
+ // apply's authoritative guard is still per-recommendation re-verification (currentValidIds), but
1847
+ // this lets the UI reason about staleness too.
1848
+ const preStateHash = crypto.createHash('sha1')
1849
+ .update(JSON.stringify({ recs: recommendations.map((r) => r.id).sort(), wiring: wiring.summary }))
1850
+ .digest('hex').slice(0, 16);
1851
+ const result = {
1852
+ token: TOKEN,
1853
+ generatedAt: new Date().toISOString(),
1854
+ preStateHash,
1855
+ host: { user: os.userInfo().username, platform: process.platform, node: process.version, npmPrefix: NPM_PREFIX.replace(HOME, '~'), brainVersion: brainVersionOnDisk() },
1856
+ sections: { wiring, memory, savings, config, userSettings, brainPower, gates, recommendations },
1857
+ };
1858
+ // Cache the last good state so repeat page-loads paint instantly, same as the stack audit does.
1859
+ // TOKEN is per-server-run and must never touch disk — ?fast=1 splices the live one back in.
1860
+ //
1861
+ // TASK 4: routed through the shared atomic writeCache(), not a private writeFileSync. This used to
1862
+ // be a SECOND, non-atomic writer to the exact same STATE_CACHE path that serveCached()'s own
1863
+ // writeCache() call (and the --refresh-cache CLI branch) also write, right after calling this very
1864
+ // function — a bare writeFileSync racing an atomic rename on one file defeats the atomicity of the
1865
+ // other writer, because a reader can still land mid-truncate from THIS one. writeCache() is already
1866
+ // best-effort internally (never throws), so no extra try/catch is needed here.
1867
+ const { token, ...safe } = result;
1868
+ writeCache(STATE_CACHE, result.generatedAt, safe, cwd); // project-scoped stamp
1869
+ return result;
1870
+ }
1871
+ function gatherStack() {
1872
+ const a = auditModel();
1873
+ // ISSUE #22 — carry `source` ('npm-global' | 'plugin') + marketplace through so the console can show
1874
+ // (and count) tools installed via the Claude Code plugin marketplace, not just `npm install -g` ones.
1875
+ const rows = a.rows.map((r) => ({ name: r.name, installed: r.installed, target: r.target, tag: r.tag, state: r.state, source: r.source ?? 'npm-global', marketplace: r.marketplace ?? null }));
1876
+ const shadows = a.shadows.map((s) => ({ name: s.name, version: s.version, global: s.global, dir: String(s.dir).replace(HOME, '~'), stale: !!(s.global && s.version !== s.global) }));
1877
+ const by = (st) => rows.filter((r) => r.state === st).length;
1878
+ const summary = { total: rows.length, behind: by('BEHIND'), broken: by('BROKEN'), ahead: by('AHEAD'), current: by('CURRENT'), unresolved: by('UNRESOLVED'), shadows: shadows.length, stale: a.stale.length };
1879
+ const recommendations = buildStackRecommendations({ rows: a.rows, stale: a.stale });
1880
+ const result = { error: a.error, packages: rows, shadows, summary, recommendations };
1881
+ // Cache the last good audit so repeat page-loads render instantly ("as of HH:MM — re-checking").
1882
+ //
1883
+ // TASK 4: routed through the shared atomic writeCache(), same reasoning as gatherState() above —
1884
+ // this used to be a private writeFileSync straight to STACK_CACHE's own path (a SECOND, non-atomic
1885
+ // writer racing the serveCached()/--refresh-cache callers that also write this exact file right
1886
+ // after calling this function). The timestamp is taken HERE, after `result` above is already fully
1887
+ // built from `auditModel()`'s completed scan (Task 3) — never before it, unlike the two callers this
1888
+ // fix also corrects.
1889
+ if (!a.error) writeCache(STACK_CACHE, new Date().toISOString(), result);
1890
+ return result;
1891
+ }
1892
+
1893
+ // ── The ONLY writer: apply / save / undo ─────────────────────────────────────────────────────────
1894
+ function journalUndo(entry) {
1895
+ fs.mkdirSync(path.dirname(UNDO_JOURNAL), { recursive: true });
1896
+ const token = crypto.randomBytes(9).toString('hex');
1897
+ fs.appendFileSync(UNDO_JOURNAL, JSON.stringify({ token, at: new Date().toISOString(), ...entry }) + '\n');
1898
+ return token;
1899
+ }
1900
+ function runNode(scriptRelPath, args) {
1901
+ const r = spawnSync(process.execPath, [path.join(REPO, scriptRelPath), ...args], { encoding: 'utf8', timeout: 16 * 60 * 1000, cwd: REPO });
1902
+ return { ok: r.status === 0, code: r.status, log: `${r.stdout || ''}${r.stderr || ''}`.trim().slice(-4000) };
1903
+ }
1904
+
1905
+ // A wiring recommendation's `project` id is relative to whichever candidate root it was found under
1906
+ // (issue #19) — reconstruct the absolute path by checking each root, so reconcile/undo can act on a
1907
+ // project under ~/source just as well as one under ~/Code.
1908
+ function resolveProjectDir(project) {
1909
+ for (const root of candidateRoots()) {
1910
+ const p = path.join(root, project);
1911
+ if (fs.existsSync(p)) return p;
1912
+ }
1913
+ return path.join(HOME, 'Code', project); // last-resort fallback: the previous fixed behavior
1914
+ }
1915
+ // Re-derive the currently-valid recommendation set, so apply can only ever act on something STILL true.
1916
+ /**
1917
+ * Observe the learner's REAL state, for the health recommendations.
1918
+ *
1919
+ * Deliberately reads the GLOBAL learner (`cwd: HOME`), because that is the store the capture flush
1920
+ * actually writes to. Reading the project-local `.claude-flow/neural` instead is exactly the mistake
1921
+ * that made the console display a dead learner (5 trajectories, last trained 6 days earlier) while
1922
+ * the live one held 412 — rUv documents this fragmentation as issue #2245, "four contradictory
1923
+ * sources". Until it is unified upstream we read the store that learning writes, never the corpse.
1924
+ */
1925
+ function observeLearning() {
1926
+ const queueDir = path.join(os.homedir(), '.cache', 'ruvnet-brain', 'learn');
1927
+ let queueDepth = 0;
1928
+ try {
1929
+ for (const f of fs.readdirSync(queueDir)) {
1930
+ if (!f.endsWith('.jsonl')) continue;
1931
+ queueDepth += fs.readFileSync(path.join(queueDir, f), 'utf8').split('\n').filter(Boolean).length;
1932
+ }
1933
+ } catch { /* no queue dir yet — depth stays 0, which is honest */ }
1934
+
1935
+ let lastTrainSeconds = null; let trajectories = 0;
1936
+ try {
1937
+ const r = spawnSync(path.join(os.homedir(), '.npm-global/bin/ruflo'),
1938
+ ['hooks', 'intelligence', '--status'],
1939
+ {
1940
+ cwd: os.homedir(),
1941
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
1942
+ encoding: 'utf8',
1943
+ timeout: 20_000,
1944
+ });
1945
+ const out = `${r.stdout || ''}`;
1946
+ const t = out.match(/Last Training:\s*(\d+)s ago/);
1947
+ const j = out.match(/Trajectories\s*\|\s*(\d+)/);
1948
+ if (t) lastTrainSeconds = Number(t[1]);
1949
+ if (j) trajectories = Number(j[1]);
1950
+ } catch { /* ruflo absent or slow — leave null, and null NEVER produces a recommendation */ }
1951
+
1952
+ // The fleet is what makes ADR-027's North Star recommendation constructible at all — without it,
1953
+ // `learning:distill-fleet` can never be built, so it can never be offered, so clicking it would be
1954
+ // rejected as "your machine changed". It was missing here, which is exactly how a recommendation
1955
+ // ends up existing in code and nowhere else.
1956
+ //
1957
+ // Read from the cache the /api/memory scan already writes: a live scan opens 100+ SQLite stores at
1958
+ // ~90ms each, which is far too slow to sit on this path. A cold cache honestly yields [] — and []
1959
+ // produces no recommendation, which is the correct answer when we have not looked.
1960
+ const fleet = readJSON(MEMORY_CACHE)?.data?.fleet ?? [];
1961
+
1962
+ return { queueDepth, lastTrainSeconds, trajectories, fleet };
1963
+ }
1964
+
1965
+ function currentValidIds(onlyId = null) {
1966
+ const ids = new Set();
1967
+ const wiringOnly = typeof onlyId === 'string' && onlyId.startsWith('reconcile:');
1968
+ const stackOnly = typeof onlyId === 'string'
1969
+ && (onlyId === 'purge:shadows'
1970
+ || onlyId.startsWith('sync:')
1971
+ || (onlyId.startsWith('repair:') && onlyId !== 'repair:memory-index'));
1972
+ const healthOnly = onlyId === 'repair:memory-index'
1973
+ || (typeof onlyId === 'string' && onlyId.startsWith('learning:'));
1974
+ const capabilityOnly = typeof onlyId === 'string' && onlyId.startsWith('enable:');
1975
+ const validateAll = !wiringOnly && !stackOnly && !healthOnly && !capabilityOnly;
1976
+
1977
+ if (validateAll || wiringOnly) {
1978
+ for (const r of buildWiringRecommendations({ sites: wiringSurvey().sites })) ids.add(r.id);
1979
+ }
1980
+ let auditRows = [];
1981
+ if (validateAll || stackOnly) {
1982
+ const a = auditModel();
1983
+ auditRows = a.rows;
1984
+ for (const r of buildStackRecommendations({ rows: a.rows, stale: a.stale })) ids.add(r.id);
1985
+ }
1986
+ // Health + learning. Previously the console could SEE a corrupt store and score it 49/100 while
1987
+ // offering nothing to do about it — detection without a remedy, which ADR-027 prohibits.
1988
+ if (validateAll || healthOnly) {
1989
+ try {
1990
+ const project = process.cwd();
1991
+ const health = scoreMemoryHealth({ project: path.basename(project), probes: probeMemory(project) });
1992
+ for (const r of buildHealthRecommendations({ memory: health, learning: observeLearning() })) ids.add(r.id);
1993
+ } catch { /* an advisory surface must never break the apply path */ }
1994
+ }
1995
+ // Capability recs (e.g. `enable:memory-distillation`) — without this, clicking the one recommended
1996
+ // capability checkbox would always report "already resolved / your machine changed", because apply()
1997
+ // only ever accepts ids this function has vouched for. Separate try from the health block above so a
1998
+ // failure in one surface never silently hides the other's ids too.
1999
+ if (validateAll || capabilityOnly) {
2000
+ try {
2001
+ for (const r of buildCapabilityRecommendations({ capabilities: capabilityAuditAll({ project: process.cwd() }) })) ids.add(r.id);
2002
+ } catch { /* an advisory surface must never break the apply path */ }
2003
+ }
2004
+ return { ids, auditRows };
2005
+ }
2006
+ function apply(ids) {
2007
+ const results = [];
2008
+ for (const id of ids) {
2009
+ // Re-read immediately before EACH fix. A batch can change the validity of the next item; one
2010
+ // pre-flight snapshot for the whole list would let item 2 run against the world item 1 changed.
2011
+ const { ids: validNow } = currentValidIds(id);
2012
+ if (!validNow.has(id)) { results.push({ id, ok: false, skipped: true, error: 'worldMoved', log: 'Skipped — this is already resolved, or your machine changed since the page loaded. Nothing was done. Reload to see the current state.' }); continue; }
2013
+
2014
+ // ONE dispatch, through the registry (scripts/remedy-registry.mjs). This used to be a chain of
2015
+ // `if (id.startsWith(...))` whose handled-id set no code could inspect — so it drifted from the
2016
+ // builders and nothing noticed: `learning:enable-fleet` was offered with NO executor and fell
2017
+ // through to "Unknown recommendation id", and one reordering silently routed a database repair
2018
+ // into a global npm sync. Now the id→executor→inverse binding is a value, an ambiguous id
2019
+ // THROWS instead of picking a winner, and remedy-registry.test.mjs proves every offerable id
2020
+ // resolves to exactly one runnable remedy with a real undo behind it.
2021
+ let plan;
2022
+ try { plan = planFor(id); }
2023
+ catch (e) { results.push({ id, ok: false, log: e.message }); continue; } // ambiguous — a bug, said out loud
2024
+ if (!plan) { results.push({ id, ok: false, log: `Unknown recommendation id: ${id}` }); continue; }
2025
+
2026
+ // Record the inverse BEFORE the change, and fill in the parts only this moment knows.
2027
+ const undoSpec = { ...plan.undo, id };
2028
+ if (undoSpec.kind === 'reinstall-version') {
2029
+ const prev = installedVersion(undoSpec.pkg);
2030
+ // No readable previous version ⇒ there is nothing to reinstall. Say that, rather than
2031
+ // journalling an inverse that would fail later while looking recorded.
2032
+ if (prev) undoSpec.prevVersion = prev; else { undoSpec.kind = 'auto-rebuild'; undoSpec.human = `no previous version of ${undoSpec.pkg} was readable, so there is nothing to roll back to`; }
2033
+ }
2034
+ if (undoSpec.kind === 'restore-memory-backup') undoSpec.db = path.join(process.cwd(), '.swarm/memory.db');
2035
+ // The console is scoped to ONE project (process.cwd()) for its whole life — same fact
2036
+ // `restore-memory-backup` just used above, recorded here too so undo() can hand it straight back
2037
+ // to distill-project.mjs's own `--restore`.
2038
+ if (undoSpec.kind === 'restore-project-distill') undoSpec.project = process.cwd();
2039
+
2040
+ let args = [...plan.exec.args];
2041
+ if (plan.exec.resolveProject) {
2042
+ const i = args.indexOf('--project');
2043
+ if (i >= 0) args[i + 1] = resolveProjectDir(args[i + 1]);
2044
+ }
2045
+ // `usesServerProject`: this remedy's script must run against the ACTUAL project the console is
2046
+ // serving, never REPO — runNode() spawns every script with `cwd: REPO` (see its own comment),
2047
+ // so a script that fell back to its own `process.cwd()` default would silently describe THIS
2048
+ // package's checkout instead of the user's project. That exact REPO-vs-project confusion is
2049
+ // capability-registry.mjs's own header's "single most damaging bug this file has shipped"; this
2050
+ // flag exists so it cannot recur here.
2051
+ if (plan.exec.usesServerProject) args = [...args, '--project', process.cwd()];
2052
+ if (plan.exec.needsReceipt) {
2053
+ const receipt = path.join(HOME, '.cache', 'ruvnet-brain', 'undo', `${plan.key}-${stamp()}.json`);
2054
+ undoSpec.receipt = receipt;
2055
+ args = [...args, '--receipt', receipt];
2056
+ }
2057
+
2058
+ const undoToken = journalUndo(undoSpec);
2059
+ const res = runNode(plan.exec.script, args);
2060
+ results.push({ id, ...res, undoToken });
2061
+ }
2062
+ return { results };
2063
+ }
2064
+
2065
+ function autoEligibleIds(recommendations = []) {
2066
+ return recommendations
2067
+ .filter((rec) => rec?.scope === 'project')
2068
+ .filter((rec) => {
2069
+ const plan = planFor(rec.id);
2070
+ return plan?.autoEligible === true && plan.undo?.kind !== 'none';
2071
+ })
2072
+ .map((rec) => rec.id);
2073
+ }
2074
+ /**
2075
+ * VALIDATE AGAINST THE SCHEMA THAT IS ALREADY DECLARED. Without this, `/api/save-config` wrote
2076
+ * whatever arrived: MEASURED, `{routing:'banana', nightly:'yes-please', provider:{evil:1}}` landed in
2077
+ * the config file verbatim, and gatherConfig then read `routing:'banana'` as "not off" and rendered
2078
+ * it as ON. Every one of these keys has its type and its allowed values stated ten lines up in
2079
+ * CONFIG_SCHEMA; nothing was consulting them.
2080
+ *
2081
+ * Rejected values are REPORTED, not silently dropped and not silently coerced — a bad value must not
2082
+ * quietly become a different setting than the one the user believes they chose.
2083
+ */
2084
+ function validateConfigPatch(values) {
2085
+ const clean = {};
2086
+ const rejected = [];
2087
+ for (const s of CONFIG_SCHEMA) {
2088
+ const v = values?.[s.key];
2089
+ if (v === undefined || v === null) continue;
2090
+ if (s.secret) {
2091
+ // Only overwrite a secret when a real new value is typed — '••••' is the masked placeholder
2092
+ // the form echoes back, and treating it as a new key would destroy the stored one.
2093
+ if (typeof v === 'string' && v.trim() && v !== '••••') clean[s.key] = v.trim();
2094
+ else if (typeof v !== 'string') rejected.push({ key: s.key, reason: 'expected a string' });
2095
+ continue;
2096
+ }
2097
+ if (s.type === 'bool') {
2098
+ if (typeof v === 'boolean') clean[s.key] = v;
2099
+ else rejected.push({ key: s.key, reason: `expected true or false, got ${JSON.stringify(v)}` });
2100
+ continue;
2101
+ }
2102
+ if (s.type === 'enum') {
2103
+ if (typeof v === 'string' && s.options.includes(v)) clean[s.key] = v;
2104
+ else rejected.push({ key: s.key, reason: `expected one of ${s.options.join(', ')}, got ${JSON.stringify(v)}` });
2105
+ continue;
2106
+ }
2107
+ if (typeof v === 'string') clean[s.key] = v;
2108
+ else rejected.push({ key: s.key, reason: 'expected a string' });
2109
+ }
2110
+ // Keys not in the schema never reach disk. The old writer only ever copied schema keys either, but
2111
+ // it did so while trusting their values, which is the half of the job that mattered.
2112
+ return { clean, rejected };
2113
+ }
2114
+
2115
+ /**
2116
+ * SAVE — the writer users actually reach, and now the one that is actually safe.
2117
+ *
2118
+ * This function used to be the counter-example to the entire user-settings.mjs module sitting beside
2119
+ * it: that file has a lock, an atomic rename, an exclusive-create backup and a from-the-future
2120
+ * refusal, all tested — and ZERO non-test callers, while this truncating, unlocked, unvalidated
2121
+ * writeFileSync served every click on the page. The hardening was real and unreachable.
2122
+ *
2123
+ * Now it borrows those primitives directly rather than growing a second, weaker copy of them:
2124
+ * withLock two Claude Code sessions on one machine is the normal case, not the exotic one, and
2125
+ * read-modify-write without a lock loses whichever key the loser wrote.
2126
+ * read-inside `prev` is re-read INSIDE the lock; reading before acquiring reintroduces the race.
2127
+ * writeAtomic writeFileSync truncates first, so a crash mid-write leaves an EMPTY config and the
2128
+ * user's answers are gone having passed the backup step successfully.
2129
+ * backup 'wx' exclusive creation, so a racing writer cannot overwrite the backup this save's undo
2130
+ * token points at.
2131
+ */
2132
+ function saveConfig(values) {
2133
+ try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); }
2134
+ catch (e) { return { ok: false, log: `could not create ${CONFIG_DIR.replace(HOME, '~')}: ${e.message}` }; }
2135
+
2136
+ const { clean, rejected } = validateConfigPatch(values);
2137
+ // Nothing valid to write is not a save. Saying "Saved." here would be the dead-button failure with
2138
+ // a receipt attached.
2139
+ if (!Object.keys(clean).length) {
2140
+ return {
2141
+ ok: false,
2142
+ rejected,
2143
+ log: rejected.length
2144
+ ? `nothing was saved — ${rejected.map((r) => `${r.key}: ${r.reason}`).join('; ')}`
2145
+ : 'nothing was saved — no recognised settings were supplied',
2146
+ };
2147
+ }
2148
+
2149
+ // Credential and scheduler changes are real effects, not JSON preferences. Execute their existing
2150
+ // owners, remember their inverses, then commit the ordinary config. If the final config write
2151
+ // fails, both effects are rolled back before the error is returned.
2152
+ const requestedSecret = clean.openrouterKey;
2153
+ delete clean.openrouterKey;
2154
+ const requestedNightly = clean.nightly;
2155
+ let credentialChange = null;
2156
+ let nightlyChange = null;
2157
+ const rollbackCredential = () => {
2158
+ if (!credentialChange?.ok) return;
2159
+ try {
2160
+ if (credentialChange.backup) {
2161
+ fs.copyFileSync(credentialChange.backup, credentialChange.path);
2162
+ } else if (!credentialChange.existed) {
2163
+ fs.rmSync(credentialChange.path, { force: true });
2164
+ }
2165
+ } catch { /* reported by the caller as a partial rollback below */ }
2166
+ };
2167
+ if (requestedSecret !== undefined) {
2168
+ credentialChange = saveOpenRouterCredential(requestedSecret, { cwd: process.cwd() });
2169
+ if (!credentialChange.ok) return { ok: false, rejected, log: credentialChange.log };
2170
+ }
2171
+ if (requestedNightly !== undefined) {
2172
+ nightlyChange = applyNightlyChoice(requestedNightly);
2173
+ if (!nightlyChange.ok) {
2174
+ rollbackCredential();
2175
+ return { ok: false, rejected, log: nightlyChange.log };
2176
+ }
2177
+ }
2178
+
2179
+ const held = withLock(CONFIG_PATH, () => {
2180
+ const existed = fs.existsSync(CONFIG_PATH);
2181
+ const prev = readJSON(CONFIG_PATH) || {};
2182
+
2183
+ let backup = null;
2184
+ if (existed) {
2185
+ const base = `${CONFIG_PATH}.bak-${stamp()}`;
2186
+ backup = base;
2187
+ for (let n = 2; fs.existsSync(backup); n++) backup = `${base}-${String(n).padStart(2, '0')}`;
2188
+ try { fs.writeFileSync(backup, fs.readFileSync(CONFIG_PATH), { flag: 'wx', mode: 0o600 }); }
2189
+ catch (e) { return { ok: false, log: `refusing to write — backup failed: ${e.message}` }; }
2190
+ }
2191
+
2192
+ const next = { ...prev, ...clean };
2193
+ // A successfully encrypted credential retires the legacy plaintext field on this same commit.
2194
+ if (credentialChange?.ok) delete next.openrouterKey;
2195
+ try { writeAtomic(CONFIG_PATH, JSON.stringify(next, null, 2) + '\n'); }
2196
+ catch (e) { return { ok: false, backup, log: `write failed: ${e.message}${backup ? `; your previous settings are at ${backup.replace(HOME, '~')}` : ''}` }; }
2197
+ try { fs.chmodSync(CONFIG_PATH, 0o600); } catch { /* best effort on non-posix */ }
2198
+
2199
+ // The undo token is journalled only AFTER the write succeeded. Recording an undo for a save that
2200
+ // never happened hands the user a button that would revert a change they never made.
2201
+ const undoToken = journalUndo({
2202
+ kind: 'restore-config',
2203
+ backup,
2204
+ existed,
2205
+ nightlyBefore: nightlyChange?.before?.state ?? null,
2206
+ secretBackup: credentialChange?.backup ?? null,
2207
+ secretPath: credentialChange?.path ?? null,
2208
+ secretExisted: credentialChange?.existed ?? null,
2209
+ });
2210
+ return { ok: true, backup: backup ? backup.replace(HOME, '~') : null, undoToken, rejected };
2211
+ });
2212
+
2213
+ if (held.timedOut) {
2214
+ if (nightlyChange?.ok && ['on', 'off'].includes(nightlyChange.before?.state)) {
2215
+ applyNightlyChoice(nightlyChange.before.state === 'on');
2216
+ }
2217
+ rollbackCredential();
2218
+ return {
2219
+ ok: false,
2220
+ rejected,
2221
+ log: `another process is writing your settings and did not finish within ${LOCK_WAIT_MS}ms — nothing was written; try again`,
2222
+ };
2223
+ }
2224
+ if (!held.value?.ok) {
2225
+ if (nightlyChange?.ok && ['on', 'off'].includes(nightlyChange.before?.state)) {
2226
+ applyNightlyChoice(nightlyChange.before.state === 'on');
2227
+ }
2228
+ rollbackCredential();
2229
+ }
2230
+ if (held.value?.ok) publishSettingsToCache();
2231
+ return held.value;
2232
+ }
2233
+ /** Read the append-only undo journal. Malformed lines are skipped; they are not entries. */
2234
+ function readUndoJournal() {
2235
+ if (!fs.existsSync(UNDO_JOURNAL)) return [];
2236
+ try {
2237
+ return fs.readFileSync(UNDO_JOURNAL, 'utf8').split('\n').filter(Boolean)
2238
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
2239
+ } catch { return []; }
2240
+ }
2241
+
2242
+ /**
2243
+ * An undo is spent once it is used. The journal is append-only, so "spent" is itself an appended
2244
+ * record rather than a mutation — same reason the project-state checkpoint is append-only.
2245
+ */
2246
+ function markUndoConsumed(token) {
2247
+ try { fs.appendFileSync(UNDO_JOURNAL, JSON.stringify({ consumed: token, at: new Date().toISOString() }) + '\n'); }
2248
+ catch { /* the restore already happened; failing to record it must not un-happen it */ }
2249
+ }
2250
+
2251
+ function restoreConfigEffects(entry) {
2252
+ const failures = [];
2253
+ if (entry.secretPath) {
2254
+ try {
2255
+ if (entry.secretBackup && fs.existsSync(entry.secretBackup)) fs.copyFileSync(entry.secretBackup, entry.secretPath);
2256
+ else if (entry.secretExisted === false) fs.rmSync(entry.secretPath, { force: true });
2257
+ } catch (error) {
2258
+ failures.push(`encrypted credential restore failed: ${error.message}`);
2259
+ }
2260
+ }
2261
+ if (entry.nightlyBefore === 'on' || entry.nightlyBefore === 'off') {
2262
+ const restored = applyNightlyChoice(entry.nightlyBefore === 'on');
2263
+ if (!restored.ok) failures.push(restored.log);
2264
+ }
2265
+ return failures;
2266
+ }
2267
+
2268
+ function undo(undoToken) {
2269
+ if (!fs.existsSync(UNDO_JOURNAL)) return { ok: false, log: 'no undo history' };
2270
+ const journal = readUndoJournal();
2271
+ const entry = journal.find((e) => e.token === undoToken);
2272
+ if (!entry) return { ok: false, log: 'that undo token was not found' };
2273
+
2274
+ // ONE UNDO, ONCE. The token was never consumed, so the same button replayed forever: clicking it
2275
+ // twice re-restored a backup over whatever the user had done in between, and reported success both
2276
+ // times. An undo you can accidentally apply to a state it was not computed against is a data-loss
2277
+ // button wearing a safety label.
2278
+ if (journal.some((e) => e.consumed === undoToken)) {
2279
+ return { ok: false, log: 'that undo has already been used — it cannot be applied twice, because what it would restore is no longer what came before' };
2280
+ }
2281
+
2282
+ if (entry.kind === 'restore-config') {
2283
+ // A LATER SAVE MAKES THIS UNDO WRONG, and this was the worst defect on the page. MEASURED: save A,
2284
+ // save B, then click A's undo — the console reported "restored your previous settings" and B's
2285
+ // choices were silently gone, because A's backup predates B entirely. Reachable in a single
2286
+ // screen: saving Settings shows an undo button, clicking "Turn on smart routing" is a second save
2287
+ // through the same endpoint, and A's undo then reverts routing while the CTA still reads ON.
2288
+ //
2289
+ // An undo can only speak for the last write. If something was written after it, the honest answer
2290
+ // is to refuse and say so — restoring anyway would be destroying newer data while claiming to
2291
+ // protect older data.
2292
+ const laterSave = journal.some((e) => e.kind === 'restore-config' && e.at > entry.at && e.token !== undoToken);
2293
+ if (laterSave) {
2294
+ return { ok: false, log: 'your settings were saved again after this point, so this undo would wipe out that newer save — nothing was changed. Use the undo from the most recent save, or restore a backup by hand.' };
2295
+ }
2296
+
2297
+ if (entry.backup && fs.existsSync(entry.backup)) {
2298
+ // Locked and atomic, matching the save path. A half-written config during an UNDO leaves the
2299
+ // user with neither their old settings nor their new ones.
2300
+ let held;
2301
+ try {
2302
+ const bytes = fs.readFileSync(entry.backup);
2303
+ held = withLock(CONFIG_PATH, () => writeAtomic(CONFIG_PATH, bytes));
2304
+ } catch (e) { return { ok: false, log: `restore failed: ${e.message} — your backup at ${entry.backup.replace(HOME, '~')} is intact` }; }
2305
+ if (held.timedOut) return { ok: false, log: `another process is writing your settings and did not finish within ${LOCK_WAIT_MS}ms — NOTHING was restored and your backup is intact; try again` };
2306
+ try { fs.chmodSync(CONFIG_PATH, 0o600); } catch { /* best effort on non-posix */ }
2307
+ const effectFailures = restoreConfigEffects(entry);
2308
+ if (effectFailures.length) {
2309
+ return { ok: false, log: `the settings file was restored, but ${effectFailures.join('; ')}. The undo remains available.` };
2310
+ }
2311
+ markUndoConsumed(undoToken);
2312
+ return { ok: true, log: 'restored your previous settings' };
2313
+ }
2314
+
2315
+ if (!entry.existed && fs.existsSync(CONFIG_PATH)) {
2316
+ // The first-ever-save case: undo means removing the file. Guarded by the same later-save check
2317
+ // above — without it, this branch DELETED THE WHOLE CONFIG including every choice made after,
2318
+ // and reported "removed the settings file (there was none before)" as if that were harmless.
2319
+ let held;
2320
+ try { held = withLock(CONFIG_PATH, () => { fs.rmSync(CONFIG_PATH); return true; }); }
2321
+ catch (e) { return { ok: false, log: `could not remove the settings file: ${e.message}` }; }
2322
+ if (held.timedOut) return { ok: false, log: `another process is writing your settings and did not finish within ${LOCK_WAIT_MS}ms — nothing was removed; try again` };
2323
+ const effectFailures = restoreConfigEffects(entry);
2324
+ if (effectFailures.length) {
2325
+ return { ok: false, log: `the settings file was removed, but ${effectFailures.join('; ')}. The undo remains available.` };
2326
+ }
2327
+ markUndoConsumed(undoToken);
2328
+ return { ok: true, log: 'removed the settings file (there was none before this save)' };
2329
+ }
2330
+ return { ok: false, log: 'no backup available to restore' };
2331
+ }
2332
+ // EVERY branch below marks its token consumed on success, for the reason spelled out on the
2333
+ // restore-config branch above: these all copy a saved snapshot over a live file, so replaying one
2334
+ // re-applies an old state over whatever the user has done since. The replay guard at the top of
2335
+ // this function covers all kinds; these calls are what arm it.
2336
+ if (entry.kind === 'reinstall-version' && entry.pkg && entry.prevVersion) {
2337
+ const r = spawnSync('npm', ['install', '-g', '--prefix', NPM_PREFIX, `${entry.pkg}@${entry.prevVersion}`], { encoding: 'utf8', timeout: 15 * 60 * 1000 });
2338
+ if (r.status === 0) markUndoConsumed(undoToken);
2339
+ return { ok: r.status === 0, log: r.status === 0 ? `reinstalled ${entry.pkg}@${entry.prevVersion}` : (r.stderr || '').slice(-800) };
2340
+ }
2341
+ if (entry.kind === 'restore-backup' && entry.project) {
2342
+ const dir = resolveProjectDir(entry.project);
2343
+ let restored = 0;
2344
+ for (const f of ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json']) {
2345
+ const target = path.join(dir, f);
2346
+ const baks = (() => { try { return fs.readdirSync(path.dirname(target)).filter((n) => n.startsWith(path.basename(target) + '.bak-reconcile-')); } catch { return []; } })();
2347
+ if (!baks.length) continue;
2348
+ baks.sort();
2349
+ fs.copyFileSync(path.join(path.dirname(target), baks[baks.length - 1]), target); restored++;
2350
+ }
2351
+ if (restored > 0) markUndoConsumed(undoToken);
2352
+ return { ok: restored > 0, log: restored ? `restored ${restored} settings file(s) from backup` : 'no reconcile backups found to restore' };
2353
+ }
2354
+ // THE BRANCH THAT DID NOT EXIST. `repair:memory-index` journalled kind 'restore-memory-backup'
2355
+ // and nothing here handled it, so it fell to the default arm below and answered "nothing to undo
2356
+ // (the change reverses itself automatically)" — while the recommendation had promised to restore
2357
+ // the pre-repair backup. health-repair.mjs writes that backup as `<db>.rescue-<iso>`; this finds
2358
+ // the newest one and puts it back.
2359
+ if (entry.kind === 'restore-memory-backup' && entry.db) {
2360
+ const dir = path.dirname(entry.db);
2361
+ const base = `${path.basename(entry.db)}.rescue-`;
2362
+ let baks = [];
2363
+ try { baks = fs.readdirSync(dir).filter((n) => n.startsWith(base)).sort(); } catch { /* dir gone */ }
2364
+ if (!baks.length) return { ok: false, log: `no pre-repair backup found next to ${entry.db.replace(HOME, '~')} — nothing was restored` };
2365
+ const from = path.join(dir, baks[baks.length - 1]);
2366
+ try { fs.copyFileSync(from, entry.db); }
2367
+ catch (e) { return { ok: false, log: `could not restore ${from.replace(HOME, '~')}: ${e.message}` }; }
2368
+ markUndoConsumed(undoToken);
2369
+ return { ok: true, log: `restored your memory store from the snapshot taken before the repair (${baks[baks.length - 1]})` };
2370
+ }
2371
+ // `enable:memory-distillation`'s inverse. Deliberately handed BACK to distill-project.mjs's own
2372
+ // `--restore` rather than re-derived here: it already knows where its snapshots live (that
2373
+ // project's `.swarm/backups`) and its restore path is the one proven end to end (see the script's
2374
+ // header: 644 → 648 → 644 → 648, 2026-07-24). Re-implementing "find the newest backup" a second time
2375
+ // in this file is exactly the duplicate-inverse pattern ADR-047 was rejected for.
2376
+ if (entry.kind === 'restore-project-distill' && entry.project) {
2377
+ const r = spawnSync(process.execPath,
2378
+ [path.join(REPO, 'scripts/distill-project.mjs'), '--project', entry.project, '--restore'],
2379
+ { encoding: 'utf8', timeout: 5 * 60 * 1000 });
2380
+ if (r.status === 0) markUndoConsumed(undoToken);
2381
+ const out = `${r.stdout || ''}${r.stderr || ''}`.trim();
2382
+ return { ok: r.status === 0, log: out.slice(-2000) || (r.status === 0 ? 'restored the pre-distill snapshot' : 'restore failed') };
2383
+ }
2384
+ // Fleet distillation touches a set of stores discovered at run time, so its executor writes a
2385
+ // receipt naming each store it snapshotted. No receipt ⇒ we do not know what was touched, and we
2386
+ // say so instead of guessing — restoring the wrong snapshot over a live store is worse than
2387
+ // restoring nothing.
2388
+ if (entry.kind === 'restore-store-backups') {
2389
+ const rec = entry.receipt && fs.existsSync(entry.receipt) ? readJSON(entry.receipt) : null;
2390
+ const stores = Array.isArray(rec?.stores) ? rec.stores : [];
2391
+ if (!stores.length) return { ok: false, log: 'no receipt of which stores were distilled — nothing was restored. Each store\'s own snapshot is still in its .swarm/backups folder.' };
2392
+ let restored = 0; const failures = [];
2393
+ for (const s of stores) {
2394
+ let snaps = [];
2395
+ try { snaps = fs.readdirSync(s.backupDir).filter((n) => n.endsWith('.db') || n.includes('memory')).sort(); } catch { /* dir gone */ }
2396
+ if (!snaps.length) { failures.push(`${s.name}: no snapshot found`); continue; }
2397
+ try { fs.copyFileSync(path.join(s.backupDir, snaps[snaps.length - 1]), s.db); restored++; }
2398
+ catch (e) { failures.push(`${s.name}: ${e.message}`); }
2399
+ }
2400
+ return {
2401
+ ok: restored > 0,
2402
+ log: `${restored} of ${stores.length} store(s) restored from their pre-distill snapshots`
2403
+ + (failures.length ? ` — could not restore: ${failures.join('; ')}` : ''),
2404
+ };
2405
+ }
2406
+ // Only kinds that genuinely reverse themselves reach here. Anything else arriving at this arm is
2407
+ // a registry/undo drift, and remedy-registry.test.mjs fails the build before it can reach a user.
2408
+ if (entry.kind === 'none' || entry.kind === 'auto-rebuild') {
2409
+ return { ok: true, log: entry.human || 'nothing to undo (the change reverses itself automatically)' };
2410
+ }
2411
+ return { ok: false, log: `no undo is implemented for "${entry.kind}" — nothing was changed back. Please report this.` };
2412
+ }
2413
+ // The undo kinds this function actually implements. Exported so the closure test can check the
2414
+ // registry against the REAL handler set rather than a hand-copied list that would drift from it.
2415
+ export const HANDLED_UNDO_KINDS = Object.freeze([
2416
+ 'restore-config', 'reinstall-version', 'restore-backup',
2417
+ 'restore-memory-backup', 'restore-store-backups', 'restore-project-distill', 'auto-rebuild', 'none',
2418
+ ]);
2419
+
2420
+ // ── HTTP ─────────────────────────────────────────────────────────────────────────────────────────
2421
+ const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.webp': 'image/webp', '.jpg': 'image/jpeg', '.json': 'application/json', '.woff2': 'font/woff2' };
2422
+ function serveStatic(req, res) {
2423
+ const rel = decodeURIComponent(req.url.split('?')[0]).replace(/^\/+/, '') || 'index.html';
2424
+ const file = path.join(CONSOLE_DIR, rel);
2425
+ if (!file.startsWith(CONSOLE_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) return send(res, 404, 'text/plain', 'not found');
2426
+ let body = fs.readFileSync(file);
2427
+ const ext = path.extname(file);
2428
+ if (ext === '.html') body = Buffer.from(String(body).replace('</head>', `<script>window.__CONSOLE_TOKEN__=${JSON.stringify(TOKEN)}</script></head>`));
2429
+ res.writeHead(200, { 'content-type': MIME[ext] || 'application/octet-stream', 'cache-control': 'no-store' });
2430
+ res.end(body);
2431
+ }
2432
+ function send(res, code, type, body) { res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' }); res.end(body); }
2433
+ function sendJSON(res, code, obj) { send(res, code, 'application/json', JSON.stringify(obj)); }
2434
+ function readBody(req) { return new Promise((resolve) => { let b = ''; req.on('data', (c) => { b += c; if (b.length > 1e6) req.destroy(); }); req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } }); }); }
2435
+
2436
+ /**
2437
+ * Open the console AND PUT IT IN FRONT OF THE USER.
2438
+ *
2439
+ * `open <url>` creates the tab but does NOT raise the browser window. Observed live 2026-07-21:
2440
+ * the console had been opened twice and was sitting in two Chrome tabs the whole time, behind
2441
+ * VS Code, while the user stared at their editor and reasonably concluded it was broken — and I
2442
+ * kept reporting "opened" because the command exited 0. Exit code 0 meant "a tab exists
2443
+ * somewhere", never "you can see it".
2444
+ *
2445
+ * So on macOS we also `activate` the browser. Raising a window the user asked for is not a
2446
+ * surprise; leaving them looking at the wrong app while claiming success is.
2447
+ */
2448
+ /* ASYNCHRONOUS, ALWAYS (RVBC-INSTANT-SPEC #5). This runs inside the `server.listen()` callback, so
2449
+ every synchronous millisecond here is a millisecond the freshly-opened tab spends waiting for its
2450
+ own first byte. `spawnSync(open)` costs a launch-services round-trip and `spawnSync(osascript)`
2451
+ was capped at EIGHT SECONDS — which is to say the browser could have the tab while the server that
2452
+ is supposed to answer it was blocked, by the very act of opening it. Detached + unref'd spawns
2453
+ start the same processes without ever holding the loop. */
2454
+ function openBrowser(url) {
2455
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
2456
+ const bg = (cmd, args, opts = {}) => {
2457
+ try { const c = spawn(cmd, args, { stdio: 'ignore', detached: true, ...opts }); c.on('error', () => {}); c.unref(); }
2458
+ catch { /* headless is fine */ }
2459
+ };
2460
+ bg(opener, [url]);
2461
+ if (process.platform !== 'darwin') return;
2462
+ // Bring whichever browser now holds the tab to the front. Best-effort and silent: a failure here
2463
+ // must never break serving the page.
2464
+ {
2465
+ bg('osascript', ['-e', `
2466
+ tell application "System Events"
2467
+ set brs to name of every application process whose bundle identifier is in ¬
2468
+ {"com.google.Chrome","com.apple.Safari","company.thebrowser.Browser","org.mozilla.firefox","com.brave.Browser"}
2469
+ end tell
2470
+ repeat with b in brs
2471
+ try
2472
+ tell application (b as text) to activate
2473
+ exit repeat
2474
+ end try
2475
+ end repeat
2476
+ `], { timeout: 8000 }); // spawn's own timeout kills a wedged osascript; it never blocks us
2477
+ }
2478
+ }
2479
+ function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = false, cwd = process.cwd() } = {}) {
2480
+ const server = http.createServer(async (req, res) => {
2481
+ // DNS-rebinding guard: this server binds 127.0.0.1 only. Reject any request whose Host header
2482
+ // isn't loopback, so a malicious web page can't rebind a hostname to 127.0.0.1 and read local state.
2483
+ const reqHost = String(req.headers.host || '').split(':')[0].toLowerCase();
2484
+ if (reqHost !== '127.0.0.1' && reqHost !== 'localhost' && reqHost !== '::1' && reqHost !== '[::1]') {
2485
+ res.writeHead(403, { 'content-type': 'text/plain' }); res.end('forbidden host'); return;
2486
+ }
2487
+ try {
2488
+ const url = req.url.split('?')[0];
2489
+ // Heavy read-models: ALWAYS cache-first (fast=1 or not — both land here now). The handler
2490
+ // never blocks the event loop; kickRefresh() recomputes in a detached child. See writeCache/
2491
+ // serveCached above and the --refresh-cache CLI mode. TOKEN is injected at serve time so it
2492
+ // never has to live in the on-disk cache.
2493
+ if (req.method === 'GET' && url === '/api/state') {
2494
+ // project-scoped: never serve another project's cached state. The measuring lives in the
2495
+ // --refresh-cache child; this handler only ever reads a file and stamps the token on it.
2496
+ return serveCached(res, STATE_CACHE, (d) => ({ ...d, token: TOKEN }), cwd);
2497
+ }
2498
+ // ── /api/capabilities — "what do I own, and is it on?" ──────────────────────────────────────
2499
+ //
2500
+ // THE MISSING WIRE. capability-registry.mjs and capability-audit.mjs were both written, both
2501
+ // tested, and had ZERO call sites — a parallel reviewer found it with one grep. The client
2502
+ // referenced them only in COMMENTS. So the console could compute the single thing the owner
2503
+ // has asked for all night ("a ton of people don't know what is or isn't turned on because
2504
+ // it's very much a black box") and served it to nobody.
2505
+ //
2506
+ // That is this project's signature failure in its purest form: built, tested, unwired. It is
2507
+ // the same shape as the recommendation with no executor, and the advocacy engine that
2508
+ // rendered nowhere. Detection without delivery is a nicer way of doing nothing.
2509
+ //
2510
+ // Cached like the other heavy read-models — auditAll() shells out to real commands to derive
2511
+ // each state, which is far too slow for a first paint but is exactly why the answers are
2512
+ // trustworthy: every row is DERIVED on this machine, never asserted.
2513
+ if (req.method === 'GET' && url === '/api/capabilities') {
2514
+ return serveCached(res, CAPABILITY_CACHE, (d) => d, cwd);
2515
+ }
2516
+ if (req.method === 'GET' && url === '/api/memory') {
2517
+ // THE THESIS, FINALLY CONNECTED (ADR-027, 2026-07-22).
2518
+ //
2519
+ // buildHealthRecommendations() has existed since the ADR was written and was reachable ONLY
2520
+ // from apply() — i.e. only once a user clicked something that was never displayed. The brain
2521
+ // could see a corrupt store, a starving learner, and a fleet of memory stores that teach it
2522
+ // nothing, and it said none of it out loud. Every word in ADR-027 about the brain advocating
2523
+ // was true of the code and invisible to the person in front of it.
2524
+ //
2525
+ // It rides /api/memory rather than /api/state because it needs the fleet scan (100+ SQLite
2526
+ // stores at ~90ms each) and a `ruflo hooks intelligence --status` round-trip. That is far too
2527
+ // slow for first paint — so it is measured ONLY in the --refresh-cache child, which builds
2528
+ // the fleet and hands it straight to the recommendation builder so the advice is derived
2529
+ // from the same scan the user is looking at.
2530
+ return serveCached(res, MEMORY_CACHE, (d) => d, cwd); // project-scoped: health + recs are about THIS project
2531
+ }
2532
+ if (req.method === 'GET' && url === '/api/stack') {
2533
+ // Machine-level (no scopeKey): the installed stack is the same whichever project you opened
2534
+ // from. Measured only in the child — this is the endpoint that answered cold in 23,640 ms on
2535
+ // the request path before the instant-open fix.
2536
+ return serveCached(res, STACK_CACHE);
2537
+ }
2538
+ if (req.method === 'GET' && url === '/api/activity') return sendJSON(res, 200, gatherActivity(cwd));
2539
+ if (req.method === 'GET' && url === '/api/lessons') return sendJSON(res, 200, gatherLessons());
2540
+ if (req.method === 'GET' && url === '/api/trust') return sendJSON(res, 200, await gatherTrust());
2541
+ if (req.method === 'GET' && url === '/tips') { req.url = '/tips.html'; return serveStatic(req, res); }
2542
+ if (req.method === 'POST') {
2543
+ const body = await readBody(req);
2544
+ if (body.token !== TOKEN) return sendJSON(res, 403, { error: 'bad or missing token' });
2545
+ if (url === '/api/apply') return sendJSON(res, 200, apply(Array.isArray(body.ids) ? body.ids : []));
2546
+ if (url === '/api/save-config') return sendJSON(res, 200, saveConfig(body.values || {}));
2547
+ if (url === '/api/save-advocacy') return sendJSON(res, 200, saveAdvocacy(body.values || {}));
2548
+ // ADR-054 — a distinct endpoint because it writes a distinct thing (the sentinel + the
2549
+ // mirror), never routed through save-advocacy/save-config.
2550
+ if (url === '/api/save-brain-power') return sendJSON(res, 200, saveBrainPower(body.values || {}));
2551
+ if (url === '/api/save-brain-profile') return sendJSON(res, 200, saveBrainProfile(body.values || {}));
2552
+ if (url === '/api/refresh') {
2553
+ // THE ONE REFRESH STORY (owner directive 2026-07-26; RVBC-INSTANT-SPEC #8). The page opens
2554
+ // instantly on the last measurement, SAYS how old it is, and this is the button that takes
2555
+ // a new one. Three things it must get right, each of which was wrong in the first draft:
2556
+ //
2557
+ // • ALL FOUR caches, not just state. The header pill speaks for the whole page; going
2558
+ // green after state alone — while stack, capabilities and the fleet were still a
2559
+ // minute behind — is a page-wide "measured just now" that is false for three of its
2560
+ // four cards. (Live cache stamps from the incident: state 03:36:58, stack 03:37:23,
2561
+ // memory/capabilities 03:38:02.)
2562
+ // • EXPIRE, NEVER DELETE, AND NEVER DE-SCOPE — see expireCachesEmbedding: the data
2563
+ // survives, marked withdrawn, so the page keeps showing the last honest picture with
2564
+ // an honest age while the new one is taken.
2565
+ // • FORCE the kick. kickRefresh's 15s debounce would silently swallow a click made
2566
+ // within 15s of any background kick — and the old code still answered `ok: true`. A
2567
+ // refresh that did not start must not report that it did, so `started` is the child's
2568
+ // real answer, not a constant.
2569
+ expireCachesEmbedding([STATE_CACHE, STACK_CACHE, MEMORY_CACHE, CAPABILITY_CACHE]);
2570
+ const started = kickRefresh({ force: true });
2571
+ return sendJSON(res, 200, { ok: true, refreshing: true, started });
2572
+ }
2573
+ if (url === '/api/undo') return sendJSON(res, 200, undo(body.undoToken));
2574
+ if (url === '/api/set-lesson') return sendJSON(res, 200, setLesson(body));
2575
+ return sendJSON(res, 404, { error: 'unknown endpoint' });
2576
+ }
2577
+ if (req.method === 'GET') return serveStatic(req, res);
2578
+ return send(res, 405, 'text/plain', 'method not allowed');
2579
+ } catch (e) { return sendJSON(res, 500, { error: String(e && e.message || e) }); }
2580
+ });
2581
+ server.on('error', (e) => {
2582
+ if (e.code === 'EADDRINUSE' && port !== 0) { console.error(` port ${port} busy — trying a free one…`); startServer({ port: 0, open, cwd }); }
2583
+ else { console.error(` server error: ${e.message}`); process.exit(1); }
2584
+ });
2585
+ server.listen(port, '127.0.0.1', () => {
2586
+ const actual = server.address().port;
2587
+ const url = `http://127.0.0.1:${actual}/`;
2588
+ console.log(`\n 🧠 RuvNet Brain — Onboarding Console`);
2589
+ console.log(` ${url}`);
2590
+ console.log(` read-only until you click · token-gated · ^C to stop\n`);
2591
+ // Cold-start fix (2026-07-17): hydrate last run's fleet/trust caches from disk FIRST — the
2592
+ // first page load paints real, honestly-stamped data in ~2s instead of a 25–50s scan — then
2593
+ // warm a fresh scan off the request path.
2594
+ // Tell a FIRST-RUN user what to expect. With a warm cache the page paints immediately; with no
2595
+ // cache at all it is genuinely empty until the detached scan lands, and an empty page with no
2596
+ // explanation reads as broken. Measured 2026-07-20: URL is printed in ~0.3s either way, so the
2597
+ // wait a user perceives is the page filling in, not the server starting.
2598
+ //
2599
+ // COLD-VS-WARM DEFINITION: loadConsoleCache() returns true only when a disk cache was successfully
2600
+ // restored at boot (meaning a prior run exists and has persisted data). First-ever run → no cache
2601
+ // file exists → loadConsoleCache returns false → message prints. Warm re-opens → cache file exists
2602
+ // and loads → message does not print. This is the same definition serveCached() uses.
2603
+ const hadCache = loadConsoleCache();
2604
+ if (!hadCache) {
2605
+ console.log(` ${'first run — the page opens now and narrates its own scan'}`);
2606
+ console.log(` ${"(next time you open this, it's already measured)"}\n`);
2607
+ announceWhenLive(url); // print "it's live — take a look at your page" when the scan lands
2608
+ }
2609
+ // ORDER MATTERS AND IS THE WHOLE POINT (RVBC-INSTANT-SPEC #5). Browser FIRST — the tab is what
2610
+ // the user is waiting for and openBrowser is now fully asynchronous — then the scan, in a
2611
+ // detached child, off this thread entirely.
2612
+ //
2613
+ // WHAT WAS HERE BEFORE, AND WHY IT COST THE OWNER HIS THREE MINUTES: `setTimeout(gatherActivity,
2614
+ // 50)`. Fifty milliseconds after the URL printed — which is to say exactly as the browser was
2615
+ // opening — the server ran the machine-wide fleet scan ON ITS OWN EVENT LOOP: 100+ SQLite stores,
2616
+ // 40s+, during which the brand-new tab could not be answered at all. A blank white page, at the
2617
+ // precise moment a first-time user is deciding whether this thing works. The child does that
2618
+ // scan now (see --refresh-cache), and gatherActivity reports `warming` until it lands.
2619
+ if (open) openBrowser(url);
2620
+ // Browser acceptance pre-warms a disposable HOME and disables only this redundant second scan.
2621
+ // The production default is unchanged: every ordinary console start refreshes in the background.
2622
+ if (process.env.RUVNET_CONSOLE_DISABLE_BACKGROUND_REFRESH !== '1') kickRefresh({ force: true });
2623
+ });
2624
+ return server;
2625
+ }
2626
+
2627
+ // ── CLI ──────────────────────────────────────────────────────────────────────────────────────────
2628
+ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('onboarding-console.mjs')) {
2629
+ const args = process.argv.slice(2);
2630
+ if (args.includes('--print-state')) { console.log(JSON.stringify(gatherState(process.cwd()), null, 2)); }
2631
+ else if (args.includes('--print-stack')) { console.log(JSON.stringify(gatherStack(), null, 2)); }
2632
+ else if (args.includes('--refresh-cache')) {
2633
+ // Runs as a DETACHED CHILD of the server (kickRefresh) — or standalone to pre-warm. Computes the
2634
+ // heavy read-models HERE, in a separate process, so the server's event loop is never blocked, and
2635
+ // writes each cache the moment it is ready (state first — it is what the page paints first).
2636
+ try {
2637
+ let st = gatherState(process.cwd(), { fleet: false });
2638
+ const autoApplyOn = loadSettings().values.autoApply === true;
2639
+ const eligible = autoApplyOn ? autoEligibleIds(st.sections.recommendations) : [];
2640
+ if (eligible.length) {
2641
+ const receipt = apply(eligible);
2642
+ // Re-measure after the mutations. A pre-apply read model must never be stamped as current.
2643
+ st = gatherState(process.cwd(), { fleet: false });
2644
+ st.sections.autoApply = {
2645
+ at: new Date().toISOString(),
2646
+ requested: eligible,
2647
+ results: receipt.results,
2648
+ };
2649
+ }
2650
+ const { token, ...safe } = st;
2651
+ writeCache(STATE_CACHE, st.generatedAt, safe, process.cwd());
2652
+ } catch { /* leave the old cache in place */ }
2653
+ // TASK 3: function-call arguments are evaluated left-to-right, so the previous
2654
+ // `writeCache(STACK_CACHE, new Date().toISOString(), gatherStack())` evaluated the timestamp
2655
+ // BEFORE gatherStack()'s ~22s scan ran — the same bug as the /api/stack handler above, duplicated
2656
+ // here. gatherStack() as its own statement first fixes it the same way.
2657
+ try { const stackData = gatherStack(); writeCache(STACK_CACHE, new Date().toISOString(), stackData); } catch { /* keep prior */ }
2658
+ // Must compute the SAME shape the /api/memory handler does — fleet AND recommendations.
2659
+ //
2660
+ // This wrote fleet-only, so the background refresh silently ERASED the advocacy the handler had
2661
+ // just produced: the first request returned 2 recommendations, the refresh landed, and every
2662
+ // request after it returned 0. The page would have shown the thesis once and then quietly
2663
+ // stopped, which is indistinguishable from "your machine is fine" — the precise failure ADR-027
2664
+ // exists to end, reintroduced by a cache writer that knew about half the payload. Caught by
2665
+ // polling the live endpoint twice instead of once.
2666
+ try {
2667
+ const fleet = scanFleet();
2668
+ let recommendations = [];
2669
+ try {
2670
+ const health = scoreMemoryHealth({ project: path.basename(process.cwd()), probes: probeMemory(process.cwd()) });
2671
+ recommendations = buildHealthRecommendations({ memory: health, learning: { ...observeLearning(), fleet } });
2672
+ } catch { /* advisory only */ }
2673
+ writeCache(MEMORY_CACHE, new Date().toISOString(), { fleet, recommendations }, process.cwd());
2674
+ } catch { /* keep prior */ }
2675
+
2676
+ // CAPABILITY_CACHE WAS NOT IN THIS LIST — the single most consequential omission in the file.
2677
+ //
2678
+ // Measured 2026-07-24: after the freshness ceiling landed, a capability cache past the ceiling was
2679
+ // correctly marked stale and `kickRefresh()` was fired — and this child, the only thing that ever
2680
+ // refreshes anything in the background, did not know CAPABILITY_CACHE existed. Polled every 5s for
2681
+ // a minute: it never came back fresh. It could not. The only other writer is serveCached's COLD
2682
+ // path, which requires the file to be absent, and it never is.
2683
+ //
2684
+ // So capabilities had NO refresher whatsoever. Under the old code that was invisible, because the
2685
+ // cache was served forever while *looking* current — the two-day-old lie was not a stale-cache bug
2686
+ // with an unlucky timestamp, it was this: a read-model nothing was ever going to recompute. The
2687
+ // ceiling did not cause the problem, it EXPOSED it, by turning a silent lie into a visible refusal.
2688
+ //
2689
+ // Found only because a test's PRECONDITION failed: waiting for the cache to become fresh so the
2690
+ // real assertion could run. Had the precondition been assumed rather than checked, the run would
2691
+ // have passed and reported a guarantee that does not exist.
2692
+ try {
2693
+ const { at, data } = computeCapabilities(); // the SAME computer the handler uses
2694
+ writeCache(CAPABILITY_CACHE, at, data, process.cwd());
2695
+ } catch { /* keep prior — a failed audit must never blank the card */ }
2696
+
2697
+ // THE MACHINE-WIDE FLEET SCAN LIVES HERE NOW (RVBC-INSTANT-SPEC #5), and this is the last thing
2698
+ // the child does because it is the longest (100+ SQLite stores, 40s+) and everything above it is
2699
+ // what the page paints first. It used to run on the SERVER's thread — inline on a first-ever
2700
+ // /api/activity, and via a setTimeout 50ms after boot, which is to say while the browser was
2701
+ // opening. loadConsoleCache() first so this process holds the previous trust measurement and
2702
+ // saveConsoleCache's merge has something to preserve.
2703
+ try { loadConsoleCache(); refreshFleetCache(); } catch { /* keep prior — a failed walk must never blank the fleet */ }
2704
+
2705
+ process.exit(0);
2706
+ }
2707
+ else if (args.includes('--serve') || args.length === 0) {
2708
+ // Hitting the command again should land on the console you already have, not spawn a second
2709
+ // server on a random port and a second tab. If one is already up, just point the browser at it.
2710
+ const port = Number(process.env.CONSOLE_PORT) || 7411;
2711
+ const open = args.includes('--open');
2712
+ const url = `http://127.0.0.1:${port}/`;
2713
+ const alive = await new Promise((resolve) => {
2714
+ const req = http.get({ host: '127.0.0.1', port, path: '/', timeout: 800 }, (res) => {
2715
+ let b = ''; res.on('data', (c) => { b += c; if (b.length > 4096) res.destroy(); });
2716
+ res.on('end', () => resolve(res.statusCode === 200 && /RuvNet Brain/.test(b)));
2717
+ res.on('error', () => resolve(false));
2718
+ });
2719
+ req.on('error', () => resolve(false));
2720
+ req.on('timeout', () => { req.destroy(); resolve(false); });
2721
+ });
2722
+ if (alive) {
2723
+ console.log(`\n 🧠 RuvNet Brain — Onboarding Console (already running)\n ${url}\n`);
2724
+ if (open) openBrowser(url);
2725
+ } else { startServer({ port, open, cwd: process.cwd() }); }
2726
+ }
2727
+ else { console.log(`\n onboarding-console — the RuvNet Brain configure page\n\n --serve [--open] start the local server (and open your browser)\n --print-state print the read-only state JSON and exit (for tests)\n --print-stack print the stack audit JSON and exit\n`); }
2728
+ }
2729
+
2730
+ export {
2731
+ gatherState,
2732
+ gatherStack,
2733
+ gatherTrust,
2734
+ wiringSurvey,
2735
+ probeMemory,
2736
+ apply,
2737
+ saveConfig,
2738
+ undo,
2739
+ gatherAdvocacy,
2740
+ saveAdvocacy,
2741
+ gatherBrainPower,
2742
+ saveBrainPower,
2743
+ gatherBrainProfile,
2744
+ saveBrainProfile,
2745
+ autoEligibleIds,
2746
+ };
2747
+ // Exported for the cross-project cache-isolation test (console-cache-scope.test.mjs). serveCached's
2748
+ // scopeKey is the guard that stops one project's cached state being served for another.
2749
+ export { serveCached, writeCache, kickRefresh };