ruvnet-brain 4.3.40 β 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 +111 -27
- package/kb/brain-profile.mjs +17 -2
- package/kb/forge-update.mjs +6 -2
- package/kb/lifecycle-evidence-retention.mjs +12 -9
- package/kb/refresh-run.mjs +17 -1
- package/kb/update-storage-transaction.mjs +33 -5
- 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/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';
|
|
@@ -228,6 +231,13 @@ function tryRun(cmd, args, opts = {}) {
|
|
|
228
231
|
const r = spawnSync(cmd, args, { stdio: 'inherit', shell: IS_WIN, ...opts });
|
|
229
232
|
return !r.error && r.status === 0;
|
|
230
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
|
+
}
|
|
231
241
|
|
|
232
242
|
// ββ download with redirect-following + progress ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
233
243
|
function download(url, dest, redirects = 0) {
|
|
@@ -1271,24 +1281,33 @@ export function installConsoleRuntime(cacheDir, sourceRoot = REPO_ROOT) {
|
|
|
1271
1281
|
}
|
|
1272
1282
|
}
|
|
1273
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
|
+
|
|
1274
1300
|
export function consoleRestartState(identity, {
|
|
1275
1301
|
receiptDir = path.join(process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'console-instances'),
|
|
1302
|
+
alive, probe,
|
|
1276
1303
|
} = {}) {
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
receipts = fs.readdirSync(receiptDir)
|
|
1280
|
-
.filter((name) => name.endsWith('.json'))
|
|
1281
|
-
.map((name) => {
|
|
1282
|
-
try { return JSON.parse(fs.readFileSync(path.join(receiptDir, name), 'utf8')); }
|
|
1283
|
-
catch { return null; }
|
|
1284
|
-
})
|
|
1285
|
-
.filter((receipt) => receipt?.product === 'ruvnet-brain-console' && receipt.schema === 1);
|
|
1286
|
-
} catch { /* no running Console receipts is the ordinary ready state */ }
|
|
1287
|
-
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;
|
|
1288
1306
|
return {
|
|
1289
1307
|
state: staleInstances > 0 ? 'pending-console-restart' : 'ready',
|
|
1290
|
-
instanceReceipts:
|
|
1308
|
+
instanceReceipts: live.length,
|
|
1291
1309
|
staleInstances,
|
|
1310
|
+
...(pruned.length ? { prunedDeadReceipts: pruned.length } : {}),
|
|
1292
1311
|
};
|
|
1293
1312
|
}
|
|
1294
1313
|
|
|
@@ -1406,7 +1425,9 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1406
1425
|
const manualMarketplace = `claude plugin marketplace add ${marketplaceSource}`;
|
|
1407
1426
|
const manualInstall = 'claude plugin install ruvnet-brain@ruvnet-brain --scope user';
|
|
1408
1427
|
|
|
1409
|
-
|
|
1428
|
+
const claudeCli = waitForHostCli('claude');
|
|
1429
|
+
if (!claudeCli.present) {
|
|
1430
|
+
if (claudeCli.message) warn(claudeCli.message);
|
|
1410
1431
|
warn(`I couldn't run the \`claude\` command from this shell.`);
|
|
1411
1432
|
info(`That's normal if you use Claude Code as the ${c.bold('VS Code extension')} or ${c.bold('desktop app')} β the`);
|
|
1412
1433
|
info(`command just isn't on your terminal's PATH. ${c.green('The brain itself is fully downloaded.')}`);
|
|
@@ -1423,15 +1444,15 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1423
1444
|
? inspectPluginShellBoundary(before.installPath)
|
|
1424
1445
|
: { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
|
|
1425
1446
|
const addedMarket = before.managed
|
|
1426
|
-
?
|
|
1427
|
-
:
|
|
1447
|
+
? tryHostCli('claude', ['plugin', 'marketplace', 'update', 'ruvnet-brain'])
|
|
1448
|
+
: tryHostCli('claude', ['plugin', 'marketplace', 'add', marketplaceSource]);
|
|
1428
1449
|
// Deliberately NOT reassuring here. This used to say "it may already be added β that's fine",
|
|
1429
1450
|
// which is a GUESS about someone else's machine, and when it was wrong the user finished the
|
|
1430
1451
|
// install with a working search_ruvnet, no slash commands, and a message telling them all was
|
|
1431
1452
|
// well. The real state is checked below; nothing is declared fine until it has been looked at.
|
|
1432
1453
|
if (!addedMarket) info(`marketplace add didn't report success β checking what actually landedβ¦`);
|
|
1433
1454
|
|
|
1434
|
-
|
|
1455
|
+
tryHostCli('claude', before.installed
|
|
1435
1456
|
? ['plugin', 'update', 'ruvnet-brain@ruvnet-brain', '--scope', 'user']
|
|
1436
1457
|
: ['plugin', 'install', 'ruvnet-brain@ruvnet-brain', '--scope', 'user']);
|
|
1437
1458
|
|
|
@@ -1856,13 +1877,16 @@ function runCodexJson(args, {
|
|
|
1856
1877
|
codexHome = codexHomeDir(),
|
|
1857
1878
|
cwd = process.cwd(),
|
|
1858
1879
|
} = {}) {
|
|
1859
|
-
const r =
|
|
1880
|
+
const r = runHostCli(codexBin, args, {
|
|
1881
|
+
stdio: 'pipe',
|
|
1882
|
+
shell: false,
|
|
1860
1883
|
cwd,
|
|
1861
1884
|
env: { ...process.env, CODEX_HOME: codexHome },
|
|
1862
1885
|
encoding: 'utf8',
|
|
1863
1886
|
timeout: 30_000,
|
|
1864
1887
|
maxBuffer: 20 * 1024 * 1024,
|
|
1865
1888
|
});
|
|
1889
|
+
if (r.missingBinary) return { ok: false, error: r.message };
|
|
1866
1890
|
if (r.error || r.status !== 0) {
|
|
1867
1891
|
const detail = String(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim();
|
|
1868
1892
|
return { ok: false, error: detail };
|
|
@@ -2754,7 +2778,9 @@ async function doctor() {
|
|
|
2754
2778
|
let hostConvergence = { healthy: true, state: 'not-recorded' };
|
|
2755
2779
|
if (fs.existsSync(convergencePath)) {
|
|
2756
2780
|
try {
|
|
2757
|
-
|
|
2781
|
+
const recorded = withLiveConsoleState(JSON.parse(fs.readFileSync(convergencePath, 'utf8')),
|
|
2782
|
+
{ receiptDir: path.join(path.dirname(convergencePath), 'console-instances') });
|
|
2783
|
+
hostConvergence = classifyHostConvergence(recorded);
|
|
2758
2784
|
if (hostConvergence.healthy) ok(`host convergence receipt: ${hostConvergence.state}`);
|
|
2759
2785
|
else {
|
|
2760
2786
|
warn(`host convergence incomplete: ${hostConvergence.state}`);
|
|
@@ -3274,6 +3300,18 @@ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = n
|
|
|
3274
3300
|
if (!requireResult) return { verdict: 'legacy-success', fallback: false, exitCode: 0 };
|
|
3275
3301
|
return { verdict: 'invalid-result', fallback: false, exitCode: 1 };
|
|
3276
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
|
+
}
|
|
3277
3315
|
return { verdict: 'failed', fallback: fallbackAllowed, exitCode: status || 1 };
|
|
3278
3316
|
}
|
|
3279
3317
|
|
|
@@ -3370,6 +3408,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3370
3408
|
wireCodexHost: detectCodexHost = wireCodexHost,
|
|
3371
3409
|
wireCodexPlugin: installCodexPlugin = wireCodexPlugin,
|
|
3372
3410
|
hostLockPath = path.join(brainHome, 'host-convergence.lock'),
|
|
3411
|
+
replaceConsoles = null, // test seam; production runs replaceStaleConsoles
|
|
3373
3412
|
runStableSpine = (apply) => spawnSync(
|
|
3374
3413
|
process.execPath,
|
|
3375
3414
|
[apply, '--auto', '--expected-version', PACKAGE_VERSION],
|
|
@@ -3455,12 +3494,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3455
3494
|
warn(`stale plugin generations were not pruned (${e.message}); nothing was removed`);
|
|
3456
3495
|
}
|
|
3457
3496
|
const receiptPath = path.join(brainHome, 'host-convergence.json');
|
|
3458
|
-
|
|
3459
|
-
runtimeTransaction.activate();
|
|
3460
|
-
results.consoleRuntime = {
|
|
3461
|
-
...runtimeTransaction.identity,
|
|
3462
|
-
...consoleRestartState(runtimeTransaction.identity, { receiptDir: consoleReceiptDir }),
|
|
3463
|
-
};
|
|
3497
|
+
const writeConvergenceReceipt = () => {
|
|
3464
3498
|
fs.mkdirSync(brainHome, { recursive: true });
|
|
3465
3499
|
const tmp = `${receiptPath}.tmp-${process.pid}`;
|
|
3466
3500
|
fs.writeFileSync(tmp, JSON.stringify({
|
|
@@ -3473,10 +3507,39 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3473
3507
|
consoleRuntime: results.consoleRuntime,
|
|
3474
3508
|
}, null, 2));
|
|
3475
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();
|
|
3476
3518
|
runtimeTransaction.commit();
|
|
3477
3519
|
} catch (error) {
|
|
3478
3520
|
return fail({ applyStatus: applied.status, error: `Console runtime convergence failed: ${error.message}` });
|
|
3479
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
|
+
}
|
|
3480
3543
|
}
|
|
3481
3544
|
if (!okApplied) return fail({ applyStatus: applied.status, error: applied.error?.message || 'Stable Spine activation failed' });
|
|
3482
3545
|
const convergence = classifyHostConvergence({
|
|
@@ -3508,7 +3571,9 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
|
|
|
3508
3571
|
}
|
|
3509
3572
|
if (badHost) return { healthy: false, state: 'host-pending', action: 're-run host synchronization' };
|
|
3510
3573
|
if (receipt.consoleRuntime?.state !== 'ready') {
|
|
3511
|
-
|
|
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` };
|
|
3512
3577
|
}
|
|
3513
3578
|
return { healthy: true, state: 'channels-converged' };
|
|
3514
3579
|
}
|
|
@@ -3587,6 +3652,21 @@ async function runUpdate() {
|
|
|
3587
3652
|
};
|
|
3588
3653
|
process.once('exit', exitGuard);
|
|
3589
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
|
+
}
|
|
3590
3670
|
let updateStatus = 1;
|
|
3591
3671
|
// NO updater at all = no brain installed here (or a pre-self-updater bundle). That is a USER
|
|
3592
3672
|
// message, not a fallback trigger: fail LOUD with the re-run-installer help and exit β never
|
|
@@ -3645,6 +3725,10 @@ async function runUpdate() {
|
|
|
3645
3725
|
result: updaterResult,
|
|
3646
3726
|
requireResult: supportsResultReceipt,
|
|
3647
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
|
+
}
|
|
3648
3732
|
if (outcome.fallback && FLAG_HOST_SYNC_ONLY) {
|
|
3649
3733
|
// Host synchronization has a narrower contract than a full update: it must converge the
|
|
3650
3734
|
// executable plugin/spine to the published package even when an optional large KB asset is
|
|
@@ -4951,7 +5035,7 @@ async function offerStack(env) {
|
|
|
4951
5035
|
for (const m of missing) {
|
|
4952
5036
|
if (m.shell) {
|
|
4953
5037
|
info(`installing ${m.what} β¦ ${c.dim(m.say)}`);
|
|
4954
|
-
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]);
|
|
4955
5039
|
// Don't trust the exit code alone β e.g. `claude mcp add` exits non-zero on "already exists",
|
|
4956
5040
|
// which is functionally success. Re-check the real state (m.verify) before warning.
|
|
4957
5041
|
if (ran || (m.verify && m.verify())) ok(`${m.what} added`);
|
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
|
}
|
package/kb/forge-update.mjs
CHANGED
|
@@ -650,7 +650,9 @@ export function restorePrivateOverlayState({ kbDir, overlay }) {
|
|
|
650
650
|
const cardsFile = path.join(kbDir, 'capability-cards.md');
|
|
651
651
|
const source = JSON.parse(fs.readFileSync(sourceFile, 'utf8'));
|
|
652
652
|
const generations = JSON.parse(fs.readFileSync(generationsFile, 'utf8'));
|
|
653
|
-
|
|
653
|
+
// A public bundle may ship no repo-aliases.json at all (build-bundle: "aliases will not resolve"); the
|
|
654
|
+
// private aliases then start from an empty map instead of failing the whole update on ENOENT.
|
|
655
|
+
const aliases = fs.existsSync(aliasesFile) ? JSON.parse(fs.readFileSync(aliasesFile, 'utf8')) : {};
|
|
654
656
|
const mergedSource = mergePrivateEntries(source.stores, overlay.sourceStores, 'SOURCE.json');
|
|
655
657
|
const mergedGenerations = mergePrivateEntries(generations.stores, overlay.generationStores, 'RVF-GENERATIONS.json');
|
|
656
658
|
const mergedAliases = mergePrivateEntries(aliases, overlay.aliases, 'repo-aliases.json');
|
|
@@ -1640,7 +1642,9 @@ async function main() {
|
|
|
1640
1642
|
if (!APPLY) {
|
|
1641
1643
|
writeCheckOutcome({ currencyVerdict: verdict.verdict, currencyReason: verdict.reason,
|
|
1642
1644
|
candidateKind: candidateIdentity.kind, storeCount: targets.length });
|
|
1643
|
-
|
|
1645
|
+
// The npx door upgrades this updater before applying; an old installed updater run directly can fail
|
|
1646
|
+
// the guard on a newer bundle (customer-state-matrix D8, 2026-09-30).
|
|
1647
|
+
if (anyBehind) { console.log(`\nA newer build exists. Run: npx ruvnet-brain@latest --update`); process.exit(10); }
|
|
1644
1648
|
console.log(`\nAll stores current. Nothing to do.`); process.exit(0);
|
|
1645
1649
|
}
|
|
1646
1650
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { physicalPath } from './refresh-run.mjs';
|
|
4
5
|
|
|
5
6
|
const TERMINAL_REFRESH = new Set(['SUCCEEDED', 'FAILED', 'ABANDONED']);
|
|
6
7
|
const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
|
|
@@ -113,10 +114,11 @@ function newest(rows, predicate) {
|
|
|
113
114
|
|
|
114
115
|
function trustedEvidenceRoot(root, unsafe) {
|
|
115
116
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
// The PARENT is where the user put the brain (`~/.cache/ruvnet-brain` may be a link to another disk):
|
|
118
|
+
// it must be a directory once resolved. The evidence root itself must not be a link.
|
|
119
|
+
if (!fs.statSync(physicalPath(path.dirname(root))).isDirectory()) throw new Error('evidence root parent is not a directory');
|
|
120
|
+
const stat = fs.lstatSync(root);
|
|
121
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('evidence root is not a trusted directory (symbolic or special entry)');
|
|
120
122
|
return true;
|
|
121
123
|
} catch (error) {
|
|
122
124
|
if (error.code !== 'ENOENT') unsafe.push({ path: root, reason: error.message });
|
|
@@ -130,7 +132,7 @@ function snapshot(options) {
|
|
|
130
132
|
const refresh = trustedEvidenceRoot(roots.refresh, unsafe) ? scanRefresh(roots.refresh, unsafe) : [];
|
|
131
133
|
const transactions = trustedEvidenceRoot(roots.transactions, unsafe) ? scanTransactions(roots.transactions, unsafe) : [];
|
|
132
134
|
const preserveRefresh = new Set((options.preserveRefreshRunIds || []).map(String));
|
|
133
|
-
const preserveTransactions = new Set((options.preserveTransactionPaths || []).map((entry) =>
|
|
135
|
+
const preserveTransactions = new Set((options.preserveTransactionPaths || []).map((entry) => physicalPath(entry)));
|
|
134
136
|
const protectedRefresh = new Map();
|
|
135
137
|
const protectRefresh = (row, reason) => { if (row) protectedRefresh.set(row.path, reason); };
|
|
136
138
|
for (const row of refresh) {
|
|
@@ -147,17 +149,18 @@ function snapshot(options) {
|
|
|
147
149
|
if (!protectedRefresh.has(row.path)) continue;
|
|
148
150
|
for (const reference of transactionReferences(row.receipt)) {
|
|
149
151
|
const resolved = path.resolve(String(reference || ''));
|
|
150
|
-
|
|
152
|
+
// The updater records real paths; the caller may spell the brain through a link. Same space, both sides.
|
|
153
|
+
const relative = path.relative(physicalPath(roots.transactions), physicalPath(resolved));
|
|
151
154
|
if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || relative.includes(path.sep)) {
|
|
152
155
|
unsafe.push({ path: row.path, reason: `forged external transaction reference: ${String(reference)}` });
|
|
153
|
-
} else retainedTransactionPaths.add(resolved);
|
|
156
|
+
} else retainedTransactionPaths.add(physicalPath(resolved));
|
|
154
157
|
}
|
|
155
158
|
}
|
|
156
159
|
const protectedTransactions = new Map();
|
|
157
160
|
for (const row of transactions) {
|
|
158
161
|
if (!TERMINAL_TRANSACTION.has(row.latest.state)) protectedTransactions.set(row.path, `nonterminal ${row.latest.state}`);
|
|
159
|
-
if (preserveTransactions.has(row.path)) protectedTransactions.set(row.path, 'explicitly preserved transaction');
|
|
160
|
-
if (retainedTransactionPaths.has(row.path)) protectedTransactions.set(row.path, 'referenced by retained refresh receipt');
|
|
162
|
+
if (preserveTransactions.has(physicalPath(row.path))) protectedTransactions.set(row.path, 'explicitly preserved transaction');
|
|
163
|
+
if (retainedTransactionPaths.has(physicalPath(row.path))) protectedTransactions.set(row.path, 'referenced by retained refresh receipt');
|
|
161
164
|
}
|
|
162
165
|
const bytes = [...refresh, ...transactions].reduce((sum, row) => sum + row.bytes, 0)
|
|
163
166
|
+ unsafe.filter(({ reason }) => /quarantine remains/.test(reason)).reduce((sum, { path: entry }) => {
|
package/kb/refresh-run.mjs
CHANGED
|
@@ -78,6 +78,19 @@ export function inspectRefreshOwner(owner, { hostname = os.hostname(), inspectPr
|
|
|
78
78
|
return ownerState(owner, { hostname, inspectProcess, isAlive });
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The ONE physical-path rule for brain directories. A user may reach the brain through a symlink
|
|
83
|
+
* (`~/.cache -> /Volumes/<disk>`) while the updater knows it by its real path (Node resolves
|
|
84
|
+
* import.meta.url), so identity checks compare this, never spellings. A directory that does not
|
|
85
|
+
* exist right now (kb/ mid-rename) resolves through its parent, so the answer does not flip.
|
|
86
|
+
*/
|
|
87
|
+
export function physicalPath(dir) {
|
|
88
|
+
const resolved = path.resolve(String(dir || ''));
|
|
89
|
+
try { return fs.realpathSync.native(resolved); } catch { /* absent: resolve through the parent */ }
|
|
90
|
+
try { return path.join(fs.realpathSync.native(path.dirname(resolved)), path.basename(resolved)); }
|
|
91
|
+
catch { return resolved; }
|
|
92
|
+
}
|
|
93
|
+
|
|
81
94
|
export const refreshLockPath = (kbDir) =>
|
|
82
95
|
path.join(path.dirname(path.resolve(kbDir)), `.${path.basename(path.resolve(kbDir))}.refresh-run.lock`);
|
|
83
96
|
|
|
@@ -156,7 +169,10 @@ export function acquireRefreshLock({
|
|
|
156
169
|
const inherited = String(env.RUVNET_REFRESH_RUN_TOKEN || '');
|
|
157
170
|
if (inherited) {
|
|
158
171
|
const owner = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
|
|
159
|
-
|
|
172
|
+
// Same DIRECTORY, not same spelling: the installer locks RUVNET_BRAIN_KB as given (possibly through
|
|
173
|
+
// a symlinked ~/.cache), the child updater knows its KB by import.meta.url, which Node resolves to
|
|
174
|
+
// the real path. Compare physical identity; a genuinely different directory still refuses.
|
|
175
|
+
if (owner.schemaVersion !== 3 || owner.token !== inherited || physicalPath(owner.kbDir) !== physicalPath(root)
|
|
160
176
|
|| path.resolve(owner.receiptPath || '') !== refreshReceiptPath(owner.brainHome, owner.runId)) {
|
|
161
177
|
throw new Error(`refresh lock inheritance does not match ${lockPath}`);
|
|
162
178
|
}
|
|
@@ -258,7 +258,23 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
|
|
|
258
258
|
const phaseFiles = fs.readdirSync(receipts).filter((name) => /^\d{3}-[A-Z_]+\.json$/.test(name)).sort();
|
|
259
259
|
if (!phaseFiles.length) throw new Error(`storage transaction receipt is empty: ${receipts}`);
|
|
260
260
|
const latest = JSON.parse(fs.readFileSync(path.join(receipts, phaseFiles.at(-1)), 'utf8'));
|
|
261
|
-
if (['NOOP', 'COMMITTED', 'ROLLED_BACK'].includes(latest.state))
|
|
261
|
+
if (['NOOP', 'COMMITTED', 'ROLLED_BACK'].includes(latest.state)) {
|
|
262
|
+
// A quarantined unsealed candidate is kept for ONE full update cycle, then released on the next
|
|
263
|
+
// run β but only if its bytes are exactly what was sealed when it was quarantined.
|
|
264
|
+
const quarantine = latest.quarantinedUnsealedCandidate;
|
|
265
|
+
if (latest.state === 'ROLLED_BACK' && quarantine && latest.quarantineReclaimed !== true
|
|
266
|
+
&& path.resolve(quarantine) === transactionPaths(live, transactionId).failed) {
|
|
267
|
+
const unchanged = !fs.existsSync(quarantine) || (() => {
|
|
268
|
+
try { requireDigest(quarantine, latest.quarantineIdentity, 'quarantined candidate'); return true; } catch { return false; }
|
|
269
|
+
})();
|
|
270
|
+
if (unchanged) {
|
|
271
|
+
removeIfPresent(quarantine);
|
|
272
|
+
appendRecoveryReceipt(receipts, 'ROLLED_BACK', { quarantineReclaimed: true,
|
|
273
|
+
reason: 'released the quarantined unsealed candidate one update cycle later (bytes unchanged)' });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
262
278
|
if (latest.state === 'RECOVERY_REQUIRED') throw new Error(`storage transaction requires manual recovery: ${transactionId}`);
|
|
263
279
|
const paths = latest.paths;
|
|
264
280
|
const expectedPaths = transactionPaths(live, transactionId);
|
|
@@ -273,12 +289,22 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
|
|
|
273
289
|
try {
|
|
274
290
|
// Validate every retained tree before any rename or deletion. A receipt owns
|
|
275
291
|
// paths, but cannot authorize discarding bytes added after the process died.
|
|
276
|
-
|
|
292
|
+
// A kill DURING candidate building (the long phase: copy, private restore, guard) leaves a candidate
|
|
293
|
+
// that was never sealed, so no receipt can vouch for its bytes. Refusing made every later update
|
|
294
|
+
// fail forever; deleting would discard bytes nothing proved disposable. It is QUARANTINED instead:
|
|
295
|
+
// renamed intact to this transaction's `failed` path, named in the receipt, and live (proved equal
|
|
296
|
+
// to the prior identity) stays in service.
|
|
297
|
+
const unsealedCandidate = fs.existsSync(paths.candidate) && !latest.candidate?.sha256
|
|
298
|
+
&& ['LOCKED', 'CANDIDATE_BUILDING'].includes(latest.state);
|
|
299
|
+
if (fs.existsSync(paths.candidate) && !unsealedCandidate) requireDigest(paths.candidate, latest.candidate, 'interrupted candidate');
|
|
277
300
|
if (fs.existsSync(paths.rollback)) requireDigest(paths.rollback, prior, 'interrupted rollback');
|
|
278
301
|
if (fs.existsSync(paths.failed)) throw new Error('interrupted failed tree has no safe recovery disposition');
|
|
279
302
|
if (['LOCKED', 'CANDIDATE_BUILDING', 'CANDIDATE_VERIFIED'].includes(latest.state)) {
|
|
280
303
|
requireDigest(live, prior, 'interrupted live');
|
|
281
|
-
|
|
304
|
+
if (unsealedCandidate) {
|
|
305
|
+
assertDirectory(paths.candidate, 'unsealed candidate');
|
|
306
|
+
fs.renameSync(paths.candidate, paths.failed);
|
|
307
|
+
} else removeIfPresent(paths.candidate);
|
|
282
308
|
} else if (latest.state === 'OLD_RENAME_STARTED') {
|
|
283
309
|
const hasLive = fs.existsSync(live);
|
|
284
310
|
const hasRollback = fs.existsSync(paths.rollback);
|
|
@@ -324,10 +350,12 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
|
|
|
324
350
|
continue;
|
|
325
351
|
} else throw new Error(`unsupported interrupted state ${latest.state}`);
|
|
326
352
|
const delta = storageDelta(paths, { prior, candidate: latest.candidate || null });
|
|
353
|
+
const quarantined = unsealedCandidate
|
|
354
|
+
? { quarantinedUnsealedCandidate: paths.failed, quarantineIdentity: identitySummary(treeIdentity(paths.failed)) } : {};
|
|
327
355
|
appendRecoveryReceipt(receipts, 'ROLLED_BACK', { terminalVerdict: 'interrupted-run-restored', prior,
|
|
328
|
-
storageDelta: delta, reason: `recovered interrupted ${latest.state} transaction before new work
|
|
356
|
+
storageDelta: delta, reason: `recovered interrupted ${latest.state} transaction before new work`, ...quarantined });
|
|
329
357
|
recovered.push({ transactionId, from: latest.state, terminalVerdict: 'interrupted-run-restored',
|
|
330
|
-
storageDelta: delta });
|
|
358
|
+
storageDelta: delta, ...quarantined });
|
|
331
359
|
} catch (error) {
|
|
332
360
|
appendRecoveryReceipt(receipts, 'RECOVERY_REQUIRED', { terminalVerdict: 'recovery-required', prior,
|
|
333
361
|
reason: error.message });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain \u2014 a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
3
|
"description": "RuvNet brain transplant for Claude Code β grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.4.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -92,8 +92,8 @@
|
|
|
92
92
|
"hooks": [
|
|
93
93
|
{
|
|
94
94
|
"type": "command",
|
|
95
|
-
"command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\"
|
|
96
|
-
"timeout":
|
|
95
|
+
"command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 2500 session-snapshot SessionEnd",
|
|
96
|
+
"timeout": 3
|
|
97
97
|
}
|
|
98
98
|
]
|
|
99
99
|
}
|
package/plugin/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "RuvNet Brain lifecycle plane. The broad legacy routing, learning, and release interceptors remain retired. What is automatic is exactly: SessionStart restores the canonical project checkpoint; UserPromptSubmit runs the single unprompted-speech chokepoint, ground-ruvnet grounding injection, and a capacity-aware context hint for clearly large independent work. The capacity hook uses bounded CPU/memory-pressure evidence, never starts agents, and tells the coordinator to check live tools and clamp to the runtime cap; it is not user-facing speech. ground-ruvnet remains a directive to the model, not speech β ADR-040 Β§Amendment 2026-09-11. PreToolUse runs decision-gate's write route, the ONE process that may refuse a Write/Edit/MultiEdit/NotebookEdit/apply_patch (ADR-067, composing ground-before-write per ADR-0012); PostToolUse on a successful search_ruvnet mints the grounding stamp that opens that gate. grounding-turn-mark (UserPromptSubmit) records that the grounding directive fired this turn, and grounding-turn-gate (Stop) forces continuation if no search_ruvnet call was recorded since. Stop also runs the ledger-scoped continuation handler and captures a project snapshot; PreCompact and SessionEnd capture a project snapshot. Every capture is bounded and fails open; only decision-gate may block on exit code, and it fails open on its own errors β the Stop stdout envelopes are advisory at the shim boundary but keep the agent working.",
|
|
2
|
+
"description": "RuvNet Brain lifecycle plane. The broad legacy routing, learning, and release interceptors remain retired. What is automatic is exactly: SessionStart restores the canonical project checkpoint; UserPromptSubmit runs the single unprompted-speech chokepoint, ground-ruvnet grounding injection, and a capacity-aware context hint for clearly large independent work. The capacity hook uses bounded CPU/memory-pressure evidence, never starts agents, and tells the coordinator to check live tools and clamp to the runtime cap; it is not user-facing speech. ground-ruvnet remains a directive to the model, not speech β ADR-040 Β§Amendment 2026-09-11. PreToolUse runs decision-gate's write route, the ONE process that may refuse a Write/Edit/MultiEdit/NotebookEdit/apply_patch (ADR-067, composing ground-before-write per ADR-0012); PostToolUse on a successful search_ruvnet mints the grounding stamp that opens that gate. grounding-turn-mark (UserPromptSubmit) records that the grounding directive fired this turn, and grounding-turn-gate (Stop) forces continuation if the final answer asserts a rUv capability and no search_ruvnet call was recorded since. Stop also runs the ledger-scoped continuation handler and captures a project snapshot; PreCompact and SessionEnd capture a project snapshot. Every capture is bounded and fails open; only decision-gate may block on exit code, and it fails open on its own errors β the Stop stdout envelopes are advisory at the shim boundary but keep the agent working.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{
|
|
@@ -451,11 +451,11 @@ export const CAPABILITIES = [
|
|
|
451
451
|
if (d.unreadable) {
|
|
452
452
|
const locked = /lock|busy|writer/i.test(String(d.unreadable));
|
|
453
453
|
return row(STATE.UNKNOWN, locked
|
|
454
|
-
? `the memory store
|
|
454
|
+
? `could not read the memory store: another process is holding it (${d.unreadable}) β that is a passing lock, not a fault; re-checking in a moment should clear it`
|
|
455
455
|
: `the memory store could not be read (${d.unreadable}) β this is not a transient lock, so re-checking will not clear it; the store or its journal files need attention before distillation state can be established`);
|
|
456
456
|
}
|
|
457
|
-
if (d.schemaless) return row(STATE.UNKNOWN, 'the store exists but has no memory_entries table (pre-AgentDB schema, never initialised) β nothing to distill yet, and nothing is broken');
|
|
458
|
-
if (typeof d.total !== 'number') return row(STATE.UNKNOWN, 'the store opened but returned no countable rows β distillation state not established');
|
|
457
|
+
if (d.schemaless) return row(STATE.UNKNOWN, 'cannot measure distillation: the store exists but has no memory_entries table (pre-AgentDB schema, never initialised) β nothing to distill yet, and nothing is broken');
|
|
458
|
+
if (typeof d.total !== 'number') return row(STATE.UNKNOWN, 'the store opened but returned no countable rows β distillation state could not be established');
|
|
459
459
|
|
|
460
460
|
if (d.total === 0) return row(STATE.ABSENT, 'the memory store is empty, so there is nothing to distill yet');
|
|
461
461
|
if (d.learns) return row(STATE.ON, `${d.patterns} reusable patterns distilled from ${d.real} memories (${(d.cover * 100).toFixed(1)}% embedded)`);
|
|
@@ -60,7 +60,7 @@ const blockingHooks = new Set([
|
|
|
60
60
|
const DETACHED_HOOKS = new Set(['learn-flush']);
|
|
61
61
|
|
|
62
62
|
/** THE BUDGET IS DERIVED FROM WHAT THE HOOK MEASURABLY COSTS, never from what looks tidy. */
|
|
63
|
-
function timeoutFor(hookId) {
|
|
63
|
+
function timeoutFor(hookId, event = '') {
|
|
64
64
|
const override = Number(process.env.RUVNET_CODEX_HOOK_TIMEOUT_MS);
|
|
65
65
|
if (Number.isFinite(override) && override > 0) return override;
|
|
66
66
|
// decision-gate's own internal budget is 4000ms (RUVNET_DECISION_BUDGET_MS) and it is allowed to
|
|
@@ -75,6 +75,11 @@ function timeoutFor(hookId) {
|
|
|
75
75
|
if (hookId === 'ground-ruvnet' || hookId === 'unprompted-speech' || hookId === 'continuation-gate') {
|
|
76
76
|
return 8_500;
|
|
77
77
|
}
|
|
78
|
+
// SessionEnd is hard-capped at 3s by the host (see DETACHED_HOOKS above) and the codex-hooks.json
|
|
79
|
+
// launcher kills this wrapper at 2500ms. A 4000ms budget here was a number nobody would ever reach:
|
|
80
|
+
// the body planned for 8s and was SIGKILLed mid-write. 2200ms leaves the launcher its margin, and the
|
|
81
|
+
// body receives it as RUVNET_CODEX_BUDGET_MS (below) so it can save the new snapshot first.
|
|
82
|
+
if (event === 'SessionEnd') return 2_200;
|
|
78
83
|
return 4_000;
|
|
79
84
|
}
|
|
80
85
|
|
|
@@ -193,7 +198,7 @@ if (DETACHED_HOOKS.has(hookId)) {
|
|
|
193
198
|
process.exit(0);
|
|
194
199
|
}
|
|
195
200
|
|
|
196
|
-
const budgetMs = timeoutFor(hookId);
|
|
201
|
+
const budgetMs = timeoutFor(hookId, process.argv[3] || '');
|
|
197
202
|
const result = spawnSync(process.execPath, [adapter, ...process.argv.slice(2)], {
|
|
198
203
|
input,
|
|
199
204
|
encoding: 'utf8',
|
|
@@ -29,6 +29,7 @@ INPUT=""
|
|
|
29
29
|
# exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
|
|
30
30
|
# is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
|
|
31
31
|
# the whole thing back at once and a per-iteration cap never fires.
|
|
32
|
+
_l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
|
|
32
33
|
while IFS= read -r -t 2 _l; do
|
|
33
34
|
INPUT+="$_l"
|
|
34
35
|
[ ${#INPUT} -ge 65536 ] && break
|
|
@@ -43,6 +43,7 @@ INPUT=""
|
|
|
43
43
|
# exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
|
|
44
44
|
# is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
|
|
45
45
|
# the whole thing back at once and a per-iteration cap never fires.
|
|
46
|
+
_l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
|
|
46
47
|
while IFS= read -r -t 2 _l; do
|
|
47
48
|
INPUT+="$_l"
|
|
48
49
|
[ ${#INPUT} -ge 65536 ] && break
|