ruvnet-brain 4.0.2 → 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.
@@ -43,6 +43,7 @@ import os from 'node:os';
43
43
  import path from 'node:path';
44
44
 
45
45
  const HOME = os.homedir();
46
+ const DEFAULT_SCAN_ROOTS = ['Code', 'code', 'src', 'source', 'projects', 'dev', 'work'];
46
47
 
47
48
  // Telemetry namespaces: high-volume, unembedded, zero-signal. Written by the npx hook calls.
48
49
  // Counted separately so "you have 11,000 memories" is never mistaken for "you have 11,000 lessons".
@@ -131,8 +132,35 @@ const q = (db, sql) => {
131
132
  // n() returns null for "unknown" and a number only when we genuinely counted. Callers must handle null.
132
133
  const n = (r) => (r.ok ? (r.value === null || r.value === '' ? null : parseInt(r.value, 10)) : null);
133
134
 
134
- export function findStores(root = path.join(HOME, 'Code')) {
135
- const out = [];
135
+ function realExisting(value) {
136
+ try { return fs.realpathSync(value); } catch { return null; }
137
+ }
138
+
139
+ export function candidateRoots({
140
+ home = HOME,
141
+ configPath = path.join(home, '.claude', 'ruvnet-brain', 'config.json'),
142
+ } = {}) {
143
+ let configured = [];
144
+ try {
145
+ const value = JSON.parse(fs.readFileSync(configPath, 'utf8'));
146
+ if (Array.isArray(value.scanRoots)) configured = value.scanRoots.filter((item) => typeof item === 'string' && item.trim());
147
+ } catch { /* absent or malformed config does not erase the common roots */ }
148
+
149
+ const roots = new Set();
150
+ for (const value of [...DEFAULT_SCAN_ROOTS, ...configured]) {
151
+ const absolute = path.isAbsolute(value) ? value : path.join(home, value);
152
+ const canonical = realExisting(absolute);
153
+ try {
154
+ if (canonical && fs.statSync(canonical).isDirectory()) roots.add(canonical);
155
+ } catch { /* missing/non-directory roots are not candidates on this machine */ }
156
+ }
157
+ return [...roots].sort();
158
+ }
159
+
160
+ function storesBelow(root) {
161
+ const out = new Set();
162
+ const canonicalRoot = realExisting(root);
163
+ if (!canonicalRoot) return out;
136
164
  const walk = (dir, depth) => {
137
165
  if (depth > 4) return;
138
166
  let entries;
@@ -142,24 +170,51 @@ export function findStores(root = path.join(HOME, 'Code')) {
142
170
  if (e.name === 'node_modules' || e.name === '.git') continue;
143
171
  if (e.name === '.swarm') {
144
172
  const db = path.join(dir, '.swarm/memory.db');
145
- if (fs.existsSync(db)) out.push(db);
173
+ const canonical = realExisting(db);
174
+ if (canonical) out.add(canonical);
146
175
  continue;
147
176
  }
148
177
  if (e.name.startsWith('.') && e.name !== '.swarm') continue;
149
178
  walk(path.join(dir, e.name), depth + 1);
150
179
  }
151
180
  };
152
- walk(root, 0);
153
- // Stores outside ~/Code exist too (~/.claude, ~/cognitum-trader). A one-level glob missed 32 of
154
- // these — including Helix — and that miss is exactly how "33/33 verified" got reported.
181
+ walk(canonicalRoot, 0);
182
+ return out;
183
+ }
184
+
185
+ export function findStores(root) {
186
+ const out = new Set();
187
+ if (root !== undefined) {
188
+ for (const db of storesBelow(root)) out.add(db);
189
+ return [...out].sort();
190
+ }
191
+
192
+ for (const candidate of candidateRoots()) {
193
+ for (const db of storesBelow(candidate)) out.add(db);
194
+ }
195
+ // These two stores intentionally sit outside the project-root convention. They belong only to
196
+ // the fleet-wide no-argument scan; an explicit root must remain genuinely scoped.
155
197
  for (const extra of [path.join(HOME, '.claude/.swarm/memory.db'), path.join(HOME, 'cognitum-trader/.swarm/memory.db')]) {
156
- if (fs.existsSync(extra) && !out.includes(extra)) out.push(extra);
198
+ const canonical = realExisting(extra);
199
+ if (canonical) out.add(canonical);
200
+ }
201
+ return [...out].sort();
202
+ }
203
+
204
+ export function displayStoreName(db, home = HOME) {
205
+ const canonicalDb = realExisting(db) || path.resolve(db);
206
+ const project = path.dirname(path.dirname(canonicalDb));
207
+ const canonicalHome = realExisting(home) || path.resolve(home);
208
+ const relative = path.relative(canonicalHome, project);
209
+ if (relative === '') return '~';
210
+ if (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
211
+ return `~/${relative.split(path.sep).join('/')}`;
157
212
  }
158
- return out.sort();
213
+ return project;
159
214
  }
160
215
 
161
216
  export function diagnose(db) {
162
- const name = db.replace(HOME + '/Code/', '').replace(HOME + '/', '~/').replace('/.swarm/memory.db', '');
217
+ const name = displayStoreName(db);
163
218
 
164
219
  const ic = q(db, 'PRAGMA integrity_check;');
165
220
  if (!ic.ok) {
@@ -41,8 +41,8 @@ echo $$ > "$LOCK"
41
41
  trap 'rm -f "$LOCK"' EXIT
42
42
 
43
43
  # NIGHTLY_SMOKE=1 — prove this whole chain (launchd -> heartbeat wrapper -> here -> node -> the repo
44
- # scan -> the log) WITHOUT a multi-hour rebuild or a real Release publish. self-update.mjs is dry-run
45
- # by default, so dropping --apply --publish exercises every link except the build itself.
44
+ # scan -> the log) WITHOUT a multi-hour rebuild. self-update.mjs is dry-run by default, so dropping
45
+ # --apply exercises every link except the build itself. Publication is never part of this job.
46
46
  # WHY IT EXISTS (2026-07-13): the 03:15 fire on 07-12 died before it could even write its own log
47
47
  # ("/bin/bash: logs/nightly.log: No such file or directory" — relative path, no working directory) and
48
48
  # NOBODY KNEW, because the only way to test this chain was to wait until 03:15 and hope. A scheduled job
@@ -51,40 +51,32 @@ trap 'rm -f "$LOCK"' EXIT
51
51
  SMOKE="${NIGHTLY_SMOKE:-0}"
52
52
 
53
53
  run_once() {
54
- local before after rc tail
55
- before=$(gh release view --json tagName -q .tagName 2>/dev/null || echo "unknown")
56
- { echo "===== nightly-wrapper attempt $1 — $(date -u +%FT%TZ) — before: $before ====="
54
+ local rc
55
+ { echo "===== nightly-wrapper rebuild attempt $1 — $(date -u +%FT%TZ) ====="
57
56
  if [ "$SMOKE" = "1" ]; then
58
- echo "[SMOKE] dry-run: exercising the full chain, NOT building or publishing"
57
+ echo "[SMOKE] dry-run: exercising the full chain, NOT building"
59
58
  /usr/local/bin/node scripts/self-update.mjs --fresh-window 60
60
59
  else
61
- /usr/local/bin/node scripts/self-update.mjs --apply --publish --fresh-window 60
60
+ /usr/local/bin/node scripts/self-update.mjs --apply --fresh-window 60
62
61
  fi
63
62
  } >> "$LOG" 2>&1
64
63
  rc=$?
65
- if [ "$SMOKE" = "1" ]; then
66
- # A smoke run must never claim a real outcome, touch the failure marker, or alter the tag story.
67
- if [ "$rc" -eq 0 ]; then echo "===== attempt $1: SMOKE OK — chain intact (no build, no publish) =====" >> "$LOG"; return 0; fi
68
- echo "===== attempt $1: SMOKE FAILED exit $rc — the chain is BROKEN, fix before 03:15 =====" >> "$LOG"
64
+ if [ "$rc" -ne 0 ]; then
65
+ echo "===== attempt $1: FAILED exit $rc — rebuild/candidate preparation did not complete =====" >> "$LOG"
69
66
  return 1
70
67
  fi
71
- after=$(gh release view --json tagName -q .tagName 2>/dev/null || echo "unknown")
72
- if [ "$after" != "$before" ] && [ -n "$after" ] && [ "$after" != "unknown" ]; then
73
- echo "===== attempt $1: VERIFIED SUCCESS $before -> $after =====" >> "$LOG"
74
- rm -f "$MARKER"
75
- return 0
76
- elif [ "$rc" -eq 0 ]; then
77
- echo "===== attempt $1: CLEAN NO-OP, tag unchanged at $after =====" >> "$LOG"
78
- rm -f "$MARKER"
68
+ if [ "$SMOKE" = "1" ]; then
69
+ # A smoke run must never claim a real outcome, touch the failure marker, or alter the tag story.
70
+ echo "===== attempt $1: SMOKE OK — chain intact (no build, no publish) =====" >> "$LOG"
79
71
  return 0
80
- else
81
- echo "===== attempt $1: FAILED exit $rc, tag stuck at $after =====" >> "$LOG"
82
- return 1
83
72
  fi
73
+ echo "===== attempt $1: REBUILD COMPLETE — candidate bytes prepared; no release channel was promoted =====" >> "$LOG"
74
+ rm -f "$MARKER"
75
+ return 0
84
76
  }
85
77
 
86
78
  # Memory distillation (ADR-174) had gone stale 3 days — same silent-death pattern as everything
87
- # else tonight. Independent of publish success/failure: mine raw memory_entries into structured
79
+ # else tonight. Independent of rebuild success/failure: mine raw memory_entries into structured
88
80
  # episodes/reasoning_patterns on every nightly run. Best-effort, never blocks the real job.
89
81
  RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory distill run --path .swarm/memory.db >> "$LOG" 2>&1 || true
90
82
  # Durability: was a one-off manual snapshot before tonight. Now recurring — WAL-safe, rotates
@@ -94,7 +86,7 @@ RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory backup --db .swarm/memor
94
86
  # AgentDB drift canary (2026-07-23, P7 wiring sweep) — scripts/memdb-health.sh existed unwired since
95
87
  # 2026-07-09/10, the exact nights idx_bridge_key/idx_bridge_ns corruption recurred 3x on this very DB
96
88
  # while in WAL mode. Best-effort, same shape as its brain-health/key-health siblings below: never
97
- # blocks the real publish, just makes a WAL-mode/integrity regression loud instead of silent.
89
+ # blocks the real rebuild, just makes a WAL-mode/integrity regression loud instead of silent.
98
90
  echo "===== memdb-health canary — $(date -u +%FT%TZ) =====" >> "$LOG"
99
91
  sh scripts/memdb-health.sh .swarm/memory.db >> "$LOG" 2>&1 \
100
92
  && echo "===== memdb-health canary: OK =====" >> "$LOG" \
@@ -109,7 +101,7 @@ sh scripts/memdb-health.sh .swarm/memory.db >> "$LOG" 2>&1 \
109
101
  # is the currency gate on the artifact this writes; if this step stops running, that workflow goes red
110
102
  # on staleness rather than everything staying quietly green.
111
103
  #
112
- # Best-effort, same shape as the canaries below: it never blocks the publish. Its exit code is not
104
+ # Best-effort, same shape as the canaries below: it never blocks the rebuild. Its exit code is not
113
105
  # thrown away though — it is written to the log by name, because 0/1/3/4 are four different facts
114
106
  # (PASS / the lesson stopped transferring / the trap was invalidated / it could not be measured) and
115
107
  # collapsing them into "failed" is how the seven prior silent-death bugs in this file happened.
@@ -126,7 +118,7 @@ esac
126
118
  # ── GONG LAYER 3: brain-health canary (Stuart, 2026-07-12 — the brain must NEVER be dark silently).
127
119
  # One real query against the LIVE cache brain every night. forge-ask-all.mjs exits non-zero on a
128
120
  # total retrieval failure (all repos erroring) and rings kb/brain-alarm.mjs itself; this adds the
129
- # guaranteed-nightly cadence plus its own urgent push, independent of the publish job's outcome.
121
+ # guaranteed-nightly cadence plus its own urgent push, independent of the rebuild job's outcome.
130
122
  BRAIN_KB="$HOME/.cache/ruvnet-brain/kb"
131
123
  if [ -f "$BRAIN_KB/forge-ask-all.mjs" ]; then
132
124
  echo "===== brain-health canary — $(date -u +%FT%TZ) =====" >> "$LOG"
@@ -170,11 +162,11 @@ python3 -c "
170
162
  import json, datetime
171
163
  json.dump({
172
164
  'at': datetime.datetime.utcnow().isoformat() + 'Z',
173
- 'tag_stuck_at': '$AFTER',
165
+ 'last_release_tag_observed': '$AFTER',
174
166
  'tail': '''$TAIL''',
175
167
  'note': 'Nightly failed twice (immediate + 3min retry). Needs a live session to diagnose — see logs/nightly.log.'
176
168
  }, open('$MARKER', 'w'), indent=2)
177
169
  "
178
- sh scripts/notify.sh "🔴 Nightly FAILED twice — needs you" "tag stuck at $AFTER after retry. Last: $TAIL" urgent "rotating_light"
170
+ sh scripts/notify.sh "🔴 Nightly FAILED twice — needs you" "candidate rebuild failed; last release remains $AFTER. Last: $TAIL" urgent "rotating_light"
179
171
  echo "===== ESCALATED: marker written at $MARKER =====" >> "$LOG"
180
172
  exit 1
@@ -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';
@@ -79,6 +79,199 @@ const INSTALLED_KB = process.env.RUVNET_BRAIN_KB
79
79
  const COMPLETE_BRAIN_SOURCE = process.env.RUVNET_BRAIN_COMPLETE_SOURCE
80
80
  || path.join(REPO, 'dist', 'ruvnet-brain');
81
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
+ }
82
275
 
83
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.-]+)?/;
84
277
 
@@ -123,27 +316,9 @@ function robustReadJSON(db, sql) {
123
316
  const VENDOR = ['/clones/', '/node_modules/', '/vendor/', '/upstream/', '.claude-backup', '_snapshots',
124
317
  '/ruvnet-repos/', '/ruvnet_repos/'];
125
318
 
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
- }
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).
147
322
  function findProjects(root) {
148
323
  const out = new Set();
149
324
  const walk = (dir, depth) => {
@@ -273,19 +448,7 @@ function probeMemory(projectDir) {
273
448
  // machine has 100+. That is far too slow to sit on the page's first paint, so it is its own endpoint
274
449
  // (/api/memory) and hydrates late, exactly like the stack audit does.
275
450
  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
- }
451
+ const stores = findStores();
289
452
  const fleet = [];
290
453
  for (const db of stores) {
291
454
  const d = diagnose(db);
@@ -2477,6 +2640,9 @@ function openBrowser(url) {
2477
2640
  }
2478
2641
  }
2479
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;
2480
2646
  const server = http.createServer(async (req, res) => {
2481
2647
  // DNS-rebinding guard: this server binds 127.0.0.1 only. Reject any request whose Host header
2482
2648
  // isn't loopback, so a malicious web page can't rebind a hostname to 127.0.0.1 and read local state.
@@ -2486,6 +2652,19 @@ function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = f
2486
2652
  }
2487
2653
  try {
2488
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
+ }
2489
2668
  // Heavy read-models: ALWAYS cache-first (fast=1 or not — both land here now). The handler
2490
2669
  // never blocks the event loop; kickRefresh() recomputes in a detached child. See writeCache/
2491
2670
  // serveCached above and the --refresh-cache CLI mode. TOKEN is injected at serve time so it
@@ -2582,9 +2761,14 @@ function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = f
2582
2761
  if (e.code === 'EADDRINUSE' && port !== 0) { console.error(` port ${port} busy — trying a free one…`); startServer({ port: 0, open, cwd }); }
2583
2762
  else { console.error(` server error: ${e.message}`); process.exit(1); }
2584
2763
  });
2764
+ server.on('close', () => {
2765
+ if (receiptFile) removeOwnedRuntimeReceipt(receiptFile, controlToken);
2766
+ });
2585
2767
  server.listen(port, '127.0.0.1', () => {
2586
2768
  const actual = server.address().port;
2587
2769
  const url = `http://127.0.0.1:${actual}/`;
2770
+ activeRuntime = runtimeIdentity({ port: actual, cwd });
2771
+ receiptFile = writeRuntimeReceipt({ ...activeRuntime, controlToken });
2588
2772
  console.log(`\n 🧠 RuvNet Brain — Onboarding Console`);
2589
2773
  console.log(` ${url}`);
2590
2774
  console.log(` read-only until you click · token-gated · ^C to stop\n`);
@@ -2629,6 +2813,7 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('onboarding-consol
2629
2813
  const args = process.argv.slice(2);
2630
2814
  if (args.includes('--print-state')) { console.log(JSON.stringify(gatherState(process.cwd()), null, 2)); }
2631
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())); }
2632
2817
  else if (args.includes('--refresh-cache')) {
2633
2818
  // Runs as a DETACHED CHILD of the server (kickRefresh) — or standalone to pre-warm. Computes the
2634
2819
  // heavy read-models HERE, in a separate process, so the server's event loop is never blocked, and
@@ -2705,26 +2890,13 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('onboarding-consol
2705
2890
  process.exit(0);
2706
2891
  }
2707
2892
  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); });
