ruvnet-brain 4.0.1 → 4.0.4

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