ruvnet-brain 4.3.39 β 4.4.0
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 +2 -2
- package/bin/install.mjs +150 -42
- package/kb/brain-profile.mjs +17 -2
- package/kb/forge-update.mjs +92 -28
- package/kb/lifecycle-evidence-retention.mjs +12 -9
- package/kb/refresh-run.mjs +17 -1
- package/kb/update-storage-transaction.mjs +44 -10
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +2 -2
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/scripts/capability-registry.mjs +3 -3
- package/plugin/scripts/codex-hook-wrapper.mjs +7 -2
- package/plugin/scripts/coverage-integrity.mjs +51 -4
- package/plugin/scripts/design-wall.sh +1 -0
- package/plugin/scripts/ground-before-write.sh +1 -0
- package/plugin/scripts/ground-ruvnet.sh +3 -3
- package/plugin/scripts/grounding-answer.mjs +129 -0
- package/plugin/scripts/grounding-stamp.sh +32 -31
- package/plugin/scripts/grounding-turn-evidence.mjs +99 -5
- package/plugin/scripts/grounding-turn-gate.mjs +25 -6
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/kling-preflight.sh +1 -0
- package/plugin/scripts/learn-capture.sh +1 -0
- package/plugin/scripts/project-progression-sources.mjs +16 -4
- package/plugin/scripts/project-progression-store.mjs +49 -1
- package/plugin/scripts/protect-brain-state.sh +1 -0
- package/plugin/scripts/route-dispatch.sh +1 -0
- package/plugin/scripts/session-snapshot-hook.mjs +224 -38
- package/plugin/scripts/session-start-health.mjs +24 -3
- package/plugin/scripts/session-start-update-plane.mjs +1 -1
- package/plugin/scripts/update-apply.mjs +22 -2
- package/scripts/console-instances.mjs +145 -0
- package/scripts/console-runtime-identity.mjs +2 -0
- package/scripts/corpus-canary.mjs +130 -18
- package/scripts/customer-seams.mjs +84 -0
- package/scripts/customer-state-matrix.mjs +363 -0
- package/scripts/full-suite-gate.mjs +155 -0
- package/scripts/grounding-turn-replay.mjs +11 -3
- package/scripts/hook-qualify-core.mjs +346 -0
- package/scripts/hook-qualify-hosts.mjs +115 -0
- package/scripts/hook-qualify.mjs +101 -0
- package/scripts/host-cli.mjs +115 -0
- package/scripts/qe/agentic-qe-4.3.mjs +0 -1
- package/scripts/route-gold-rank.mjs +156 -0
- package/scripts/route-index-memory.mjs +51 -0
- package/scripts/route-latency-warm.mjs +123 -0
- package/scripts/wired-check.mjs +17 -3
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Created: 2026-06-29 22:36:38 EDT
|
|
|
7
7
|
|
|
8
8
|
# π§ RuvNet Brain
|
|
9
9
|
|
|
10
|
-
### π§ RuvNet Brain β [](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
|
|
11
11
|
|
|
12
12
|
**A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack β delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
|
|
13
13
|
|
|
@@ -562,7 +562,7 @@ node forge-ask-all.mjs --dir . --q "How does RuVector implement HNSW vector sear
|
|
|
562
562
|
|
|
563
563
|
This project versions in the open (see the live badge up top for the exact plugin version; the downloadable knowledge bundle is a separate track) β we don't claim βdone,β βcomplete,β or βzero hallucinations.β Where it stands:
|
|
564
564
|
|
|
565
|
-
- β
**The grounding brain is real and proven** β 199 public stores Β· 161,
|
|
565
|
+
- β
**The grounding brain is real and proven** β 199 public stores Β· 161,371 public source chunks, dual embeddings, cross-encoder rerank, plugin (MCP tool + explicit skills; automatic hooks retired), all re-runnable.
|
|
566
566
|
- β
**Code-level depth** β the code-rich repos are indexed to full function bodies; βhow is it implemented?β returns the implementation. Verified in the shipped bundle (clean-room 3/3).
|
|
567
567
|
- β
**Routing holds** β named 47/48, described 26/28, scenario 7/8; behavioral L1βL3 all pass (**L4 downgraded β it measures that the brain spoke, not that anything listened**); private stores fenced out of the public bundle (zero-leak verified).
|
|
568
568
|
- β οΈ **Two routing residuals** (above) β surfaced, not hidden.
|
package/bin/install.mjs
CHANGED
|
@@ -21,9 +21,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
21
21
|
import readline from 'node:readline';
|
|
22
22
|
import crypto from 'node:crypto';
|
|
23
23
|
import { applyBrainProfile, readBrainProfile } from '../kb/brain-profile.mjs';
|
|
24
|
-
import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, recordRefreshAdvisory,
|
|
24
|
+
import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, physicalPath, recordRefreshAdvisory,
|
|
25
25
|
recordRefreshPhase, settleRefreshRun, UPDATE_REFRESH_PHASES } from '../kb/refresh-run.mjs';
|
|
26
26
|
import { pruneLifecycleEvidence } from '../kb/lifecycle-evidence-retention.mjs';
|
|
27
|
+
import { recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
|
|
27
28
|
import {
|
|
28
29
|
requiredEmbedderModels,
|
|
29
30
|
missingEmbedderModels,
|
|
@@ -70,6 +71,8 @@ import {
|
|
|
70
71
|
CONSOLE_RUNTIME_SURFACE, CONSOLE_RUNTIME_IDENTITY_FILE, consoleRuntimeDigest,
|
|
71
72
|
} from '../scripts/console-runtime-identity.mjs';
|
|
72
73
|
import { shellDiff as pluginShellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
|
|
74
|
+
import { readConsoleReceipts, replaceStaleConsoles } from '../scripts/console-instances.mjs';
|
|
75
|
+
import { runHostCli, waitForHostCli } from '../scripts/host-cli.mjs';
|
|
73
76
|
import {
|
|
74
77
|
writeInstalledRuntimeIdentity, recordCorpusTransportIdentity, isCorpusReleaseTag, rejectedReleasePath,
|
|
75
78
|
} from '../kb/corpus-release-identity.mjs';
|
|
@@ -102,8 +105,9 @@ const PACKAGE_VERSION = (() => {
|
|
|
102
105
|
const REPO = 'stuinfla/ruvnet-brain';
|
|
103
106
|
const RELEASE_API = `https://api.github.com/repos/${REPO}/releases/latest`;
|
|
104
107
|
const ASSET_NAME = 'ruvnet-brain.zip';
|
|
105
|
-
// Known-good BUNDLE tag, used
|
|
106
|
-
//
|
|
108
|
+
// Known-good BUNDLE tag, used ONLY by --pin. It is no longer a silent fallback for a failed
|
|
109
|
+
// latest-release lookup: that bundle predates ReleaseCoverage and cannot pass validation, so a lookup
|
|
110
|
+
// failure now stops with its real cause (resolveRelease / releaseLookupFailure).
|
|
107
111
|
//
|
|
108
112
|
// This MUST NOT be derived from this package's own version. The installer and the brain bundle are
|
|
109
113
|
// two independent version streams (README: "Three independent things version separately here β by
|
|
@@ -227,6 +231,13 @@ function tryRun(cmd, args, opts = {}) {
|
|
|
227
231
|
const r = spawnSync(cmd, args, { stdio: 'inherit', shell: IS_WIN, ...opts });
|
|
228
232
|
return !r.error && r.status === 0;
|
|
229
233
|
}
|
|
234
|
+
// The claude/codex CLIs update themselves and can be absent for seconds (scripts/host-cli.mjs):
|
|
235
|
+
// retried with a bounded backoff, then ONE clear line instead of raw shell errors.
|
|
236
|
+
function tryHostCli(cmd, args, opts = {}) {
|
|
237
|
+
const r = runHostCli(cmd, args, opts);
|
|
238
|
+
if (r.missingBinary) warn(r.message);
|
|
239
|
+
return !r.missingBinary && !r.error && r.status === 0;
|
|
240
|
+
}
|
|
230
241
|
|
|
231
242
|
// ββ download with redirect-following + progress ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
232
243
|
function download(url, dest, redirects = 0) {
|
|
@@ -299,7 +310,9 @@ function fetchJson(url, redirects = 0) {
|
|
|
299
310
|
}
|
|
300
311
|
if (statusCode !== 200) {
|
|
301
312
|
res.resume();
|
|
302
|
-
|
|
313
|
+
const reset = headers['x-ratelimit-remaining'] === '0' && Number(headers['x-ratelimit-reset']);
|
|
314
|
+
return reject(new Error(`GitHub API returned HTTP ${statusCode}${reset
|
|
315
|
+
? ` (anonymous rate limit used up; it resets at ${new Date(reset * 1000).toISOString()})` : ''}`));
|
|
303
316
|
}
|
|
304
317
|
let body = '';
|
|
305
318
|
res.setEncoding('utf8');
|
|
@@ -321,8 +334,8 @@ function fetchJson(url, redirects = 0) {
|
|
|
321
334
|
// ββ step: resolve which Release to download (latest by default; safe fallback) βββββββββββββββββββ
|
|
322
335
|
// Default behavior: ask GitHub for the LATEST Release and use its ruvnet-brain.zip asset.
|
|
323
336
|
// --version <tag> forces a tag; --pin skips the network check and uses the bundled known-good tag.
|
|
324
|
-
// Any failure (offline / rate-limited / no releases)
|
|
325
|
-
//
|
|
337
|
+
// Any failure (offline / rate-limited / no releases) THROWS with the HTTP status or network error and
|
|
338
|
+
// a retry hint; callers that must download stop on it, the staleness check reports "could not check".
|
|
326
339
|
/**
|
|
327
340
|
* Which asset of a Release actually holds the brain bundle.
|
|
328
341
|
*
|
|
@@ -387,12 +400,31 @@ async function resolveRelease() {
|
|
|
387
400
|
ok(`latest Release is ${c.bold(tag)}`);
|
|
388
401
|
return { tag, url, source: 'latest' };
|
|
389
402
|
} catch (e) {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
403
|
+
// FAIL LOUD. This used to return the hardcoded RELEASE_VERSION and promise "the install is still
|
|
404
|
+
// safe and complete" β but that bundle predates ReleaseCoverage, so two steps later it failed
|
|
405
|
+
// validation with "COVERAGE.json is missing", and the reason (a rate limit, a network blip) was
|
|
406
|
+
// gone from the screen. Measured 2026-09-30 on the owner's recovery rail. Only --pin and
|
|
407
|
+
// --version choose a tag the lookup did not return.
|
|
408
|
+
const failure = releaseLookupFailure(e);
|
|
409
|
+
throw Object.assign(new Error(failure.message), { hint: failure.hint });
|
|
393
410
|
}
|
|
394
411
|
}
|
|
395
412
|
|
|
413
|
+
/** Why the latest-release lookup failed, and what to do about it. Pure, for testing. */
|
|
414
|
+
export function releaseLookupFailure(error) {
|
|
415
|
+
const detail = (error && error.message) || String(error);
|
|
416
|
+
const limited = /HTTP (?:403|429)\b/.test(detail);
|
|
417
|
+
return {
|
|
418
|
+
message: `couldn't look up the latest RuvNet Brain release (${detail}).`,
|
|
419
|
+
hint: [
|
|
420
|
+
limited
|
|
421
|
+
? 'GitHub allows a limited number of anonymous release checks per hour from one network address. Wait until the reset time above, then re-run the same command.'
|
|
422
|
+
: 'Check your connection, then re-run the same command in a minute.',
|
|
423
|
+
`Nothing was downloaded or installed. To install one specific release instead: add --version <tag> (tags: https://github.com/${REPO}/releases).`,
|
|
424
|
+
].join('\n'),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
396
428
|
// ββ step: resolve the cache dir ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
397
429
|
function resolveCacheDir() {
|
|
398
430
|
const custom = process.env.RUVNET_BRAIN_KB;
|
|
@@ -1249,24 +1281,33 @@ export function installConsoleRuntime(cacheDir, sourceRoot = REPO_ROOT) {
|
|
|
1249
1281
|
}
|
|
1250
1282
|
}
|
|
1251
1283
|
|
|
1284
|
+
// Receipts of Consoles that died (pid gone, port silent) are pruned, not counted: the owner's Mac
|
|
1285
|
+
// reported pending-console-restart forever from two receipts left on 2026-09-16/17
|
|
1286
|
+
// (scripts/console-instances.mjs).
|
|
1287
|
+
/**
|
|
1288
|
+
* --doctor's view of a recorded convergence receipt. Its Console state is a snapshot from the last sync;
|
|
1289
|
+
* a recorded pending-console-restart is re-read against the LIVE receipts, so a Console that has since
|
|
1290
|
+
* exited (or a receipt it left when it died) stops failing --doctor. Returns a new object.
|
|
1291
|
+
*/
|
|
1292
|
+
export function withLiveConsoleState(recorded, { receiptDir, alive, probe } = {}) {
|
|
1293
|
+
if (recorded?.consoleRuntime?.state !== 'pending-console-restart' || !recorded.consoleRuntime.sourceSha256) return recorded;
|
|
1294
|
+
const { replacementFailures, ...kept } = recorded.consoleRuntime;
|
|
1295
|
+
const live = { ...kept, ...consoleRestartState(recorded.consoleRuntime, { receiptDir, alive, probe }) };
|
|
1296
|
+
if (live.state !== 'ready' && replacementFailures) live.replacementFailures = replacementFailures;
|
|
1297
|
+
return { ...recorded, consoleRuntime: live };
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1252
1300
|
export function consoleRestartState(identity, {
|
|
1253
1301
|
receiptDir = path.join(process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'console-instances'),
|
|
1302
|
+
alive, probe,
|
|
1254
1303
|
} = {}) {
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
receipts = fs.readdirSync(receiptDir)
|
|
1258
|
-
.filter((name) => name.endsWith('.json'))
|
|
1259
|
-
.map((name) => {
|
|
1260
|
-
try { return JSON.parse(fs.readFileSync(path.join(receiptDir, name), 'utf8')); }
|
|
1261
|
-
catch { return null; }
|
|
1262
|
-
})
|
|
1263
|
-
.filter((receipt) => receipt?.product === 'ruvnet-brain-console' && receipt.schema === 1);
|
|
1264
|
-
} catch { /* no running Console receipts is the ordinary ready state */ }
|
|
1265
|
-
const staleInstances = receipts.filter((receipt) => receipt.sourceSha256 !== identity.sourceSha256).length;
|
|
1304
|
+
const { live, pruned } = readConsoleReceipts(receiptDir, { ...(alive ? { alive } : {}), ...(probe ? { probe } : {}) });
|
|
1305
|
+
const staleInstances = live.filter(({ receipt }) => receipt.sourceSha256 !== identity.sourceSha256).length;
|
|
1266
1306
|
return {
|
|
1267
1307
|
state: staleInstances > 0 ? 'pending-console-restart' : 'ready',
|
|
1268
|
-
instanceReceipts:
|
|
1308
|
+
instanceReceipts: live.length,
|
|
1269
1309
|
staleInstances,
|
|
1310
|
+
...(pruned.length ? { prunedDeadReceipts: pruned.length } : {}),
|
|
1270
1311
|
};
|
|
1271
1312
|
}
|
|
1272
1313
|
|
|
@@ -1384,7 +1425,9 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1384
1425
|
const manualMarketplace = `claude plugin marketplace add ${marketplaceSource}`;
|
|
1385
1426
|
const manualInstall = 'claude plugin install ruvnet-brain@ruvnet-brain --scope user';
|
|
1386
1427
|
|
|
1387
|
-
|
|
1428
|
+
const claudeCli = waitForHostCli('claude');
|
|
1429
|
+
if (!claudeCli.present) {
|
|
1430
|
+
if (claudeCli.message) warn(claudeCli.message);
|
|
1388
1431
|
warn(`I couldn't run the \`claude\` command from this shell.`);
|
|
1389
1432
|
info(`That's normal if you use Claude Code as the ${c.bold('VS Code extension')} or ${c.bold('desktop app')} β the`);
|
|
1390
1433
|
info(`command just isn't on your terminal's PATH. ${c.green('The brain itself is fully downloaded.')}`);
|
|
@@ -1401,15 +1444,15 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1401
1444
|
? inspectPluginShellBoundary(before.installPath)
|
|
1402
1445
|
: { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
|
|
1403
1446
|
const addedMarket = before.managed
|
|
1404
|
-
?
|
|
1405
|
-
:
|
|
1447
|
+
? tryHostCli('claude', ['plugin', 'marketplace', 'update', 'ruvnet-brain'])
|
|
1448
|
+
: tryHostCli('claude', ['plugin', 'marketplace', 'add', marketplaceSource]);
|
|
1406
1449
|
// Deliberately NOT reassuring here. This used to say "it may already be added β that's fine",
|
|
1407
1450
|
// which is a GUESS about someone else's machine, and when it was wrong the user finished the
|
|
1408
1451
|
// install with a working search_ruvnet, no slash commands, and a message telling them all was
|
|
1409
1452
|
// well. The real state is checked below; nothing is declared fine until it has been looked at.
|
|
1410
1453
|
if (!addedMarket) info(`marketplace add didn't report success β checking what actually landedβ¦`);
|
|
1411
1454
|
|
|
1412
|
-
|
|
1455
|
+
tryHostCli('claude', before.installed
|
|
1413
1456
|
? ['plugin', 'update', 'ruvnet-brain@ruvnet-brain', '--scope', 'user']
|
|
1414
1457
|
: ['plugin', 'install', 'ruvnet-brain@ruvnet-brain', '--scope', 'user']);
|
|
1415
1458
|
|
|
@@ -1834,13 +1877,16 @@ function runCodexJson(args, {
|
|
|
1834
1877
|
codexHome = codexHomeDir(),
|
|
1835
1878
|
cwd = process.cwd(),
|
|
1836
1879
|
} = {}) {
|
|
1837
|
-
const r =
|
|
1880
|
+
const r = runHostCli(codexBin, args, {
|
|
1881
|
+
stdio: 'pipe',
|
|
1882
|
+
shell: false,
|
|
1838
1883
|
cwd,
|
|
1839
1884
|
env: { ...process.env, CODEX_HOME: codexHome },
|
|
1840
1885
|
encoding: 'utf8',
|
|
1841
1886
|
timeout: 30_000,
|
|
1842
1887
|
maxBuffer: 20 * 1024 * 1024,
|
|
1843
1888
|
});
|
|
1889
|
+
if (r.missingBinary) return { ok: false, error: r.message };
|
|
1844
1890
|
if (r.error || r.status !== 0) {
|
|
1845
1891
|
const detail = String(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim();
|
|
1846
1892
|
return { ok: false, error: detail };
|
|
@@ -2732,7 +2778,9 @@ async function doctor() {
|
|
|
2732
2778
|
let hostConvergence = { healthy: true, state: 'not-recorded' };
|
|
2733
2779
|
if (fs.existsSync(convergencePath)) {
|
|
2734
2780
|
try {
|
|
2735
|
-
|
|
2781
|
+
const recorded = withLiveConsoleState(JSON.parse(fs.readFileSync(convergencePath, 'utf8')),
|
|
2782
|
+
{ receiptDir: path.join(path.dirname(convergencePath), 'console-instances') });
|
|
2783
|
+
hostConvergence = classifyHostConvergence(recorded);
|
|
2736
2784
|
if (hostConvergence.healthy) ok(`host convergence receipt: ${hostConvergence.state}`);
|
|
2737
2785
|
else {
|
|
2738
2786
|
warn(`host convergence incomplete: ${hostConvergence.state}`);
|
|
@@ -3252,6 +3300,18 @@ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = n
|
|
|
3252
3300
|
if (!requireResult) return { verdict: 'legacy-success', fallback: false, exitCode: 0 };
|
|
3253
3301
|
return { verdict: 'invalid-result', fallback: false, exitCode: 1 };
|
|
3254
3302
|
}
|
|
3303
|
+
// The updater refused BECAUSE full-KB copies already sit beside the brain ("refusing to create another
|
|
3304
|
+
// full-KB copy"). A fresh install is exactly another full copy (it preserves the prior generation), so
|
|
3305
|
+
// the fallback would turn the refusal into +1 copy per run (measured: 2 -> 3, +1.3 GB). Report instead.
|
|
3306
|
+
if (/^unresolved rollback state exists/.test(String(result?.reason || ''))) {
|
|
3307
|
+
return { verdict: 'refused-retained-copies', fallback: false, exitCode: status || 1 };
|
|
3308
|
+
}
|
|
3309
|
+
// Exit 2 is "manifest unreachable, nothing touched". The fallback exists for a DEAD manifest URL (an old
|
|
3310
|
+
// bundle polling a path that 404s); a rate limit, a 5xx or no network is transient, and a full fresh
|
|
3311
|
+
// reinstall over it re-downloads the brain and preserves another full copy each time. Retry later instead.
|
|
3312
|
+
if (status === 2 && /returned HTTP (?:403|408|429|5\d\d)\b|network failure/.test(String(result?.reason || ''))) {
|
|
3313
|
+
return { verdict: 'transient-network', fallback: false, exitCode: 2 };
|
|
3314
|
+
}
|
|
3255
3315
|
return { verdict: 'failed', fallback: fallbackAllowed, exitCode: status || 1 };
|
|
3256
3316
|
}
|
|
3257
3317
|
|
|
@@ -3348,6 +3408,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3348
3408
|
wireCodexHost: detectCodexHost = wireCodexHost,
|
|
3349
3409
|
wireCodexPlugin: installCodexPlugin = wireCodexPlugin,
|
|
3350
3410
|
hostLockPath = path.join(brainHome, 'host-convergence.lock'),
|
|
3411
|
+
replaceConsoles = null, // test seam; production runs replaceStaleConsoles
|
|
3351
3412
|
runStableSpine = (apply) => spawnSync(
|
|
3352
3413
|
process.execPath,
|
|
3353
3414
|
[apply, '--auto', '--expected-version', PACKAGE_VERSION],
|
|
@@ -3433,12 +3494,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3433
3494
|
warn(`stale plugin generations were not pruned (${e.message}); nothing was removed`);
|
|
3434
3495
|
}
|
|
3435
3496
|
const receiptPath = path.join(brainHome, 'host-convergence.json');
|
|
3436
|
-
|
|
3437
|
-
runtimeTransaction.activate();
|
|
3438
|
-
results.consoleRuntime = {
|
|
3439
|
-
...runtimeTransaction.identity,
|
|
3440
|
-
...consoleRestartState(runtimeTransaction.identity, { receiptDir: consoleReceiptDir }),
|
|
3441
|
-
};
|
|
3497
|
+
const writeConvergenceReceipt = () => {
|
|
3442
3498
|
fs.mkdirSync(brainHome, { recursive: true });
|
|
3443
3499
|
const tmp = `${receiptPath}.tmp-${process.pid}`;
|
|
3444
3500
|
fs.writeFileSync(tmp, JSON.stringify({
|
|
@@ -3451,10 +3507,39 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3451
3507
|
consoleRuntime: results.consoleRuntime,
|
|
3452
3508
|
}, null, 2));
|
|
3453
3509
|
fs.renameSync(tmp, receiptPath);
|
|
3510
|
+
};
|
|
3511
|
+
try {
|
|
3512
|
+
runtimeTransaction.activate();
|
|
3513
|
+
results.consoleRuntime = {
|
|
3514
|
+
...runtimeTransaction.identity,
|
|
3515
|
+
...consoleRestartState(runtimeTransaction.identity, { receiptDir: consoleReceiptDir }),
|
|
3516
|
+
};
|
|
3517
|
+
writeConvergenceReceipt();
|
|
3454
3518
|
runtimeTransaction.commit();
|
|
3455
3519
|
} catch (error) {
|
|
3456
3520
|
return fail({ applyStatus: applied.status, error: `Console runtime convergence failed: ${error.message}` });
|
|
3457
3521
|
}
|
|
3522
|
+
// A Console still serving the previous runtime is replaced here, by the activated runtime's own
|
|
3523
|
+
// launcher (the 'stale-running' path of scripts/onboarding-console.mjs, without --open), so an
|
|
3524
|
+
// update never ends with "restart Console". Anything it could not replace is recorded with why.
|
|
3525
|
+
if (results.consoleRuntime.state === 'pending-console-restart') {
|
|
3526
|
+
try {
|
|
3527
|
+
const replaceArgs = { entry: runtimeTransaction.entry, identity: runtimeTransaction.identity, receiptDir: consoleReceiptDir };
|
|
3528
|
+
results.consoleReplacement = replaceConsoles ? replaceConsoles(replaceArgs) : replaceStaleConsoles(replaceArgs);
|
|
3529
|
+
const failures = results.consoleReplacement.filter((item) => !item.replaced);
|
|
3530
|
+
for (const item of results.consoleReplacement) {
|
|
3531
|
+
if (item.replaced) ok(`Console on port ${item.port} replaced with the current runtime (pid ${item.newPid})`);
|
|
3532
|
+
}
|
|
3533
|
+
results.consoleRuntime = {
|
|
3534
|
+
...runtimeTransaction.identity,
|
|
3535
|
+
...consoleRestartState(runtimeTransaction.identity, { receiptDir: consoleReceiptDir }),
|
|
3536
|
+
...(failures.length ? { replacementFailures: failures.map((item) => `port ${item.port ?? '?'}: ${item.reason}`) } : {}),
|
|
3537
|
+
};
|
|
3538
|
+
writeConvergenceReceipt();
|
|
3539
|
+
} catch (error) {
|
|
3540
|
+
warn(`could not replace the running Console automatically (${error.message})`);
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
3458
3543
|
}
|
|
3459
3544
|
if (!okApplied) return fail({ applyStatus: applied.status, error: applied.error?.message || 'Stable Spine activation failed' });
|
|
3460
3545
|
const convergence = classifyHostConvergence({
|
|
@@ -3486,7 +3571,9 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
|
|
|
3486
3571
|
}
|
|
3487
3572
|
if (badHost) return { healthy: false, state: 'host-pending', action: 're-run host synchronization' };
|
|
3488
3573
|
if (receipt.consoleRuntime?.state !== 'ready') {
|
|
3489
|
-
|
|
3574
|
+
const why = Array.isArray(receipt.consoleRuntime?.replacementFailures) && receipt.consoleRuntime.replacementFailures.length
|
|
3575
|
+
? `the installer could not replace the running Console (${receipt.consoleRuntime.replacementFailures.join('; ')}); ` : '';
|
|
3576
|
+
return { healthy: false, state: receipt.consoleRuntime?.state || 'console-unproven', action: `${why}restart Console, then re-run --doctor` };
|
|
3490
3577
|
}
|
|
3491
3578
|
return { healthy: true, state: 'channels-converged' };
|
|
3492
3579
|
}
|
|
@@ -3565,6 +3652,21 @@ async function runUpdate() {
|
|
|
3565
3652
|
};
|
|
3566
3653
|
process.once('exit', exitGuard);
|
|
3567
3654
|
info(`brain dir: ${c.bold(kbDir)}`);
|
|
3655
|
+
// RECOVER AN INTERRUPTED UPDATE FIRST, before anything below writes into the brain. Two measured
|
|
3656
|
+
// reasons: (1) a kill between the two directory renames leaves no usable kb/ β the brain sits in a
|
|
3657
|
+
// receipted kb.rollback-<id>, and the recovery that renames it back lives inside the updater that is
|
|
3658
|
+
// now missing; (2) the preflight below re-stamps RUNTIME-IDENTITY.json into kb/, after which recovery
|
|
3659
|
+
// can never prove kb/ still equals the identity sealed at LOCKED, so any pre-activation kill wedged
|
|
3660
|
+
// every later update at RECOVERY_REQUIRED. Same function the updater runs, under this refresh lock.
|
|
3661
|
+
// Receipts hold the REAL paths the updater knew, so recovery is addressed by the physical path.
|
|
3662
|
+
if (fs.existsSync(path.join(path.dirname(kbDir), `.${path.basename(kbDir)}.update-transactions`))) {
|
|
3663
|
+
try {
|
|
3664
|
+
const recovered = recoverIncompleteStorageTransactions(physicalPath(kbDir));
|
|
3665
|
+
if (recovered.length) ok(`restored the brain from an interrupted update (${recovered.map((r) => `${r.transactionId}: ${r.from}`).join(', ')})`);
|
|
3666
|
+
} catch (error) {
|
|
3667
|
+
warn(`an interrupted update could not be recovered automatically: ${error.message}`);
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3568
3670
|
let updateStatus = 1;
|
|
3569
3671
|
// NO updater at all = no brain installed here (or a pre-self-updater bundle). That is a USER
|
|
3570
3672
|
// message, not a fallback trigger: fail LOUD with the re-run-installer help and exit β never
|
|
@@ -3623,6 +3725,10 @@ async function runUpdate() {
|
|
|
3623
3725
|
result: updaterResult,
|
|
3624
3726
|
requireResult: supportsResultReceipt,
|
|
3625
3727
|
});
|
|
3728
|
+
if (outcome.verdict === 'refused-retained-copies') {
|
|
3729
|
+
warn('nothing was changed: full copies of earlier brain generations already sit beside this one (listed above).');
|
|
3730
|
+
info('Check that you no longer need them, remove them, then re-run npx ruvnet-brain --update');
|
|
3731
|
+
}
|
|
3626
3732
|
if (outcome.fallback && FLAG_HOST_SYNC_ONLY) {
|
|
3627
3733
|
// Host synchronization has a narrower contract than a full update: it must converge the
|
|
3628
3734
|
// executable plugin/spine to the published package even when an optional large KB asset is
|
|
@@ -3650,7 +3756,7 @@ async function runUpdate() {
|
|
|
3650
3756
|
let fr;
|
|
3651
3757
|
if (hasPrivateOverlay) {
|
|
3652
3758
|
warn("\nthe installed updater failed; using authenticated staged recovery to preserve private storesβ¦\n");
|
|
3653
|
-
const release = await resolveRelease();
|
|
3759
|
+
const release = await resolveRelease().catch((error) => die(error.message, error.hint));
|
|
3654
3760
|
const bundle = await obtainBundle(release);
|
|
3655
3761
|
if (!bundle.zipPath) throw new Error('private-overlay recovery requires a downloadable signed bundle');
|
|
3656
3762
|
const sigPath = `${bundle.zipPath}.sig`;
|
|
@@ -4929,7 +5035,7 @@ async function offerStack(env) {
|
|
|
4929
5035
|
for (const m of missing) {
|
|
4930
5036
|
if (m.shell) {
|
|
4931
5037
|
info(`installing ${m.what} β¦ ${c.dim(m.say)}`);
|
|
4932
|
-
const ran = tryRun(m.shell[0], m.shell[1]);
|
|
5038
|
+
const ran = ['claude', 'codex'].includes(m.shell[0]) ? tryHostCli(m.shell[0], m.shell[1]) : tryRun(m.shell[0], m.shell[1]);
|
|
4933
5039
|
// Don't trust the exit code alone β e.g. `claude mcp add` exits non-zero on "already exists",
|
|
4934
5040
|
// which is functionally success. Re-check the real state (m.verify) before warning.
|
|
4935
5041
|
if (ran || (m.verify && m.verify())) ok(`${m.what} added`);
|
|
@@ -5333,8 +5439,9 @@ function showHelp() {
|
|
|
5333
5439
|
console.log(`
|
|
5334
5440
|
RuvNet Brain installer
|
|
5335
5441
|
|
|
5336
|
-
By default this installs the LATEST published Release (it asks GitHub which one that is)
|
|
5337
|
-
|
|
5442
|
+
By default this installs the LATEST published Release (it asks GitHub which one that is).
|
|
5443
|
+
If GitHub can't be reached or rate-limits the check, it STOPS with the reason and downloads
|
|
5444
|
+
nothing; re-run later, or pick a release yourself with --version <tag>.
|
|
5338
5445
|
|
|
5339
5446
|
Usage:
|
|
5340
5447
|
npx ruvnet-brain Install the brain + Claude Code plugin (recommended, npm)
|
|
@@ -5476,9 +5583,9 @@ the installer reports that boot-level declarations changed.
|
|
|
5476
5583
|
installedTag = installedBrainVersion(cacheDir); // 'unknown' when SOURCE.json has no releaseTag
|
|
5477
5584
|
try {
|
|
5478
5585
|
resolvedRelease = await resolveRelease();
|
|
5479
|
-
// ONLY a genuine `latest` lookup counts as "what current means".
|
|
5480
|
-
//
|
|
5481
|
-
// source:'fallback'. Treating that as latest
|
|
5586
|
+
// ONLY a genuine `latest` lookup counts as "what current means". A failed lookup now THROWS
|
|
5587
|
+
// (caught below: "could not check"); it used to return the hardcoded known-good pin with
|
|
5588
|
+
// source:'fallback'. Treating that as latest inverted this whole fix: a rate-limited lookup
|
|
5482
5589
|
// would report installed v3.4.21-dev "β latest v2.9.0" and DOWNGRADE a perfectly current
|
|
5483
5590
|
// machine. (Caught by exercising the failure path against a 404 repo β the first version of
|
|
5484
5591
|
// this fix did exactly that.) A pinned/forced resolution is likewise the operator's explicit
|
|
@@ -5541,7 +5648,8 @@ the installer reports that boot-level declarations changed.
|
|
|
5541
5648
|
const localZipPresent =
|
|
5542
5649
|
FLAG_LOCAL || fs.existsSync(path.join(REPO_ROOT, 'dist', 'ruvnet-brain.zip'));
|
|
5543
5650
|
// Reuse the staleness check's resolution when it already ran β one network round-trip, not two.
|
|
5544
|
-
const release = localZipPresent ? null
|
|
5651
|
+
const release = localZipPresent ? null
|
|
5652
|
+
: (resolvedRelease || await resolveRelease().catch((error) => die(error.message, error.hint)));
|
|
5545
5653
|
const { zipPath, sourceDir, tmpDir, downloaded, sigError } = await obtainBundle(release);
|
|
5546
5654
|
// Verify the Ed25519 signature BEFORE extracting a downloaded bundle into the user's config
|
|
5547
5655
|
// (SEC-0010 #6 β trust root = the pubkey EMBEDDED in this file, so an attacker who swaps the
|
package/kb/brain-profile.mjs
CHANGED
|
@@ -61,16 +61,31 @@ function profileOwnership(dir) {
|
|
|
61
61
|
? Object.entries(source.stores) : null;
|
|
62
62
|
if (!entries) throw new Error('invalid SOURCE ownership policy');
|
|
63
63
|
const privateNames = new Set(fence.privateStores.map((s) => s.toLowerCase()));
|
|
64
|
+
// Published store names include dots (`dspy.ts`, `ruv.io`). A name may not start with a dot, hold `..`
|
|
65
|
+
// or a separator, or end in an artifact suffix (`x.big` would collide with store x's `x.big.rvf`).
|
|
66
|
+
const safeName = (name) => typeof name === 'string' && /^[a-z0-9][a-z0-9._-]*$/i.test(name) && !name.includes('..')
|
|
67
|
+
&& !/\.(?:big|rvf)$/i.test(name);
|
|
64
68
|
const managed = new Set();
|
|
69
|
+
const optedOut = new Set();
|
|
65
70
|
const seen = new Set();
|
|
66
71
|
for (const [name, value] of entries) {
|
|
67
|
-
if (
|
|
72
|
+
if (!safeName(name)
|
|
68
73
|
|| !value || typeof value !== 'object' || Array.isArray(value)
|
|
69
74
|
|| (value.kbName != null && value.kbName !== name)
|
|
70
75
|
|| (value.updateManaged != null && typeof value.updateManaged !== 'boolean')
|
|
71
76
|
|| seen.has(name.toLowerCase())) throw new Error('invalid SOURCE store ownership');
|
|
72
77
|
seen.add(name.toLowerCase());
|
|
73
|
-
if (value.updateManaged
|
|
78
|
+
if (value.updateManaged === false) optedOut.add(name.toLowerCase());
|
|
79
|
+
else if (!privateNames.has(name.toLowerCase())) managed.add(name);
|
|
80
|
+
}
|
|
81
|
+
// The release's PUBLIC ledger is the other ownership record: stores it publishes but SOURCE does not list
|
|
82
|
+
// (concepts, ruv-gists) are still release-owned, and forge-update's profiled check expects them gone.
|
|
83
|
+
const publicFile = path.join(dir, 'PUBLIC-RVF-GENERATIONS.json');
|
|
84
|
+
if (fs.existsSync(publicFile)) {
|
|
85
|
+
for (const name of Object.keys(read('PUBLIC-RVF-GENERATIONS.json').stores || {})) {
|
|
86
|
+
const key = String(name).toLowerCase();
|
|
87
|
+
if (safeName(name) && !privateNames.has(key) && !optedOut.has(key)) managed.add(name);
|
|
88
|
+
}
|
|
74
89
|
}
|
|
75
90
|
return { managed, read };
|
|
76
91
|
}
|