2893
+ await launchConsole({
2894
+ port: Number(process.env.CONSOLE_PORT) || 7411,
2895
+ open: args.includes('--open'),
2896
+ cwd: process.cwd(),
2721
2897
  });
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
2898
  }
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`); }
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`); }
2728
2900
  }
2729
2901
 
2730
2902
  export {
@@ -2747,3 +2919,4 @@ export {
2747
2919
  // Exported for the cross-project cache-isolation test (console-cache-scope.test.mjs). serveCached's
2748
2920
  // scopeKey is the guard that stops one project's cached state being served for another.
2749
2921
  export { serveCached, writeCache, kickRefresh };
2922
+ export { inspectConsoleRuntime, launchConsole, runtimeReceiptPath, startServer };
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ // Local publication is deliberately impossible. The one publisher may mutate public channels only
3
+ // when the reviewer-protected GitHub workflow carries a fully valid candidate seal into it.
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { evaluateCandidateReceipt } from './release-proof.mjs';
8
+ import { getVersion } from './version.mjs';
9
+
10
+ export const PROTECTED_WORKFLOW = 'protected-release';
11
+ export const PROTECTED_VERSION = getVersion();
12
+
13
+ const hex = (value, length) => new RegExp(`^[0-9a-f]{${length}}$`).test(String(value || ''));
14
+ const digestOf = (receipt) => String(receipt?.artifact?.sha256 || '').replace(/^sha256:/, '');
15
+
16
+ export function validateProtectedPublishInvocation({ root = process.cwd(), env = process.env } = {}) {
17
+ const failures = [];
18
+ let releaseMode = 'unknown';
19
+ if (env.GITHUB_ACTIONS !== 'true' || env.GITHUB_WORKFLOW !== PROTECTED_WORKFLOW) {
20
+ failures.push('publication is allowed only inside the protected-release GitHub workflow');
21
+ }
22
+
23
+ const expectedSha = String(env.RUVNET_EXPECTED_SHA || '');
24
+ const expectedDigest = String(env.RUVNET_EXPECTED_ARTIFACT_SHA256 || '').replace(/^sha256:/, '');
25
+ const expectedVersion = String(env.RUVNET_EXPECTED_VERSION || '');
26
+ if (!hex(expectedSha, 40)) failures.push('expected candidate SHA is missing or malformed');
27
+ if (!hex(expectedDigest, 64)) failures.push('expected artifact digest is missing or malformed');
28
+ if (expectedVersion !== PROTECTED_VERSION) failures.push(`release generation must be ${PROTECTED_VERSION}`);
29
+
30
+ const receiptRelative = String(env.RUVNET_CANDIDATE_RECEIPT || '');
31
+ const evidenceRoot = path.resolve(root, 'release-evidence');
32
+ const receiptPath = path.resolve(root, receiptRelative || '.missing-candidate-receipt');
33
+ if (!receiptRelative || !(receiptPath === evidenceRoot || receiptPath.startsWith(`${evidenceRoot}${path.sep}`))) {
34
+ failures.push('candidate receipt must be inside release-evidence');
35
+ }
36
+
37
+ let receipt = null;
38
+ try {
39
+ receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
40
+ } catch (error) {
41
+ failures.push(`candidate receipt unavailable: ${error.message}`);
42
+ }
43
+
44
+ if (receipt) {
45
+ const seal = evaluateCandidateReceipt(receipt);
46
+ if (seal.verdict !== 'PASS') failures.push(`candidate seal failed: ${seal.failures.map(({ code }) => code).join(',')}`);
47
+ const receiptDigest = digestOf(receipt);
48
+ if (receipt.sha !== expectedSha) failures.push('candidate receipt SHA mismatch');
49
+ if (receiptDigest !== expectedDigest) failures.push('candidate artifact digest mismatch');
50
+ if (receipt.version !== expectedVersion) failures.push('candidate version mismatch');
51
+ const expectedMode = receipt.phase === 'stabilization-candidate' ? 'stabilization' : 'strict';
52
+ releaseMode = expectedMode;
53
+ if (env.RUVNET_RELEASE_MODE !== expectedMode) failures.push(`release mode must be ${expectedMode} for this receipt`);
54
+
55
+ const artifactPath = path.resolve(root, String(receipt.artifact?.path || '.missing-artifact'));
56
+ if (!(artifactPath === evidenceRoot || artifactPath.startsWith(`${evidenceRoot}${path.sep}`))) {
57
+ failures.push('candidate artifact must be inside release-evidence');
58
+ } else {
59
+ try {
60
+ const actual = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
61
+ if (actual !== expectedDigest) failures.push('candidate artifact bytes do not match the seal');
62
+ } catch (error) {
63
+ failures.push(`candidate artifact unavailable: ${error.message}`);
64
+ }
65
+ }
66
+ }
67
+
68
+ return {
69
+ verdict: failures.length === 0 ? 'PASS' : 'FAIL',
70
+ sha: expectedSha,
71
+ artifactSha256: expectedDigest,
72
+ version: expectedVersion,
73
+ mode: releaseMode,
74
+ failures,
75
+ };
76
+ }