ruvnet-brain 4.0.2 → 4.0.5

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 (46) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +215 -31
  3. package/docs/RELEASE-NOTES-4.0.md +1 -1
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/commands/whats-new.md +6 -6
  8. package/plugin/docs/RELEASE-NOTES-4.0.md +88 -0
  9. package/plugin/mcp/server.mjs +70 -33
  10. package/plugin/scripts/hook-shim.mjs +27 -22
  11. package/plugin/scripts/lesson-command-scope.mjs +133 -0
  12. package/plugin/scripts/lesson-gate.mjs +401 -0
  13. package/plugin/scripts/lesson-presentation.mjs +99 -0
  14. package/plugin/scripts/lesson-store.mjs +452 -0
  15. package/plugin/scripts/route-dispatch.sh +1 -0
  16. package/plugin/scripts/session-start-core.mjs +28 -2
  17. package/plugin/scripts/verify-interface.sh +1 -0
  18. package/plugin/scripts/whats-new.mjs +42 -0
  19. package/plugin/skills/release-proof/SKILL.md +21 -4
  20. package/plugin/skills/release-proof/references/receipt-contract.md +8 -2
  21. package/plugin/skills/release-proof/scripts/release-proof.mjs +77 -1
  22. package/plugin/skills/ruvnet-brain/SKILL.md +22 -7
  23. package/plugin/skills/whats-new/SKILL.md +4 -4
  24. package/scripts/build-bundle.mjs +34 -25
  25. package/scripts/fix-workstream.mjs +291 -0
  26. package/scripts/health-repair.mjs +14 -27
  27. package/scripts/issue-fix.mjs +135 -216
  28. package/scripts/learning-replay-cli.mjs +236 -0
  29. package/scripts/learning-replay-contract.mjs +255 -0
  30. package/scripts/learning-replay-execution.mjs +193 -0
  31. package/scripts/learning-replay-fixture.mjs +380 -0
  32. package/scripts/learning-replay-proof.mjs +459 -0
  33. package/scripts/learning-replay.mjs +10 -1565
  34. package/scripts/lesson-gate.mjs +3 -679
  35. package/scripts/lesson-store.mjs +4 -447
  36. package/scripts/memory-doctor.mjs +80 -9
  37. package/scripts/nightly-wrapper.sh +20 -28
  38. package/scripts/onboarding-console.mjs +302 -95
  39. package/scripts/protected-release-invocation.mjs +76 -0
  40. package/scripts/publication-receipt.mjs +307 -0
  41. package/scripts/release-authority.mjs +93 -0
  42. package/scripts/release-vector.mjs +49 -25
  43. package/scripts/release.mjs +55 -11
  44. package/scripts/self-update.mjs +15 -227
  45. package/scripts/stabilization-receipt.mjs +108 -0
  46. package/scripts/wired-check.mjs +3 -0
@@ -27,7 +27,7 @@ import { fileURLToPath } from 'node:url';
27
27
  import { spawnSync, execFileSync, spawn } from 'node:child_process';
28
28
 
29
29
  import { auditModel, installedVersion } from './stack-sync.mjs';
30
- import { findStores, diagnose } from './memory-doctor.mjs';
30
+ import { candidateRoots, findStores, diagnose } from './memory-doctor.mjs';
31
31
  import { buildStackRecommendations, buildWiringRecommendations, summarizeWiring, scoreMemoryHealth, buildHealthRecommendations, buildCapabilityRecommendations } from './console-engine.mjs';
32
32
  import { planFor } from './remedy-registry.mjs';
33
33
  import { auditAll as capabilityAuditAll } from './capability-registry.mjs';
@@ -69,16 +69,230 @@ import { applyNightlyChoice, nightlyStatus } from './nightly-controller.mjs';
69
69
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
70
70
  const REPO = path.dirname(__dirname);
71
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');
72
+ const SYSTEM_HOME = os.homedir();
73
+
74
+ /**
75
+ * The console's explicit root boundary. It scopes only console-owned configuration, state, cache,
76
+ * and discovery; global binaries, account credentials, and external tool state retain SYSTEM_HOME.
77
+ */
78
+ export function consoleRootFromEnvironment(env = process.env, systemHome = SYSTEM_HOME) {
79
+ const configured = env.RUVNET_CONSOLE_ROOT;
80
+ if (!configured) return systemHome;
81
+ if (typeof configured !== 'string' || !path.isAbsolute(configured)) {
82
+ throw new Error('RUVNET_CONSOLE_ROOT must be an absolute path');
83
+ }
84
+ return path.resolve(configured);
85
+ }
86
+
87
+ // A test or an embedded caller can isolate the console's owned files and discovery roots without
88
+ // redefining the operating-system home directory. Normal launches retain the OS home exactly.
89
+ const CONSOLE_ROOT = consoleRootFromEnvironment();
90
+ const NPM_PREFIX = path.join(SYSTEM_HOME, '.npm-global');
91
+ const CONFIG_DIR = path.join(CONSOLE_ROOT, '.claude/ruvnet-brain');
75
92
  const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
76
- const UNDO_JOURNAL = path.join(HOME, '.cache/ruvnet-brain/console-undo.jsonl');
93
+ const UNDO_JOURNAL = path.join(CONSOLE_ROOT, '.cache/ruvnet-brain/console-undo.jsonl');
77
94
  const INSTALLED_KB = process.env.RUVNET_BRAIN_KB
78
- || path.join(HOME, '.cache', 'ruvnet-brain', 'kb');
95
+ || path.join(CONSOLE_ROOT, '.cache', 'ruvnet-brain', 'kb');
79
96
  const COMPLETE_BRAIN_SOURCE = process.env.RUVNET_BRAIN_COMPLETE_SOURCE
80
97
  || path.join(REPO, 'dist', 'ruvnet-brain');
81
98
  const TOKEN = crypto.randomBytes(24).toString('hex');
