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.
- package/README.md +1 -1
- package/bin/install.mjs +215 -31
- package/docs/RELEASE-NOTES-4.0.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/whats-new.md +6 -6
- package/plugin/docs/RELEASE-NOTES-4.0.md +88 -0
- package/plugin/mcp/server.mjs +70 -33
- package/plugin/scripts/hook-shim.mjs +27 -22
- package/plugin/scripts/lesson-command-scope.mjs +133 -0
- package/plugin/scripts/lesson-gate.mjs +401 -0
- package/plugin/scripts/lesson-presentation.mjs +99 -0
- package/plugin/scripts/lesson-store.mjs +452 -0
- package/plugin/scripts/route-dispatch.sh +1 -0
- package/plugin/scripts/session-start-core.mjs +28 -2
- package/plugin/scripts/verify-interface.sh +1 -0
- package/plugin/scripts/whats-new.mjs +42 -0
- package/plugin/skills/release-proof/SKILL.md +21 -4
- package/plugin/skills/release-proof/references/receipt-contract.md +8 -2
- package/plugin/skills/release-proof/scripts/release-proof.mjs +77 -1
- package/plugin/skills/ruvnet-brain/SKILL.md +22 -7
- package/plugin/skills/whats-new/SKILL.md +4 -4
- package/scripts/build-bundle.mjs +34 -25
- package/scripts/fix-workstream.mjs +291 -0
- package/scripts/health-repair.mjs +14 -27
- package/scripts/issue-fix.mjs +135 -216
- package/scripts/learning-replay-cli.mjs +236 -0
- package/scripts/learning-replay-contract.mjs +255 -0
- package/scripts/learning-replay-execution.mjs +193 -0
- package/scripts/learning-replay-fixture.mjs +380 -0
- package/scripts/learning-replay-proof.mjs +459 -0
- package/scripts/learning-replay.mjs +10 -1565
- package/scripts/lesson-gate.mjs +3 -679
- package/scripts/lesson-store.mjs +4 -447
- package/scripts/memory-doctor.mjs +80 -9
- package/scripts/nightly-wrapper.sh +20 -28
- package/scripts/onboarding-console.mjs +302 -95
- package/scripts/protected-release-invocation.mjs +76 -0
- package/scripts/publication-receipt.mjs +307 -0
- package/scripts/release-authority.mjs +93 -0
- package/scripts/release-vector.mjs +49 -25
- package/scripts/release.mjs +55 -11
- package/scripts/self-update.mjs +15 -227
- package/scripts/stabilization-receipt.mjs +108 -0
- 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
|
|
73
|
-
|
|
74
|
-
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
312
|
-
path.join(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
2124
|
+
const r = spawnSync(path.join(SYSTEM_HOME, '.npm-global/bin/ruflo'),
|
|
1938
2125
|
['hooks', 'intelligence', '--status'],
|
|
1939
2126
|
{
|
|
1940
|
-
cwd:
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
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
|
|
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 };
|