99
+ const RUNTIME_RECEIPT_DIR = path.join(CONSOLE_ROOT, '.cache', 'ruvnet-brain', 'console-instances');
100
+ const RUNTIME_PRODUCT = 'ruvnet-brain-console';
101
+ const RUNTIME_SCHEMA = 1;
102
+ const RUNTIME_API_CONTRACT = 1;
103
+ const RUNTIME_SCRIPT = fs.realpathSync(fileURLToPath(import.meta.url));
104
+ const RUNTIME_SOURCE_SHA256 = crypto.createHash('sha256').update(fs.readFileSync(RUNTIME_SCRIPT)).digest('hex');
105
+
106
+ function consoleCandidateRoots() {
107
+ return candidateRoots({ home: CONSOLE_ROOT, configPath: CONFIG_PATH });
108
+ }
109
+
110
+ function canonicalScope(cwd = process.cwd()) {
111
+ try { return fs.realpathSync(cwd); } catch { return path.resolve(cwd); }
112
+ }
113
+
114
+ function runtimeReceiptPath(cwd = process.cwd()) {
115
+ const scope = canonicalScope(cwd);
116
+ const scopeId = crypto.createHash('sha256').update(scope).digest('hex').slice(0, 24);
117
+ return path.join(RUNTIME_RECEIPT_DIR, `${scopeId}.json`);
118
+ }
119
+
120
+ function runtimeIdentity({ port, cwd = process.cwd(), pid = process.pid, startedAt = new Date().toISOString() }) {
121
+ return {
122
+ product: RUNTIME_PRODUCT,
123
+ schema: RUNTIME_SCHEMA,
124
+ apiContract: RUNTIME_API_CONTRACT,
125
+ pid,
126
+ port,
127
+ startedAt,
128
+ scope: canonicalScope(cwd),
129
+ scriptRealpath: RUNTIME_SCRIPT,
130
+ runtimeVersion: brainVersionOnDisk(),
131
+ sourceSha256: RUNTIME_SOURCE_SHA256,
132
+ };
133
+ }
134
+
135
+ function validRuntimeReceipt(receipt, file) {
136
+ if (!receipt || typeof receipt !== 'object') return false;
137
+ if (process.platform !== 'win32') {
138
+ try { if ((fs.statSync(file).mode & 0o777) !== 0o600) return false; } catch { return false; }
139
+ }
140
+ return receipt.product === RUNTIME_PRODUCT
141
+ && receipt.schema === RUNTIME_SCHEMA
142
+ && receipt.apiContract === RUNTIME_API_CONTRACT
143
+ && Number.isInteger(receipt.pid) && receipt.pid > 0
144
+ && Number.isInteger(receipt.port) && receipt.port > 0 && receipt.port <= 65535
145
+ && typeof receipt.startedAt === 'string'
146
+ && typeof receipt.scope === 'string'
147
+ && typeof receipt.scriptRealpath === 'string'
148
+ && typeof receipt.runtimeVersion === 'string'
149
+ && /^[a-f0-9]{64}$/.test(receipt.sourceSha256 || '')
150
+ && /^[a-f0-9]{48}$/.test(receipt.controlToken || '');
151
+ }
152
+
153
+ function publicRuntimeIdentity(receipt) {
154
+ const { controlToken: _secret, ...identity } = receipt;
155
+ return identity;
156
+ }
157
+
158
+ function writeRuntimeReceipt(receipt, file = runtimeReceiptPath(receipt.scope)) {
159
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
160
+ if (process.platform !== 'win32') fs.chmodSync(path.dirname(file), 0o700);
161
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
162
+ try {
163
+ fs.writeFileSync(tmp, `${JSON.stringify(receipt)}\n`, { mode: 0o600, flag: 'wx' });
164
+ if (process.platform !== 'win32') fs.chmodSync(tmp, 0o600);
165
+ fs.renameSync(tmp, file);
166
+ } finally {
167
+ try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch { /* best effort */ }
168
+ }
169
+ return file;
170
+ }
171
+
172
+ function removeOwnedRuntimeReceipt(file, controlToken) {
173
+ const current = readJSON(file);
174
+ if (!current || current.controlToken !== controlToken) return false;
175
+ try { fs.unlinkSync(file); return true; } catch { return false; }
176
+ }
177
+
178
+ function sameRuntimeIdentity(left, right) {
179
+ return ['product', 'schema', 'apiContract', 'pid', 'port', 'startedAt', 'scope',
180
+ 'scriptRealpath', 'runtimeVersion', 'sourceSha256'].every((key) => left?.[key] === right?.[key]);
181
+ }
182
+
183
+ function probeRuntime(port, timeout = 800) {
184
+ return new Promise((resolve) => {
185
+ const req = http.get({ host: '127.0.0.1', port, path: '/api/runtime', timeout }, (res) => {
186
+ let body = '';
187
+ res.on('data', (chunk) => {
188
+ body += chunk;
189
+ if (body.length > 16_384) req.destroy();
190
+ });
191
+ res.on('end', () => {
192
+ if (res.statusCode !== 200) return resolve(null);
193
+ try { resolve(JSON.parse(body)); } catch { resolve(null); }
194
+ });
195
+ res.on('error', () => resolve(null));
196
+ });
197
+ req.on('error', () => resolve(null));
198
+ req.on('timeout', () => { req.destroy(); resolve(null); });
199
+ });
200
+ }
201
+
202
+ function probeHttpEndpoint(port, endpoint = '/api/runtime', timeout = 800) {
203
+ return new Promise((resolve) => {
204
+ const req = http.get({ host: '127.0.0.1', port, path: endpoint, timeout }, (res) => {
205
+ res.resume();
206
+ res.on('end', () => resolve({ reachable: true, status: res.statusCode }));
207
+ });
208
+ req.on('error', () => resolve({ reachable: false, status: null }));
209
+ req.on('timeout', () => { req.destroy(); resolve({ reachable: false, status: null }); });
210
+ });
211
+ }
212
+
213
+ function requestRuntimeShutdown(port, controlToken, timeout = 1_500) {
214
+ return new Promise((resolve) => {
215
+ const body = JSON.stringify({ controlToken });
216
+ const req = http.request({
217
+ host: '127.0.0.1', port, path: '/api/runtime/shutdown', method: 'POST', timeout,
218
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
219
+ }, (res) => {
220
+ res.resume();
221
+ res.on('end', () => resolve(res.statusCode === 202));
222
+ });
223
+ req.on('error', () => resolve(false));
224
+ req.on('timeout', () => { req.destroy(); resolve(false); });
225
+ req.end(body);
226
+ });
227
+ }
228
+
229
+ async function waitForRuntimeToStop(port, timeout = 3_000) {
230
+ const until = Date.now() + timeout;
231
+ while (Date.now() < until) {
232
+ if (!await probeRuntime(port, 150)) return true;
233
+ await new Promise((resolve) => setTimeout(resolve, 50));
234
+ }
235
+ return false;
236
+ }
237
+
238
+ async function inspectConsoleRuntime({ cwd = process.cwd(), preferredPort = Number(process.env.CONSOLE_PORT) || 7411 } = {}) {
239
+ const receiptFile = runtimeReceiptPath(cwd);
240
+ if (!fs.existsSync(receiptFile)) {
241
+ const endpoint = await probeHttpEndpoint(preferredPort);
242
+ return endpoint.reachable
243
+ ? { state: 'foreign-port', scope: canonicalScope(cwd), port: preferredPort }
244
+ : { state: 'not-running', scope: canonicalScope(cwd) };
245
+ }
246
+ const receipt = readJSON(receiptFile);
247
+ if (!validRuntimeReceipt(receipt, receiptFile)) return { state: 'receipt-invalid', scope: canonicalScope(cwd) };
248
+ const live = await probeRuntime(receipt.port);
249
+ if (!live) {
250
+ const endpoint = await probeHttpEndpoint(receipt.port);
251
+ return endpoint.reachable
252
+ ? { state: 'legacy-unowned', scope: canonicalScope(cwd), port: receipt.port }
253
+ : { state: 'stale-receipt', scope: canonicalScope(cwd), port: receipt.port };
254
+ }
255
+ if (!sameRuntimeIdentity(live, publicRuntimeIdentity(receipt))) {
256
+ return { state: 'legacy-unowned', scope: canonicalScope(cwd), port: receipt.port };
257
+ }
258
+ const candidate = runtimeIdentity({ port: receipt.port, cwd, pid: receipt.pid, startedAt: receipt.startedAt });
259
+ const current = sameRuntimeIdentity(live, candidate);
260
+ return {
261
+ state: current ? 'current' : 'stale-running',
262
+ scope: canonicalScope(cwd),
263
+ port: receipt.port,
264
+ live,
265
+ candidate: publicRuntimeIdentity(candidate),
266
+ };
267
+ }
268
+
269
+ async function launchConsole({ port = Number(process.env.CONSOLE_PORT) || 7411, open = false, cwd = process.cwd() } = {}) {
270
+ const status = await inspectConsoleRuntime({ cwd, preferredPort: port });
271
+ if (status.state === 'current') {
272
+ const url = `http://127.0.0.1:${status.port}/`;
273
+ console.log(`\n 🧠 RuvNet Brain — Onboarding Console (already running)\n ${url}\n`);
274
+ if (open) openBrowser(url);
275
+ return { reused: true, port: status.port };
276
+ }
277
+
278
+ if (status.state === 'stale-running') {
279
+ const receiptFile = runtimeReceiptPath(cwd);
280
+ const receipt = readJSON(receiptFile);
281
+ const stopped = validRuntimeReceipt(receipt, receiptFile)
282
+ && await requestRuntimeShutdown(receipt.port, receipt.controlToken)
283
+ && await waitForRuntimeToStop(receipt.port);
284
+ if (stopped) {
285
+ console.log(` replacing owned stale Console on port ${receipt.port}…`);
286
+ return { reused: false, server: startServer({ port: receipt.port, open, cwd }) };
287
+ }
288
+ console.error(' owned stale Console did not release its port — starting the current Console separately');
289
+ }
290
+
291
+ if (status.state === 'stale-receipt' || status.state === 'receipt-invalid') {
292
+ try { fs.unlinkSync(runtimeReceiptPath(cwd)); } catch { /* already gone or unreadable */ }
293
+ }
294
+ return { reused: false, server: startServer({ port, open, cwd }) };
295
+ }
82
296
 
83
297
  const NPX_RUV = /npx\s+(?:-y\s+|--yes\s+)?(?:@claude-flow\/[\w-]+|claude-flow|ruflo|ruvector|ruv-swarm|flow-nexus|metaharness|@metaharness\/[\w-]+|agentic-qe|aqe)(?:@[\w.-]+)?/;
84
298
 
@@ -123,27 +337,9 @@ function robustReadJSON(db, sql) {
123
337
  const VENDOR = ['/clones/', '/node_modules/', '/vendor/', '/upstream/', '.claude-backup', '_snapshots',
124
338
  '/ruvnet-repos/', '/ruvnet_repos/'];
125
339
 
126
- // ── Candidate scan roots (issue #19) ────────────────────────────────────────────────────────────
127
- // A single hardcoded `~/Code` silently reports "0" on any machine that keeps projects somewhere
128
- // else (a reporter's `~/source`, `~/dev`, `~/work`, …) — a confident zero that just means "didn't
129
- // look in the right place". Scan every root that actually exists on THIS machine, plus a user
130
- // override in config.json (`scanRoots`, absolute paths or relative to $HOME) when present.
131
- const DEFAULT_SCAN_ROOTS = ['Code', 'code', 'src', 'source', 'projects', 'dev', 'work'];
132
- function candidateRoots() {
133
- const cfg = readJSON(CONFIG_PATH) || {};
134
- const configured = Array.isArray(cfg.scanRoots) && cfg.scanRoots.length > 0
135
- ? cfg.scanRoots.map((r) => (path.isAbsolute(r) ? r : path.join(HOME, r)))
136
- : DEFAULT_SCAN_ROOTS.map((d) => path.join(HOME, d));
137
- const seen = new Set();
138
- const roots = [];
139
- for (const r of configured) {
140
- const resolved = path.resolve(r);
141
- if (seen.has(resolved)) continue;
142
- seen.add(resolved);
143
- try { if (fs.statSync(resolved).isDirectory()) roots.push(resolved); } catch { /* doesn't exist on this machine — skip silently */ }
144
- }
145
- return roots;
146
- }
340
+ // memory-doctor.mjs owns candidate-root policy for the standalone CLI, Console, and other callers.
341
+ // Keeping one exported implementation prevents a new project-root convention from fixing one
342
+ // surface while another continues to print a confident but incomplete machine-wide count (#81).
147
343
  function findProjects(root) {
148
344
  const out = new Set();
149
345
  const walk = (dir, depth) => {
@@ -181,7 +377,7 @@ function wiringSurvey() {
181
377
  // must never count the same project twice.
182
378
  const seenProjects = new Set();
183
379
  const projects = [];
184
- for (const root of candidateRoots()) {
380
+ for (const root of consoleCandidateRoots()) {
185
381
  for (const proj of findProjects(root)) {
186
382
  const resolved = path.resolve(proj);
187
383
  if (seenProjects.has(resolved)) continue;
@@ -219,7 +415,7 @@ function wiringSurvey() {
219
415
 
220
416
  // ── Memory health (read-only probes for the project the console was launched from) ────────────────
221
417
  function sessionHookExists() {
222
- return fs.existsSync(path.join(HOME, '.claude/hooks/agentdb-ensure.sh')) || fs.existsSync(path.join(HOME, '.claude/hooks'));
418
+ return fs.existsSync(path.join(CONSOLE_ROOT, '.claude/hooks/agentdb-ensure.sh')) || fs.existsSync(path.join(CONSOLE_ROOT, '.claude/hooks'));
223
419
  }
224
420
  function probeMemory(projectDir) {
225
421
  const db = path.join(projectDir, '.swarm/memory.db');
@@ -273,19 +469,7 @@ function probeMemory(projectDir) {
273
469
  // machine has 100+. That is far too slow to sit on the page's first paint, so it is its own endpoint
274
470
  // (/api/memory) and hydrates late, exactly like the stack audit does.
275
471
  function scanFleet() {
276
- // memory-doctor.mjs's findStores() defaults to ~/Code and cannot be edited here (issue #19) — so
277
- // pass it every candidate root explicitly and de-dupe (it also always appends a couple of known
278
- // extra paths regardless of root, which the Set below folds together instead of duplicating).
279
- const seen = new Set();
280
- const stores = [];
281
- for (const root of candidateRoots()) {
282
- for (const db of findStores(root)) {
283
- const resolved = path.resolve(db);
284
- if (seen.has(resolved)) continue;
285
- seen.add(resolved);
286
- stores.push(db);
287
- }
288
- }
472
+ const stores = findStores();
289
473
  const fleet = [];
290
474
  for (const db of stores) {
291
475
  const d = diagnose(db);
@@ -299,7 +483,7 @@ function scanFleet() {
299
483
  function gatherMemory(cwd, { fleet = true } = {}) {
300
484
  // health = for the project the console was launched from (fall back to this repo)
301
485
  const project = fs.existsSync(path.join(cwd, '.swarm/memory.db')) ? cwd : REPO;
302
- const projName = project.replace(HOME + '/Code/', '').replace(HOME + '/', '~/');
486
+ const projName = project.replace(CONSOLE_ROOT + '/Code/', '').replace(CONSOLE_ROOT + '/', '~/');
303
487
  const health = scoreMemoryHealth({ project: projName, probes: probeMemory(project) });
304
488
  return { fleet: fleet ? scanFleet() : null, health };
305
489
  }
@@ -308,10 +492,10 @@ function gatherMemory(cwd, { fleet = true } = {}) {
308
492
  function gatherSavings() {
309
493
  // Primary source is the real routing-receipts ledger written by scripts/route-cheap.mjs.
310
494
  const files = [
311
- path.join(HOME, '.claude/metaharness/routing-receipts.jsonl'),
312
- path.join(HOME, '.cache/ruvnet-brain/metaharness-receipts.jsonl'),
495
+ path.join(SYSTEM_HOME, '.claude/metaharness/routing-receipts.jsonl'),
496
+ path.join(SYSTEM_HOME, '.cache/ruvnet-brain/metaharness-receipts.jsonl'),
313
497
  // Canonical user-level ledger (issue #36 — the hooks no longer scatter per-CWD copies).
314
- path.join(HOME, '.cache/ruvnet-brain/token-ledger.jsonl'),
498
+ path.join(SYSTEM_HOME, '.cache/ruvnet-brain/token-ledger.jsonl'),
315
499
  // Legacy location, still read so an existing user's history is not orphaned by the move.
316
500
  path.join(REPO, 'plugin/scripts/.ruvnet-brain/token-ledger.jsonl'),
317
501
  ];
@@ -415,7 +599,7 @@ function gatherConfig() {
415
599
  });
416
600
  }
417
601
  return {
418
- path: CONFIG_PATH.replace(HOME, '~'),
602
+ path: CONFIG_PATH.replace(CONSOLE_ROOT, '~'),
419
603
  exists: fs.existsSync(CONFIG_PATH),
420
604
  values: {
421
605
  openrouterKey: credential.configured, // boolean only — never the secret itself
@@ -464,7 +648,7 @@ function gatherAdvocacy() {
464
648
  const chosen = raw && typeof raw === 'object' && raw.settings && typeof raw.settings === 'object'
465
649
  ? raw.settings : {};
466
650
  return {
467
- path: state.path.replace(HOME, '~'),
651
+ path: state.path.replace(CONSOLE_ROOT, '~'),
468
652
  exists: state.exists,
469
653
  values: Object.fromEntries(LIVE_USER_FIELDS.map((field) => [
470
654
  field.key,
@@ -512,7 +696,7 @@ function saveAdvocacy(values) {
512
696
  publishSettingsToCache();
513
697
  return {
514
698
  ok: true,
515
- backup: result.backup ? result.backup.replace(HOME, '~') : null,
699
+ backup: result.backup ? result.backup.replace(CONSOLE_ROOT, '~') : null,
516
700
  values: Object.fromEntries(LIVE_USER_SETTING_KEYS.map((key) => [key, result.values[key]])),
517
701
  log: result.log,
518
702
  };
@@ -552,7 +736,7 @@ function gatherBrainProfile() {
552
736
  const source = measureBrainProfile(COMPLETE_BRAIN_SOURCE);
553
737
  const updaterAvailable = fs.existsSync(path.join(INSTALLED_KB, 'forge-update.mjs'));
554
738
  return {
555
- path: INSTALLED_KB.replace(HOME, '~'),
739
+ path: INSTALLED_KB.replace(CONSOLE_ROOT, '~'),
556
740
  values: { brainProfile: actual },
557
741
  stored: settings.values.brainProfile,
558
742
  disagreement: settings.values.brainProfile !== actual,
@@ -573,7 +757,7 @@ function gatherBrainProfile() {
573
757
  bytes: installed.byStore.ruvector ?? source.byStore.ruvector ?? null,
574
758
  },
575
759
  },
576
- restoreSource: COMPLETE_BRAIN_SOURCE.replace(HOME, '~'),
760
+ restoreSource: COMPLETE_BRAIN_SOURCE.replace(CONSOLE_ROOT, '~'),
577
761
  };
578
762
  }
579
763
 
@@ -584,7 +768,7 @@ function gatherBrainPower() {
584
768
  off: state.off,
585
769
  since: state.since,
586
770
  reason: state.reason,
587
- switchPath: state.path.replace(HOME, '~'),
771
+ switchPath: state.path.replace(CONSOLE_ROOT, '~'),
588
772
  // The RESOLVED answer — what the machine actually does — not the mirror's opinion of it.
589
773
  values: { brainEnabled: !state.off },
590
774
  defaults: { brainEnabled: BRAIN_FIELD.default },
@@ -666,7 +850,7 @@ function saveBrainProfile(values) {
666
850
  removed: changed.removed || [],
667
851
  bytesFreed: changed.bytesFreed || 0,
668
852
  mirrored: mirrored.ok,
669
- backup: mirrored.backup ? mirrored.backup.replace(HOME, '~') : null,
853
+ backup: mirrored.backup ? mirrored.backup.replace(CONSOLE_ROOT, '~') : null,
670
854
  log: mirrored.ok
671
855
  ? (profile === PROFILE_RUVECTOR
672
856
  ? `RuVector Only is active — ${changed.removed.length} unselected artifact(s) removed`
@@ -754,7 +938,7 @@ function saveBrainPower(values) {
754
938
  ok: true,
755
939
  off: state.off,
756
940
  since: state.since,
757
- backup: mirrored.backup ? mirrored.backup.replace(HOME, '~') : null,
941
+ backup: mirrored.backup ? mirrored.backup.replace(CONSOLE_ROOT, '~') : null,
758
942
  values: { brainEnabled: !state.off },
759
943
  // Honest about the half-failure rather than reporting a clean success: the switch is what counts
760
944
  // and it moved, but say so plainly if the visible record did not follow it.
@@ -924,7 +1108,7 @@ function setLesson(body) {
924
1108
  // setImmediate either (deferring synchronous work still blocks the loop when it runs). A machine with
925
1109
  // no fleet scan yet is reported `warming`, never as a fabricated zero.
926
1110
  let ACTIVITY_MACHINE_CACHE = null;
927
- const CONSOLE_CACHE_PATH = path.join(HOME, '.cache/ruvnet-brain/console-cache.json');
1111
+ const CONSOLE_CACHE_PATH = path.join(CONSOLE_ROOT, '.cache/ruvnet-brain/console-cache.json');
928
1112
 
929
1113
  // ── Warm-cache serving (2026-07-17, the demo-hang fix) ─────────────────────────────────────────────
930
1114
  // Every read-model here does multi-second synchronous work: gatherState ~13s, gatherStack ~22s,
@@ -1374,7 +1558,7 @@ function refreshFleetCache() {
1374
1558
  const seen = new Set();
1375
1559
  // Scan every candidate root (issue #19) — this is what made machine-wide totals read 0 on a
1376
1560
  // machine whose projects live under ~/source instead of ~/Code.
1377
- for (const root of candidateRoots()) {
1561
+ for (const root of consoleCandidateRoots()) {
1378
1562
  for (const s of findMemoryStores(root)) {
1379
1563
  const resolved = path.resolve(s.project);
1380
1564
  if (seen.has(resolved)) continue; // a project visible under two roots (e.g. a symlink) counts once
@@ -1653,11 +1837,11 @@ function brainVersionOnDisk() {
1653
1837
  try { return getVersion(); } catch { return null; }
1654
1838
  }
1655
1839
  function readInstallChannel() {
1656
- const reg = readJSON(path.join(HOME, '.claude/plugins/installed_plugins.json'));
1840
+ const reg = readJSON(path.join(SYSTEM_HOME, '.claude/plugins/installed_plugins.json'));
1657
1841
  const entries = reg && reg.plugins && reg.plugins['ruvnet-brain@ruvnet-brain'];
1658
1842
  const e = Array.isArray(entries) ? entries[0] : null;
1659
1843
  if (!e || !e.installPath || !fs.existsSync(e.installPath)) return { installed: false };
1660
- const km = readJSON(path.join(HOME, '.claude/plugins/known_marketplaces.json'));
1844
+ const km = readJSON(path.join(SYSTEM_HOME, '.claude/plugins/known_marketplaces.json'));
1661
1845
  const src = km && km['ruvnet-brain'] && km['ruvnet-brain'].source;
1662
1846
  const pinned = !!(src && (src.ref || src.tag || src.commit)); // no pin recorded → tracking latest
1663
1847
  return {
@@ -1665,7 +1849,7 @@ function readInstallChannel() {
1665
1849
  version: path.basename(e.installPath) || e.version || null, // the plugin cache version dir IS the truth
1666
1850
  channel: pinned ? 'pinned' : 'latest',
1667
1851
  lastUpdated: e.lastUpdated || null,
1668
- cacheDir: String(e.installPath).replace(HOME, '~'),
1852
+ cacheDir: String(e.installPath).replace(SYSTEM_HOME, '~'),
1669
1853
  repo: (src && src.repo) || null,
1670
1854
  };
1671
1855
  }
@@ -1806,7 +1990,7 @@ function gatherState(cwd, { fleet = true } = {}) {
1806
1990
  // agentic-flow, and the Savings card claiming "Smart routing: ON" while the Capabilities card said
1807
1991
  // "not installed" — in the same render — was the contradiction that made the whole page untrustworthy.
1808
1992
  // The card now carries the same measurement the capability row uses, so the two cannot disagree.
1809
- savings.routingInstalled = fs.existsSync(path.join(HOME, '.npm-global/bin/agentic-flow'));
1993
+ savings.routingInstalled = fs.existsSync(path.join(SYSTEM_HOME, '.npm-global/bin/agentic-flow'));
1810
1994
  try { savings.routerEngine = gatherRouterEngine(); } catch { savings.routerEngine = null; }
1811
1995
  try {
1812
1996
  const cat = loadCatalog();
@@ -1852,7 +2036,7 @@ function gatherState(cwd, { fleet = true } = {}) {
1852
2036
  token: TOKEN,
1853
2037
  generatedAt: new Date().toISOString(),
1854
2038
  preStateHash,
1855
- host: { user: os.userInfo().username, platform: process.platform, node: process.version, npmPrefix: NPM_PREFIX.replace(HOME, '~'), brainVersion: brainVersionOnDisk() },
2039
+ host: { user: os.userInfo().username, platform: process.platform, node: process.version, npmPrefix: NPM_PREFIX.replace(SYSTEM_HOME, '~'), brainVersion: brainVersionOnDisk() },
1856
2040
  sections: { wiring, memory, savings, config, userSettings, brainPower, gates, recommendations },
1857
2041
  };
1858
2042
  // Cache the last good state so repeat page-loads paint instantly, same as the stack audit does.
@@ -1873,7 +2057,7 @@ function gatherStack() {
1873
2057
  // ISSUE #22 — carry `source` ('npm-global' | 'plugin') + marketplace through so the console can show
1874
2058
  // (and count) tools installed via the Claude Code plugin marketplace, not just `npm install -g` ones.
1875
2059
  const rows = a.rows.map((r) => ({ name: r.name, installed: r.installed, target: r.target, tag: r.tag, state: r.state, source: r.source ?? 'npm-global', marketplace: r.marketplace ?? null }));
1876
- const shadows = a.shadows.map((s) => ({ name: s.name, version: s.version, global: s.global, dir: String(s.dir).replace(HOME, '~'), stale: !!(s.global && s.version !== s.global) }));
2060
+ const shadows = a.shadows.map((s) => ({ name: s.name, version: s.version, global: s.global, dir: String(s.dir).replace(SYSTEM_HOME, '~'), stale: !!(s.global && s.version !== s.global) }));
1877
2061
  const by = (st) => rows.filter((r) => r.state === st).length;
1878
2062
  const summary = { total: rows.length, behind: by('BEHIND'), broken: by('BROKEN'), ahead: by('AHEAD'), current: by('CURRENT'), unresolved: by('UNRESOLVED'), shadows: shadows.length, stale: a.stale.length };
1879
2063
  const recommendations = buildStackRecommendations({ rows: a.rows, stale: a.stale });
@@ -1901,29 +2085,32 @@ function runNode(scriptRelPath, args) {
1901
2085
  const r = spawnSync(process.execPath, [path.join(REPO, scriptRelPath), ...args], { encoding: 'utf8', timeout: 16 * 60 * 1000, cwd: REPO });
1902
2086
  return { ok: r.status === 0, code: r.status, log: `${r.stdout || ''}${r.stderr || ''}`.trim().slice(-4000) };
1903
2087
  }
2088
+ function elapsedMs(startedAt) {
2089
+ return Math.round((performance.now() - startedAt) * 1000) / 1000;
2090
+ }
1904
2091
 
1905
2092
  // A wiring recommendation's `project` id is relative to whichever candidate root it was found under
1906
2093
  // (issue #19) — reconstruct the absolute path by checking each root, so reconcile/undo can act on a
1907
2094
  // project under ~/source just as well as one under ~/Code.
1908
2095
  function resolveProjectDir(project) {
1909
- for (const root of candidateRoots()) {
2096
+ for (const root of consoleCandidateRoots()) {
1910
2097
  const p = path.join(root, project);
1911
2098
  if (fs.existsSync(p)) return p;
1912
2099
  }
1913
- return path.join(HOME, 'Code', project); // last-resort fallback: the previous fixed behavior
2100
+ return path.join(CONSOLE_ROOT, 'Code', project); // last-resort fallback: the previous fixed behavior
1914
2101
  }
1915
2102
  // Re-derive the currently-valid recommendation set, so apply can only ever act on something STILL true.
1916
2103
  /**
1917
2104
  * Observe the learner's REAL state, for the health recommendations.
1918
2105
  *
1919
- * Deliberately reads the GLOBAL learner (`cwd: HOME`), because that is the store the capture flush
2106
+ * Deliberately reads the global learner, because that is the store the capture flush
1920
2107
  * actually writes to. Reading the project-local `.claude-flow/neural` instead is exactly the mistake
1921
2108
  * that made the console display a dead learner (5 trajectories, last trained 6 days earlier) while
1922
2109
  * the live one held 412 — rUv documents this fragmentation as issue #2245, "four contradictory
1923
2110
  * sources". Until it is unified upstream we read the store that learning writes, never the corpse.
1924
2111
  */
1925
2112
  function observeLearning() {
1926
- const queueDir = path.join(os.homedir(), '.cache', 'ruvnet-brain', 'learn');
2113
+ const queueDir = path.join(SYSTEM_HOME, '.cache', 'ruvnet-brain', 'learn');
1927
2114
  let queueDepth = 0;
1928
2115
  try {
1929
2116
  for (const f of fs.readdirSync(queueDir)) {
@@ -1934,10 +2121,10 @@ function observeLearning() {
1934
2121
 
1935
2122
  let lastTrainSeconds = null; let trajectories = 0;
1936
2123
  try {
1937
- const r = spawnSync(path.join(os.homedir(), '.npm-global/bin/ruflo'),
2124
+ const r = spawnSync(path.join(SYSTEM_HOME, '.npm-global/bin/ruflo'),
1938
2125
  ['hooks', 'intelligence', '--status'],
1939
2126
  {
1940
- cwd: os.homedir(),
2127
+ cwd: SYSTEM_HOME,
1941
2128
  env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
1942
2129
  encoding: 'utf8',
1943
2130
  timeout: 20_000,
@@ -2004,11 +2191,15 @@ function currentValidIds(onlyId = null) {
2004
2191
  return { ids, auditRows };
2005
2192
  }
2006
2193
  function apply(ids) {
2194
+ const startedAt = performance.now();
2195
+ const phaseMs = { revalidationMs: 0, undoJournalMs: 0, childRemedyMs: 0 };
2007
2196
  const results = [];
2008
2197
  for (const id of ids) {
2009
2198
  // Re-read immediately before EACH fix. A batch can change the validity of the next item; one
2010
2199
  // pre-flight snapshot for the whole list would let item 2 run against the world item 1 changed.
2200
+ const revalidationStartedAt = performance.now();
2011
2201
  const { ids: validNow } = currentValidIds(id);
2202
+ phaseMs.revalidationMs += elapsedMs(revalidationStartedAt);
2012
2203
  if (!validNow.has(id)) { results.push({ id, ok: false, skipped: true, error: 'worldMoved', log: 'Skipped — this is already resolved, or your machine changed since the page loaded. Nothing was done. Reload to see the current state.' }); continue; }
2013
2204
 
2014
2205
  // ONE dispatch, through the registry (scripts/remedy-registry.mjs). This used to be a chain of
@@ -2050,16 +2241,22 @@ function apply(ids) {
2050
2241
  // flag exists so it cannot recur here.
2051
2242
  if (plan.exec.usesServerProject) args = [...args, '--project', process.cwd()];
2052
2243
  if (plan.exec.needsReceipt) {
2053
- const receipt = path.join(HOME, '.cache', 'ruvnet-brain', 'undo', `${plan.key}-${stamp()}.json`);
2244
+ const receipt = path.join(CONSOLE_ROOT, '.cache', 'ruvnet-brain', 'undo', `${plan.key}-${stamp()}.json`);
2054
2245
  undoSpec.receipt = receipt;
2055
2246
  args = [...args, '--receipt', receipt];
2056
2247
  }
2057
2248
 
2249
+ const journalStartedAt = performance.now();
2058
2250
  const undoToken = journalUndo(undoSpec);
2251
+ phaseMs.undoJournalMs += elapsedMs(journalStartedAt);
2252
+ const remedyStartedAt = performance.now();
2059
2253
  const res = runNode(plan.exec.script, args);
2254
+ phaseMs.childRemedyMs += elapsedMs(remedyStartedAt);
2060
2255
  results.push({ id, ...res, undoToken });
2061
2256
  }
2062
- return { results };
2257
+ // This receipt contains only aggregate durations, never the applied ids, paths, command output,
2258
+ // or undo tokens. It makes an end-user-visible delay diagnosable without exposing their machine.
2259
+ return { results, timings: { ...phaseMs, totalMs: elapsedMs(startedAt) } };
2063
2260
  }
2064
2261
 
2065
2262
  function autoEligibleIds(recommendations = []) {
@@ -2131,7 +2328,7 @@ function validateConfigPatch(values) {
2131
2328
  */
2132
2329
  function saveConfig(values) {
2133
2330
  try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); }
2134
- catch (e) { return { ok: false, log: `could not create ${CONFIG_DIR.replace(HOME, '~')}: ${e.message}` }; }
2331
+ catch (e) { return { ok: false, log: `could not create ${CONFIG_DIR.replace(CONSOLE_ROOT, '~')}: ${e.message}` }; }
2135
2332
 
2136
2333
  const { clean, rejected } = validateConfigPatch(values);
2137
2334
  // Nothing valid to write is not a save. Saying "Saved." here would be the dead-button failure with
@@ -2193,7 +2390,7 @@ function saveConfig(values) {
2193
2390
  // A successfully encrypted credential retires the legacy plaintext field on this same commit.
2194
2391
  if (credentialChange?.ok) delete next.openrouterKey;
2195
2392
  try { writeAtomic(CONFIG_PATH, JSON.stringify(next, null, 2) + '\n'); }
2196
- catch (e) { return { ok: false, backup, log: `write failed: ${e.message}${backup ? `; your previous settings are at ${backup.replace(HOME, '~')}` : ''}` }; }
2393
+ catch (e) { return { ok: false, backup, log: `write failed: ${e.message}${backup ? `; your previous settings are at ${backup.replace(CONSOLE_ROOT, '~')}` : ''}` }; }
2197
2394
  try { fs.chmodSync(CONFIG_PATH, 0o600); } catch { /* best effort on non-posix */ }
2198
2395
 
2199
2396
  // The undo token is journalled only AFTER the write succeeded. Recording an undo for a save that
@@ -2207,7 +2404,7 @@ function saveConfig(values) {
2207
2404
  secretPath: credentialChange?.path ?? null,
2208
2405
  secretExisted: credentialChange?.existed ?? null,
2209
2406
  });
2210
- return { ok: true, backup: backup ? backup.replace(HOME, '~') : null, undoToken, rejected };
2407
+ return { ok: true, backup: backup ? backup.replace(CONSOLE_ROOT, '~') : null, undoToken, rejected };
2211
2408
  });
2212
2409
 
2213
2410
  if (held.timedOut) {
@@ -2301,7 +2498,7 @@ function undo(undoToken) {
2301
2498
  try {
2302
2499
  const bytes = fs.readFileSync(entry.backup);
2303
2500
  held = withLock(CONFIG_PATH, () => writeAtomic(CONFIG_PATH, bytes));
2304
- } catch (e) { return { ok: false, log: `restore failed: ${e.message} — your backup at ${entry.backup.replace(HOME, '~')} is intact` }; }
2501
+ } catch (e) { return { ok: false, log: `restore failed: ${e.message} — your backup at ${entry.backup.replace(CONSOLE_ROOT, '~')} is intact` }; }
2305
2502
  if (held.timedOut) return { ok: false, log: `another process is writing your settings and did not finish within ${LOCK_WAIT_MS}ms — NOTHING was restored and your backup is intact; try again` };
2306
2503
  try { fs.chmodSync(CONFIG_PATH, 0o600); } catch { /* best effort on non-posix */ }
2307
2504
  const effectFailures = restoreConfigEffects(entry);
@@ -2361,10 +2558,10 @@ function undo(undoToken) {
2361
2558
  const base = `${path.basename(entry.db)}.rescue-`;
2362
2559
  let baks = [];
2363
2560
  try { baks = fs.readdirSync(dir).filter((n) => n.startsWith(base)).sort(); } catch { /* dir gone */ }
2364
- if (!baks.length) return { ok: false, log: `no pre-repair backup found next to ${entry.db.replace(HOME, '~')} — nothing was restored` };
2561
+ if (!baks.length) return { ok: false, log: `no pre-repair backup found next to ${entry.db.replace(CONSOLE_ROOT, '~')} — nothing was restored` };
2365
2562
  const from = path.join(dir, baks[baks.length - 1]);
2366
2563
  try { fs.copyFileSync(from, entry.db); }
2367
- catch (e) { return { ok: false, log: `could not restore ${from.replace(HOME, '~')}: ${e.message}` }; }
2564
+ catch (e) { return { ok: false, log: `could not restore ${from.replace(CONSOLE_ROOT, '~')}: ${e.message}` }; }
2368
2565
  markUndoConsumed(undoToken);
2369
2566
  return { ok: true, log: `restored your memory store from the snapshot taken before the repair (${baks[baks.length - 1]})` };
2370
2567
  }
@@ -2477,6 +2674,9 @@ function openBrowser(url) {
2477
2674
  }
2478
2675
  }
2479
2676
  function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = false, cwd = process.cwd() } = {}) {
2677
+ const controlToken = crypto.randomBytes(24).toString('hex');
2678
+ let activeRuntime = null;
2679
+ let receiptFile = null;
2480
2680
  const server = http.createServer(async (req, res) => {
2481
2681
  // DNS-rebinding guard: this server binds 127.0.0.1 only. Reject any request whose Host header
2482
2682
  // isn't loopback, so a malicious web page can't rebind a hostname to 127.0.0.1 and read local state.
@@ -2486,6 +2686,19 @@ function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = f
2486
2686
  }
2487
2687
  try {
2488
2688
  const url = req.url.split('?')[0];
2689
+ // This identity endpoint is deliberately cache-independent and never exposes the shutdown
2690
+ // token. The private receipt is ownership proof; this endpoint only proves which generation
2691
+ // is listening before reuse or replacement.
2692
+ if (req.method === 'GET' && url === '/api/runtime') {
2693
+ return sendJSON(res, activeRuntime ? 200 : 503, activeRuntime || { error: 'runtime not ready' });
2694
+ }
2695
+ if (req.method === 'POST' && url === '/api/runtime/shutdown') {
2696
+ const body = await readBody(req);
2697
+ if (body.controlToken !== controlToken) return sendJSON(res, 403, { error: 'bad or missing control token' });
2698
+ sendJSON(res, 202, { ok: true, stopping: true });
2699
+ setImmediate(() => server.close());
2700
+ return;
2701
+ }
2489
2702
  // Heavy read-models: ALWAYS cache-first (fast=1 or not — both land here now). The handler
2490
2703
  // never blocks the event loop; kickRefresh() recomputes in a detached child. See writeCache/
2491
2704
  // serveCached above and the --refresh-cache CLI mode. TOKEN is injected at serve time so it
@@ -2582,9 +2795,14 @@ function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = f
2582
2795
  if (e.code === 'EADDRINUSE' && port !== 0) { console.error(` port ${port} busy — trying a free one…`); startServer({ port: 0, open, cwd }); }
2583
2796
  else { console.error(` server error: ${e.message}`); process.exit(1); }
2584
2797
  });
2798
+ server.on('close', () => {
2799
+ if (receiptFile) removeOwnedRuntimeReceipt(receiptFile, controlToken);
2800
+ });
2585
2801
  server.listen(port, '127.0.0.1', () => {
2586
2802
  const actual = server.address().port;
2587
2803
  const url = `http://127.0.0.1:${actual}/`;
2804
+ activeRuntime = runtimeIdentity({ port: actual, cwd });
2805
+ receiptFile = writeRuntimeReceipt({ ...activeRuntime, controlToken });
2588
2806
  console.log(`\n 🧠 RuvNet Brain — Onboarding Console`);
2589
2807
  console.log(` ${url}`);
2590
2808
  console.log(` read-only until you click · token-gated · ^C to stop\n`);
@@ -2617,7 +2835,7 @@ function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = f
2617
2835
  // precise moment a first-time user is deciding whether this thing works. The child does that
2618
2836
  // scan now (see --refresh-cache), and gatherActivity reports `warming` until it lands.
2619
2837
  if (open) openBrowser(url);
2620
- // Browser acceptance pre-warms a disposable HOME and disables only this redundant second scan.
2838
+ // Browser acceptance pre-warms a disposable fixture root and disables only this redundant second scan.
2621
2839
  // The production default is unchanged: every ordinary console start refreshes in the background.
2622
2840
  if (process.env.RUVNET_CONSOLE_DISABLE_BACKGROUND_REFRESH !== '1') kickRefresh({ force: true });
2623
2841
  });
@@ -2629,6 +2847,7 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('onboarding-consol
2629
2847
  const args = process.argv.slice(2);
2630
2848
  if (args.includes('--print-state')) { console.log(JSON.stringify(gatherState(process.cwd()), null, 2)); }
2631
2849
  else if (args.includes('--print-stack')) { console.log(JSON.stringify(gatherStack(), null, 2)); }
2850
+ else if (args.includes('--runtime-status')) { console.log(JSON.stringify(await inspectConsoleRuntime())); }
2632
2851
  else if (args.includes('--refresh-cache')) {
2633
2852
  // Runs as a DETACHED CHILD of the server (kickRefresh) — or standalone to pre-warm. Computes the
2634
2853
  // heavy read-models HERE, in a separate process, so the server's event loop is never blocked, and
@@ -2705,26 +2924,13 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('onboarding-consol
2705
2924
  process.exit(0);
2706
2925
  }
2707
2926
  else if (args.includes('--serve') || args.length === 0) {
2708
- // Hitting the command again should land on the console you already have, not spawn a second
2709
- // server on a random port and a second tab. If one is already up, just point the browser at it.
2710
- const port = Number(process.env.CONSOLE_PORT) || 7411;
2711
- const open = args.includes('--open');
2712
- const url = `http://127.0.0.1:${port}/`;
2713
- const alive = await new Promise((resolve) => {
2714
- const req = http.get({ host: '127.0.0.1', port, path: '/', timeout: 800 }, (res) => {
2715
- let b = ''; res.on('data', (c) => { b += c; if (b.length > 4096) res.destroy(); });
2716
- res.on('end', () => resolve(res.statusCode === 200 && /RuvNet Brain/.test(b)));
2717
- res.on('error', () => resolve(false));
2718
- });
2719
- req.on('error', () => resolve(false));
2720
- req.on('timeout', () => { req.destroy(); resolve(false); });
2927
+ await launchConsole({
2928
+ port: Number(process.env.CONSOLE_PORT) || 7411,
2929
+ open: args.includes('--open'),
2930
+ cwd: process.cwd(),
2721
2931
  });
2722
- if (alive) {
2723
- console.log(`\n 🧠 RuvNet Brain — Onboarding Console (already running)\n ${url}\n`);
2724
- if (open) openBrowser(url);
2725
- } else { startServer({ port, open, cwd: process.cwd() }); }
2726
2932
  }
2727
- else { console.log(`\n onboarding-console — the RuvNet Brain configure page\n\n --serve [--open] start the local server (and open your browser)\n --print-state print the read-only state JSON and exit (for tests)\n --print-stack print the stack audit JSON and exit\n`); }
2933
+ 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`); }
2728
2934
  }
2729
2935
 
2730
2936
  export {
@@ -2747,3 +2953,4 @@ export {
2747
2953
  // Exported for the cross-project cache-isolation test (console-cache-scope.test.mjs). serveCached's
2748
2954
  // scopeKey is the guard that stops one project's cached state being served for another.
2749
2955
  export { serveCached, writeCache, kickRefresh };
2956
+ export { inspectConsoleRuntime, launchConsole, runtimeReceiptPath, startServer };