ruvnet-brain 4.5.9 → 4.5.11
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 +48 -93
- package/bin/nightly-refresh.mjs +20 -34
- package/kb/download-retry.mjs +97 -0
- package/kb/forge-update.mjs +12 -10
- package/kb/verify-citation.mjs +10 -13
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/automatic-update.mjs +125 -0
- package/plugin/scripts/completion-claim-evidence.mjs +9 -4
- package/plugin/scripts/continuation-gate.mjs +34 -10
- package/plugin/scripts/continuation-objective.mjs +45 -10
- package/plugin/scripts/continuity-journal.mjs +11 -11
- package/plugin/scripts/ground-ruvnet.sh +3 -0
- package/plugin/scripts/grounding-code-projection.mjs +6 -1
- package/plugin/scripts/grounding-turn-evidence.mjs +2 -2
- package/plugin/scripts/grounding-turn-gate.mjs +8 -14
- package/plugin/scripts/grounding-turn-mark.mjs +106 -20
- package/plugin/scripts/hijack-ruvnet.sh +4 -4
- package/plugin/scripts/hook-input.mjs +1 -1
- package/plugin/scripts/host-update.mjs +16 -15
- package/plugin/scripts/lesson-presentation.mjs +2 -1
- package/plugin/scripts/nightly-scheduler.mjs +34 -7
- package/plugin/scripts/project-progression-hook.mjs +22 -10
- package/plugin/scripts/project-progression-producer.mjs +11 -1
- package/plugin/scripts/project-progression-store.mjs +3 -0
- package/plugin/scripts/project-transition-hook.mjs +7 -1
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +33 -0
- package/plugin/scripts/session-snapshot-hook.mjs +4 -3
- package/plugin/scripts/session-start-core.mjs +3 -1
- package/plugin/scripts/turn-capture-privacy.mjs +202 -0
- package/plugin/scripts/turn-outcome-capture.mjs +28 -13
- package/plugin/scripts/user-settings.mjs +23 -1
- package/scripts/brain-grade-groundtruth.mjs +1 -0
- package/scripts/brain-score.mjs +11 -11
- package/scripts/claude-controlled-terminal.mjs +265 -0
- package/scripts/issue-watch.mjs +21 -10
- package/scripts/model-terminal-launchers.mjs +19 -9
- package/scripts/model-weekly-cycle.mjs +45 -9
- package/scripts/record-lesson.mjs +13 -7
- package/scripts/release-qualification-contract.mjs +35 -4
- package/scripts/user-model-hook-install.mjs +145 -0
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
|
|
package/bin/install.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// Design rules: dependency-free (Node built-ins + shelling to unzip/npm/claude only), idempotent
|
|
12
12
|
// (safe to re-run), and never a silent half-state (every failure explains the next step).
|
|
13
13
|
|
|
14
|
-
import
|
|
14
|
+
import { downloadFileWithRetry, httpsJsonWithRetry } from '../kb/download-retry.mjs';
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import os from 'node:os';
|
|
17
17
|
import path from 'node:path';
|
|
@@ -25,6 +25,7 @@ import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, physicalP
|
|
|
25
25
|
recordRefreshPhase, settleRefreshRun, UPDATE_REFRESH_PHASES } from '../kb/refresh-run.mjs';
|
|
26
26
|
import { assessLifecycleEvidence, pruneLifecycleEvidence } from '../kb/lifecycle-evidence-retention.mjs';
|
|
27
27
|
import { checkDiskSpace, recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
|
|
28
|
+
import { saveUpdateSource, stableNode } from '../plugin/scripts/automatic-update.mjs';
|
|
28
29
|
import { footprintRoots, inventoryFootprint, sweepFootprint } from '../plugin/scripts/brain-footprint.mjs';
|
|
29
30
|
import { kbCopyProof } from '../plugin/scripts/kb-copy-proof.mjs';
|
|
30
31
|
import { assessMoveLeftovers, isVolumeMetadata } from '../plugin/scripts/footprint-io.mjs';
|
|
@@ -122,8 +123,13 @@ const REPO = 'stuinfla/ruvnet-brain';
|
|
|
122
123
|
const RELEASE_API = `https://api.github.com/repos/${REPO}/releases/latest`;
|
|
123
124
|
const ASSET_NAME = 'ruvnet-brain.zip';
|
|
124
125
|
const fallbackUrl = (tag) => `https://github.com/${REPO}/releases/download/${tag}/${ASSET_NAME}`;
|
|
125
|
-
const APPROX_SIZE = '~736MB';
|
|
126
126
|
|
|
127
|
+
export function optionArg(args, flag) {
|
|
128
|
+
const at = args.indexOf(flag);
|
|
129
|
+
if (at < 0) return undefined;
|
|
130
|
+
if (!args[at + 1] || args[at + 1].startsWith('-') || args.filter(value => value === flag).length !== 1) throw new Error(`${flag} requires one value`);
|
|
131
|
+
return args[at + 1];
|
|
132
|
+
}
|
|
127
133
|
const argv = process.argv.slice(2);
|
|
128
134
|
const FLAG_LOCAL = argv.includes('--local');
|
|
129
135
|
const FLAG_FORCE = argv.includes('--force');
|
|
@@ -182,6 +188,9 @@ export function namedReleaseFromArgs(args) {
|
|
|
182
188
|
};
|
|
183
189
|
const pin = valueAfter('--pin');
|
|
184
190
|
const version = valueAfter('--version');
|
|
191
|
+
if (version === null) {
|
|
192
|
+
return { error: '--version needs the release to install, e.g. --version vX.Y.Z', hint: 'Use --help for installer options; read the package manifest to inspect the installed version.' };
|
|
193
|
+
}
|
|
185
194
|
if (pin === null) {
|
|
186
195
|
return { error: '--pin needs the release to install, e.g. --pin vX.Y.Z', hint: `It no longer falls back to a built-in release (that bundle could not pass validation). Pick one from https://github.com/${REPO}/releases, or omit --pin to install the latest.` };
|
|
187
196
|
}
|
|
@@ -270,94 +279,25 @@ function tryHostCli(cmd, args, opts = {}) {
|
|
|
270
279
|
}
|
|
271
280
|
|
|
272
281
|
// ── download with redirect-following + progress ──────────────────────────────────────────────────
|
|
273
|
-
function download(url, dest
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
(
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
res.resume();
|
|
288
|
-
return reject(new Error(`server returned HTTP ${statusCode}`));
|
|
289
|
-
}
|
|
290
|
-
const total = Number(headers['content-length'] || 0);
|
|
291
|
-
let received = 0;
|
|
292
|
-
let lastShown = -1;
|
|
293
|
-
const out = fs.createWriteStream(dest);
|
|
294
|
-
res.on('data', (chunk) => {
|
|
295
|
-
received += chunk.length;
|
|
296
|
-
const mb = (received / 1e6).toFixed(0);
|
|
297
|
-
if (total) {
|
|
298
|
-
const pct = Math.floor((received / total) * 100);
|
|
299
|
-
if (pct !== lastShown && pct % 5 === 0) {
|
|
300
|
-
process.stdout.write(`\r …${pct}% (${mb}MB / ${(total / 1e6).toFixed(0)}MB)`);
|
|
301
|
-
lastShown = pct;
|
|
302
|
-
}
|
|
303
|
-
} else if (mb % 20 === 0 && Number(mb) !== lastShown) {
|
|
304
|
-
process.stdout.write(`\r …${mb}MB`);
|
|
305
|
-
lastShown = Number(mb);
|
|
306
|
-
}
|
|
307
|
-
});
|
|
308
|
-
res.pipe(out);
|
|
309
|
-
out.on('finish', () => out.close(() => {
|
|
310
|
-
process.stdout.write('\n');
|
|
311
|
-
resolve();
|
|
312
|
-
}));
|
|
313
|
-
out.on('error', (e) => reject(e));
|
|
314
|
-
},
|
|
315
|
-
);
|
|
316
|
-
req.on('error', (e) => reject(e));
|
|
317
|
-
});
|
|
282
|
+
function download(url, dest) {
|
|
283
|
+
let lastShown = -1;
|
|
284
|
+
return downloadFileWithRetry(url, dest, {
|
|
285
|
+
headers: { 'User-Agent': 'ruvnet-brain-installer', Accept: 'application/octet-stream' },
|
|
286
|
+
onRetry: ({ attempt, error }) => warn(`download interrupted (${error.message}); retry ${attempt + 1}/3 from a fresh stage`),
|
|
287
|
+
onProgress: ({ received, total }) => {
|
|
288
|
+
const mb = Math.floor(received / 1e6);
|
|
289
|
+
const pct = total ? Math.floor(received / total * 100) : mb;
|
|
290
|
+
if (pct !== lastShown && pct % (total ? 5 : 20) === 0) {
|
|
291
|
+
process.stdout.write(`\r …${total ? `${pct}% (${mb}MB / ${Math.floor(total / 1e6)}MB)` : `${mb}MB`}`);
|
|
292
|
+
lastShown = pct;
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
}).then(() => process.stdout.write('\n'));
|
|
318
296
|
}
|
|
319
297
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (redirects > 10) return reject(new Error('too many redirects'));
|
|
324
|
-
const req = https.get(
|
|
325
|
-
url,
|
|
326
|
-
{
|
|
327
|
-
headers: {
|
|
328
|
-
// GitHub's API requires a User-Agent; the Accept header pins the v3 JSON schema.
|
|
329
|
-
'User-Agent': 'ruvnet-brain-installer',
|
|
330
|
-
Accept: 'application/vnd.github+json',
|
|
331
|
-
},
|
|
332
|
-
timeout: 15000,
|
|
333
|
-
},
|
|
334
|
-
(res) => {
|
|
335
|
-
const { statusCode = 0, headers } = res;
|
|
336
|
-
if ([301, 302, 303, 307, 308].includes(statusCode) && headers.location) {
|
|
337
|
-
res.resume();
|
|
338
|
-
const next = new URL(headers.location, url).toString();
|
|
339
|
-
return resolve(fetchJson(next, redirects + 1));
|
|
340
|
-
}
|
|
341
|
-
if (statusCode !== 200) {
|
|
342
|
-
res.resume();
|
|
343
|
-
const reset = headers['x-ratelimit-remaining'] === '0' && Number(headers['x-ratelimit-reset']);
|
|
344
|
-
return reject(new Error(`GitHub API returned HTTP ${statusCode}${reset
|
|
345
|
-
? ` (anonymous rate limit used up; it resets at ${new Date(reset * 1000).toISOString()})` : ''}`));
|
|
346
|
-
}
|
|
347
|
-
let body = '';
|
|
348
|
-
res.setEncoding('utf8');
|
|
349
|
-
res.on('data', (chunk) => (body += chunk));
|
|
350
|
-
res.on('end', () => {
|
|
351
|
-
try {
|
|
352
|
-
resolve(JSON.parse(body));
|
|
353
|
-
} catch (e) {
|
|
354
|
-
reject(new Error(`GitHub API response was not valid JSON: ${e.message}`));
|
|
355
|
-
}
|
|
356
|
-
});
|
|
357
|
-
},
|
|
358
|
-
);
|
|
359
|
-
req.on('timeout', () => req.destroy(new Error('GitHub API request timed out')));
|
|
360
|
-
req.on('error', (e) => reject(e));
|
|
298
|
+
function fetchJson(url) {
|
|
299
|
+
return httpsJsonWithRetry(url, {
|
|
300
|
+
headers: { 'User-Agent': 'ruvnet-brain-installer', Accept: 'application/vnd.github+json' },
|
|
361
301
|
});
|
|
362
302
|
}
|
|
363
303
|
|
|
@@ -505,7 +445,7 @@ async function obtainBundle(release) {
|
|
|
505
445
|
if (!release || !release.url) die('no release was resolved to download.', 'Re-run to look up the latest release, or name one with --version <tag>.');
|
|
506
446
|
const downloadUrl = release.url;
|
|
507
447
|
step(
|
|
508
|
-
|
|
448
|
+
'Downloading the brain',
|
|
509
449
|
'the brain embeds source from dozens of RuvNet repos — too big for git, so it ships as a Release',
|
|
510
450
|
);
|
|
511
451
|
info(`version: ${c.bold(release.tag)}`);
|
|
@@ -520,7 +460,7 @@ async function obtainBundle(release) {
|
|
|
520
460
|
process.on('exit', () => { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } });
|
|
521
461
|
const tmp = path.join(tmpDir, ASSET_NAME);
|
|
522
462
|
try {
|
|
523
|
-
console.log(
|
|
463
|
+
console.log(' downloading the brain…');
|
|
524
464
|
await download(downloadUrl, tmp);
|
|
525
465
|
} catch (e) {
|
|
526
466
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
@@ -607,7 +547,7 @@ function placeFileAtomic(target, bytes) {
|
|
|
607
547
|
// one. The signed npm package carries the updater and the sibling modules it imports; the `--update`
|
|
608
548
|
// preflight places them (same trust as the validator) so a stale updater upgrades itself before it runs.
|
|
609
549
|
const UPDATER_FILES = ['forge-update.mjs', 'zip-extract.mjs', 'brain-profile.mjs', 'refresh-run.mjs',
|
|
610
|
-
'update-storage-transaction.mjs', 'lifecycle-evidence-retention.mjs', 'corpus-release-identity.mjs'];
|
|
550
|
+
'update-storage-transaction.mjs', 'lifecycle-evidence-retention.mjs', 'corpus-release-identity.mjs', 'download-retry.mjs'];
|
|
611
551
|
export function placeUpdater(kbDir, { sourceDir = path.join(REPO_ROOT, 'kb') } = {}) {
|
|
612
552
|
const placed = {};
|
|
613
553
|
for (const name of UPDATER_FILES) {
|
|
@@ -4374,7 +4314,8 @@ function enableNightly() {
|
|
|
4374
4314
|
try {
|
|
4375
4315
|
const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(kbDir);
|
|
4376
4316
|
const registration = installNightlyRunner({ brainHome, env: { ...process.env, RUVNET_BRAIN_HOME: brainHome, RUVNET_BRAIN_KB: kbDir },
|
|
4377
|
-
source: path.join(REPO_ROOT, 'bin', 'nightly-refresh.mjs')
|
|
4317
|
+
source: path.join(REPO_ROOT, 'bin', 'nightly-refresh.mjs'),
|
|
4318
|
+
nodePath: stableNode({ explicit: optionArg(argv, '--nightly-node') }) });
|
|
4378
4319
|
const installed = installScheduler(registration, {
|
|
4379
4320
|
platform: process.platform, env: process.env, kbDir, testMode: TEST_MODE,
|
|
4380
4321
|
pathValue: [...new Set([path.dirname(process.execPath), ...(process.env.PATH || '').split(path.delimiter)])].filter(Boolean).join(path.delimiter),
|
|
@@ -4990,7 +4931,7 @@ export function syncManagedRouterTools({ routerDir = path.join(os.homedir(), '.c
|
|
|
4990
4931
|
const destination = path.join(routerDir, 'bin');
|
|
4991
4932
|
fs.mkdirSync(destination, { recursive: true });
|
|
4992
4933
|
let copied = 0;
|
|
4993
|
-
for (const name of ['model-router-engine.mjs', 'model-router-setup.mjs', 'model-router-status.mjs', 'model-router-outcome.mjs', 'subscription-hosts.mjs', 'review-model-defaults.mjs', 'dual-host-deliberation.mjs', 'dual-host-suggest.mjs', 'route-cheap.mjs', 'dispatch-receipt.mjs', 'metaharness-receipts.mjs', 'metaharness-router.mjs', 'model-router-dispatch.mjs', 'model-currency.mjs', 'model-currency-evidence.mjs', 'user-model-prompt-hook.mjs', 'model-router-agent-hook.mjs', 'model-weekly-assessment.mjs', 'model-weekly-analyst.mjs', 'model-weekly-cycle.mjs', 'model-weekly-qualification.mjs', 'model-native-qualification.mjs', 'model-routing-launchers.mjs', 'model-terminal-gateway.mjs', 'model-terminal-launchers.mjs', 'claude-terminal-mod.mjs', 'model-native-catalog.mjs', 'model-analyst-sandbox.mjs', 'model-routing-policy-promotion.mjs', 'model-routing-gateway.mjs', 'native-subscription-usage.mjs', 'codex-routed.sh', 'goldie-weekly.sh']) {
|
|
4934
|
+
for (const name of ['model-router-engine.mjs', 'model-router-setup.mjs', 'model-router-status.mjs', 'model-router-outcome.mjs', 'subscription-hosts.mjs', 'review-model-defaults.mjs', 'dual-host-deliberation.mjs', 'dual-host-suggest.mjs', 'route-cheap.mjs', 'dispatch-receipt.mjs', 'metaharness-receipts.mjs', 'metaharness-router.mjs', 'model-router-dispatch.mjs', 'model-currency.mjs', 'model-currency-evidence.mjs', 'user-model-prompt-hook.mjs', 'model-router-agent-hook.mjs', 'model-weekly-assessment.mjs', 'model-weekly-analyst.mjs', 'model-weekly-cycle.mjs', 'model-weekly-qualification.mjs', 'model-native-qualification.mjs', 'model-routing-launchers.mjs', 'model-terminal-gateway.mjs', 'model-terminal-launchers.mjs', 'claude-terminal-mod.mjs', 'claude-controlled-terminal.mjs', 'model-native-catalog.mjs', 'model-analyst-sandbox.mjs', 'model-routing-policy-promotion.mjs', 'model-routing-gateway.mjs', 'native-subscription-usage.mjs', 'codex-routed.sh', 'goldie-weekly.sh']) {
|
|
4994
4935
|
const source = path.join(packageRoot, 'scripts', name);
|
|
4995
4936
|
if (!fs.existsSync(source)) throw new Error(`Packaged router tool missing: ${name}`);
|
|
4996
4937
|
const target = path.join(destination, name);
|
|
@@ -6183,6 +6124,9 @@ Usage:
|
|
|
6183
6124
|
npx ruvnet-brain --enhance-claude-md Add the 6-line RuvNet-Brain block to ~/.claude/CLAUDE.md
|
|
6184
6125
|
(appended at the bottom between markers; your file is backed up first)
|
|
6185
6126
|
(a default install RECOMMENDS nightly and asks, defaulting to yes)
|
|
6127
|
+
node bin/install.mjs --update-source installed Keep compatible signed corpus updates; never unattended npx
|
|
6128
|
+
node bin/install.mjs --update-source latest Restore the default latest-package update coordinator
|
|
6129
|
+
node bin/install.mjs --enable-nightly --nightly-node /absolute/stable/node Use a supported stable Node alias
|
|
6186
6130
|
node bin/install.mjs --no-nightly-prompt Don't offer nightly auto-updates at the end of the install
|
|
6187
6131
|
node bin/install.mjs --no-telemetry Decline anonymous usage counts without being asked
|
|
6188
6132
|
(counts of installs/searches ONLY — never queries, code, or paths;
|
|
@@ -6230,6 +6174,17 @@ the installer reports that boot-level declarations changed.
|
|
|
6230
6174
|
&& canonical(process.argv[1]) === canonical(fileURLToPath(import.meta.url));
|
|
6231
6175
|
if (!invokedDirectly) return;
|
|
6232
6176
|
if (FLAG_HELP) return showHelp();
|
|
6177
|
+
if (NAMED_RELEASE?.error) {
|
|
6178
|
+
console.error(NAMED_RELEASE.error);
|
|
6179
|
+
console.error(NAMED_RELEASE.hint);
|
|
6180
|
+
process.exitCode = 1;
|
|
6181
|
+
return;
|
|
6182
|
+
}
|
|
6183
|
+
if (argv.includes('--update-source')) {
|
|
6184
|
+
try { saveUpdateSource(optionArg(argv, '--update-source')); }
|
|
6185
|
+
catch (error) { console.error(error.message); process.exitCode = 1; return; }
|
|
6186
|
+
if (!FLAG_UPDATE && !FLAG_ENABLE_NIGHTLY) { info('automatic update source saved; other owner settings preserved'); return; }
|
|
6187
|
+
}
|
|
6233
6188
|
// The Brain moved to another disk (a symlink at ~/.cache/ruvnet-brain) and that disk is not here:
|
|
6234
6189
|
// say so in one line and stop. Never re-create a fresh brain in ~/.cache over the dangling link, and
|
|
6235
6190
|
// never update one that is not there. --doctor reports it as its verdict.
|
package/bin/nightly-refresh.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { spawnSync } from 'node:child_process';
|
|
9
|
-
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
10
|
|
|
11
11
|
const runnerPath = path.resolve(fileURLToPath(import.meta.url));
|
|
12
12
|
const registrationPath = String((process.argv[2] === '--registration' ? process.argv[3] : null) || process.env.RUVNET_NIGHTLY_REGISTRATION
|
|
@@ -21,7 +21,7 @@ try {
|
|
|
21
21
|
&& !/^com\.ruvnet\.brain-update\.proof-[A-Za-z0-9._-]+$/.test(registration.identity || ''))
|
|
22
22
|
|| path.resolve(registration.runnerPath || '') !== runnerPath
|
|
23
23
|
|| registration.runnerSha256 !== runnerSha256
|
|
24
|
-
||
|
|
24
|
+
|| fs.realpathSync(registration.nodePath || '') !== fs.realpathSync(process.execPath)
|
|
25
25
|
|| !Array.isArray(registration.argv) || registration.argv.length !== 0
|
|
26
26
|
|| !registration.packageTarget || typeof registration.packageTarget.spec !== 'string') {
|
|
27
27
|
throw new Error('registration does not bind this exact runner and Node executable');
|
|
@@ -58,44 +58,30 @@ if (registration.environment !== undefined && (!registration.environment || Arra
|
|
|
58
58
|
}
|
|
59
59
|
Object.assign(process.env, registration.environment || {});
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
const shim = fs.existsSync(adjacent) ? adjacent : String(process.env.PATH || '')
|
|
72
|
-
.split(path.delimiter).filter(Boolean).map(dir => path.join(dir, npxName))
|
|
73
|
-
.find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
|
|
74
|
-
if (!shim) throw new Error('managed npm npx shim is missing');
|
|
75
|
-
const packageRoot = fs.realpathSync(path.join(path.dirname(fs.realpathSync(shim)), 'node_modules', 'npm'));
|
|
76
|
-
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
77
|
-
const declared = manifest.bin?.npx;
|
|
78
|
-
if (manifest.name !== 'npm' || typeof declared !== 'string' || !declared
|
|
79
|
-
|| path.isAbsolute(declared) || path.win32.isAbsolute(declared) || declared.split(/[\\/]/).includes('..')) {
|
|
80
|
-
throw new Error('managed npm package has no safe declared npx entry');
|
|
81
|
-
}
|
|
82
|
-
const entry = fs.realpathSync(path.resolve(packageRoot, declared));
|
|
83
|
-
const relative = path.relative(packageRoot, entry);
|
|
84
|
-
if (!relative || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) || !fs.statSync(entry).isFile()) {
|
|
85
|
-
throw new Error('managed npm npx entry escapes its package');
|
|
86
|
-
}
|
|
87
|
-
executable = process.execPath;
|
|
88
|
-
launchArgs = [entry, ...argv];
|
|
89
|
-
} catch (error) {
|
|
90
|
-
console.error(`RuvNet Brain nightly refresh could not resolve managed npm: ${error.message}`);
|
|
91
|
-
process.exit(1);
|
|
61
|
+
let invocation;
|
|
62
|
+
try {
|
|
63
|
+
const modules = registration.updateModules;
|
|
64
|
+
if (!modules || Object.keys(modules).sort().join(',') !== 'automatic-update.mjs,ruvnet-gate1-pattern.mjs,user-settings.mjs') throw new Error('registered update module closure is missing');
|
|
65
|
+
for (const [name, item] of Object.entries(modules)) {
|
|
66
|
+
if (!path.isAbsolute(item.path) || path.basename(item.path) !== name
|
|
67
|
+
|| path.dirname(item.path) !== path.dirname(modules['automatic-update.mjs'].path)
|
|
68
|
+
|| !fs.lstatSync(item.path).isFile()
|
|
69
|
+
|| crypto.createHash('sha256').update(fs.readFileSync(item.path)).digest('hex') !== item.sha256) throw new Error('update module digest mismatch');
|
|
92
70
|
}
|
|
71
|
+
const policy = await import(pathToFileURL(modules['automatic-update.mjs'].path).href);
|
|
72
|
+
process.env.PATH = policy.automaticPath({ nodePath: registration.nodePath });
|
|
73
|
+
invocation = policy.automaticInvocation(['--update', '--no-nightly-prompt'], {
|
|
74
|
+
nodePath: registration.nodePath, packageTarget: registration.packageTarget.spec,
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error(`RuvNet Brain automatic update refused: ${error.message}`);
|
|
78
|
+
process.exit(1);
|
|
93
79
|
}
|
|
94
80
|
const allowed = ['PATH', 'HOME', 'USERPROFILE', 'RUVNET_BRAIN_HOME', 'RUVNET_BRAIN_KB',
|
|
95
81
|
'npm_config_cache', 'NO_COLOR', 'SystemRoot', 'SYSTEMROOT', 'ComSpec', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP'];
|
|
96
82
|
const childEnv = Object.fromEntries(allowed.filter((key) => process.env[key] !== undefined)
|
|
97
83
|
.map((key) => [key, process.env[key]]));
|
|
98
|
-
const result = spawnSync(executable,
|
|
84
|
+
const result = spawnSync(invocation.executable, invocation.args, {
|
|
99
85
|
stdio: 'inherit',
|
|
100
86
|
shell: false,
|
|
101
87
|
env: { ...childEnv, RUVNET_NIGHTLY: '1',
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Retry transport failures only. Parsing, signature and corpus validation remain caller-owned.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
const TRANSIENT = new Set(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN', 'EPIPE',
|
|
8
|
+
'ERR_STREAM_PREMATURE_CLOSE', 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT']);
|
|
9
|
+
function retryable(error) {
|
|
10
|
+
for (let current = error; current; current = current.cause) {
|
|
11
|
+
if (TRANSIENT.has(current.code)) return true;
|
|
12
|
+
}
|
|
13
|
+
return [408, 429, 500, 502, 503, 504].includes(error.status);
|
|
14
|
+
}
|
|
15
|
+
function httpError(status) {
|
|
16
|
+
return Object.assign(new Error(`server returned HTTP ${status}`), { status });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function retryDownload(operation, { deadlineMs = 20 * 60_000,
|
|
20
|
+
attemptMs = 5 * 60_000, delays = [250, 1000], onRetry = () => {} } = {}) {
|
|
21
|
+
const deadline = Date.now() + deadlineMs;
|
|
22
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
23
|
+
const remaining = deadline - Date.now();
|
|
24
|
+
if (remaining <= 0) throw new Error('download deadline exceeded');
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timeout = Object.assign(new Error('download request timed out'), { code: 'ETIMEDOUT' });
|
|
27
|
+
const timer = setTimeout(() => controller.abort(timeout), Math.min(attemptMs, remaining));
|
|
28
|
+
try { return await operation({ signal: controller.signal, attempt }); }
|
|
29
|
+
catch (caught) {
|
|
30
|
+
const error = controller.signal.aborted ? controller.signal.reason : caught;
|
|
31
|
+
const delay = delays[attempt - 1] ?? 0;
|
|
32
|
+
if (attempt === 3 || !retryable(error) || Date.now() + delay >= deadline) throw error;
|
|
33
|
+
onRetry({ attempt, error });
|
|
34
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
35
|
+
} finally { clearTimeout(timer); }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function fetchBytesWithRetry(url, { fetchImpl = globalThis.fetch, ...options } = {}) {
|
|
40
|
+
return retryDownload(async ({ signal }) => {
|
|
41
|
+
const response = await fetchImpl(url, { redirect: 'follow', signal });
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
try { await response.body?.cancel(); } catch { /* preserve the permanent HTTP refusal */ }
|
|
44
|
+
throw httpError(response.status);
|
|
45
|
+
}
|
|
46
|
+
return Buffer.from(await response.arrayBuffer());
|
|
47
|
+
}, options);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function fetchJsonWithRetry(url, options = {}) {
|
|
51
|
+
// Parse AFTER transport succeeds: malformed JSON must never be re-requested or accepted.
|
|
52
|
+
const bytes = await fetchBytesWithRetry(url, { deadlineMs: 45_000, attemptMs: 15_000, ...options });
|
|
53
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function get(url, { signal, headers }, redirects = 0) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
if (redirects > 10) { reject(new Error('too many redirects')); return; }
|
|
59
|
+
const request = https.get(url, { signal, headers }, response => {
|
|
60
|
+
if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
|
|
61
|
+
response.destroy();
|
|
62
|
+
resolve(get(new URL(response.headers.location, url).toString(), { signal, headers }, redirects + 1));
|
|
63
|
+
} else if (response.statusCode !== 200) {
|
|
64
|
+
response.destroy(); reject(httpError(response.statusCode));
|
|
65
|
+
} else resolve(response);
|
|
66
|
+
});
|
|
67
|
+
request.on('error', reject);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function downloadFileWithRetry(url, destination, { headers = {}, onProgress = () => {}, ...options } = {}) {
|
|
72
|
+
return retryDownload(async ({ signal }) => {
|
|
73
|
+
const staged = `${destination}.part-${process.pid}-${randomUUID()}`;
|
|
74
|
+
try {
|
|
75
|
+
const response = await get(url, { signal, headers });
|
|
76
|
+
let received = 0;
|
|
77
|
+
const total = Number(response.headers['content-length'] || 0);
|
|
78
|
+
response.on('data', bytes => { received += bytes.length; onProgress({ received, total }); });
|
|
79
|
+
await pipeline(response, fs.createWriteStream(staged, { flags: 'wx', mode: 0o600 }), { signal });
|
|
80
|
+
if (!response.complete || (total && received !== total)) {
|
|
81
|
+
throw Object.assign(new Error('download body ended prematurely'), { code: 'ECONNRESET' });
|
|
82
|
+
}
|
|
83
|
+
fs.renameSync(staged, destination);
|
|
84
|
+
} finally { fs.rmSync(staged, { force: true }); }
|
|
85
|
+
}, options);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function httpsJsonWithRetry(url, { headers = {}, ...options } = {}) {
|
|
89
|
+
const bytes = await retryDownload(async ({ signal }) => {
|
|
90
|
+
const response = await get(url, { signal, headers });
|
|
91
|
+
const chunks = [];
|
|
92
|
+
for await (const chunk of response) chunks.push(chunk);
|
|
93
|
+
if (!response.complete) throw Object.assign(new Error('response body ended prematurely'), { code: 'ECONNRESET' });
|
|
94
|
+
return Buffer.concat(chunks);
|
|
95
|
+
}, { deadlineMs: 45_000, attemptMs: 15_000, ...options });
|
|
96
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
97
|
+
}
|
package/kb/forge-update.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import os from 'node:os';
|
|
|
25
25
|
import { execFileSync } from 'node:child_process';
|
|
26
26
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
27
27
|
import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto';
|
|
28
|
+
import { fetchJsonWithRetry, fetchBytesWithRetry } from './download-retry.mjs';
|
|
28
29
|
import { extractZip, zipDeclaredBytes } from './zip-extract.mjs';
|
|
29
30
|
import { applyBrainProfile, discoverStoreFamilies, readBrainProfile } from './brain-profile.mjs';
|
|
30
31
|
import { acquireRefreshLock, releaseRefreshLock } from './refresh-run.mjs';
|
|
@@ -773,18 +774,19 @@ if (!manifestUrl && !STAGED_RELEASE_FILE) {
|
|
|
773
774
|
}
|
|
774
775
|
|
|
775
776
|
async function fetchJson(url) {
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
777
|
+
try { return await fetchJsonWithRetry(url); }
|
|
778
|
+
catch (error) {
|
|
779
|
+
if (error instanceof SyntaxError) die(`canonical manifest was not valid JSON: ${error.message}`, 2);
|
|
780
|
+
if (error.status) die(`canonical manifest returned HTTP ${error.status} for ${url} — nothing changed.`, 2);
|
|
781
|
+
die(`network failure fetching ${url}: ${error.message} — nothing changed locally.`, 2);
|
|
782
|
+
}
|
|
781
783
|
}
|
|
782
784
|
async function fetchBuffer(url, { failureCode = 2, kind = 'bundle' } = {}) {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
785
|
+
try { return await fetchBytesWithRetry(url); }
|
|
786
|
+
catch (error) {
|
|
787
|
+
if (error.status) die(`${kind} download returned HTTP ${error.status} for ${url} — nothing changed.`, failureCode);
|
|
788
|
+
die(`network failure downloading ${kind} ${url}: ${error.message} — nothing changed locally.`, failureCode);
|
|
789
|
+
}
|
|
788
790
|
}
|
|
789
791
|
|
|
790
792
|
// The canonical manifest can be ONE of three shapes — handle all three:
|
package/kb/verify-citation.mjs
CHANGED
|
@@ -33,9 +33,9 @@ import readline from 'node:readline';
|
|
|
33
33
|
* stop ("not airtight"). The reader prints, before each body, `chars: <exact body length>`; the parser
|
|
34
34
|
* consumes the body BY THAT COUNT and requires the 67-'=' terminator right after it, so nothing inside a
|
|
35
35
|
* body is ever scanned for headers. A declared length that does not land on the terminator fails closed:
|
|
36
|
-
* that hit keeps no body and nothing after it is trusted
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* that hit keeps no body and nothing after it is trusted. Legacy bodies without `chars:` also stop
|
|
37
|
+
* parsing: their header metadata is retained, but their body and everything after it are untrusted.
|
|
38
|
+
* Metadata-only headers remain supported. `path`/`title`/scores come only from the header lines between
|
|
39
39
|
* a hit's header and its body, never from a body, and hits are numbered #1, #2, … strictly in order.
|
|
40
40
|
*/
|
|
41
41
|
const SEPARATOR = '='.repeat(67);
|
|
@@ -44,10 +44,10 @@ export function parseCitations(stdout) {
|
|
|
44
44
|
const text = String(stdout ?? '');
|
|
45
45
|
const headerRe = /^#(\d+)[ \t]+repo=(\S+)([^\r\n]*)/gm;
|
|
46
46
|
const nextHeaderRe = /^#\d+\s+repo=\S+/gm;
|
|
47
|
-
|
|
47
|
+
// Recognize marker-shaped lines too: malformed framing must not expose body headers as metadata.
|
|
48
|
+
const markerRe = /^[ \t]*-{3,}[ \t]*full[ \t]+document[ \t]*-{3,}[^\r\n]*(?:\r?\n|$)/gm;
|
|
48
49
|
let m;
|
|
49
50
|
let expectedRank = 1;
|
|
50
|
-
let structured = false;
|
|
51
51
|
while ((m = headerRe.exec(text)) !== null) {
|
|
52
52
|
const rank = Number(m[1]);
|
|
53
53
|
if (rank !== expectedRank) continue; // out-of-sequence header: a look-alike, not a real hit
|
|
@@ -61,28 +61,25 @@ export function parseCitations(stdout) {
|
|
|
61
61
|
const head = text.slice(headStart, markerAt >= 0 ? markerAt : nextAt);
|
|
62
62
|
const pathM = /^path\s*:\s*(.+)$/m.exec(head);
|
|
63
63
|
const titleM = /^title\s*:\s*(.+)$/m.exec(head);
|
|
64
|
-
const charsM = /^chars
|
|
64
|
+
const charsM = /^chars:[ \t]*(\d+)(?:[ \t]*\|[ \t]*chunks:[ \t]*\d+(?:[ \t]+\(truncated\))?)?[ \t]*\r?$/m.exec(head);
|
|
65
|
+
const bodyLength = charsM ? Number(charsM[1]) : null;
|
|
66
|
+
const canonicalMarker = marker && /^----- full document -----\r?\n$/.test(marker[0]);
|
|
65
67
|
// A pathless match does not fill (or burn) its rank: real reader output never omits path, and a
|
|
66
68
|
// look-alike fragment consuming the slot would reject the real citation that fills it later.
|
|
67
69
|
if (!pathM) continue;
|
|
68
70
|
let returnedText = null;
|
|
69
71
|
let stop = false;
|
|
70
|
-
if (
|
|
72
|
+
if (Number.isSafeInteger(bodyLength) && bodyLength >= 0 && markerAt >= 0 && canonicalMarker) {
|
|
71
73
|
const bodyStart = markerAt + marker[0].length;
|
|
72
74
|
const bodyEnd = bodyStart + Number(charsM[1]);
|
|
73
75
|
const terminator = text.startsWith(`\n${SEPARATOR}`, bodyEnd) ? 1 + SEPARATOR.length
|
|
74
76
|
: text.startsWith(`\r\n${SEPARATOR}`, bodyEnd) ? 2 + SEPARATOR.length : 0;
|
|
75
77
|
if (terminator) {
|
|
76
78
|
returnedText = text.slice(bodyStart, bodyEnd);
|
|
77
|
-
structured = true;
|
|
78
79
|
headerRe.lastIndex = bodyEnd + terminator; // resume AFTER the body: its contents are never parsed
|
|
79
80
|
} else stop = true; // the declared body does not end where it says: its boundary, and all after it, is unknown
|
|
80
|
-
} else if (markerAt >= 0 && structured) {
|
|
81
|
-
stop = true; // a body with no declared length after length-bound hits: its boundary is unknown
|
|
82
81
|
} else if (markerAt >= 0) {
|
|
83
|
-
|
|
84
|
-
const body = /^----- full document -----\r?\n([\s\S]*?)\r?\n={67}(?:\r?\n|$)/m.exec(text.slice(headStart, nextAt));
|
|
85
|
-
returnedText = body ? body[1] : null;
|
|
82
|
+
stop = true; // missing/invalid length or noncanonical marker: later body boundaries are untrusted
|
|
86
83
|
}
|
|
87
84
|
expectedRank = rank + 1;
|
|
88
85
|
const repo = m[2];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.5.
|
|
3
|
+
"version": "4.5.11",
|
|
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": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"metaharness:receipts": "node scripts/metaharness-receipts.mjs",
|
|
42
42
|
"route:cheap": "node scripts/route-cheap.mjs",
|
|
43
43
|
"routing:eval": "node scripts/model-routing-eval.mjs",
|
|
44
|
+
"routing:hooks:plan": "node scripts/user-model-hook-install.mjs --plan",
|
|
44
45
|
"hooks:security-guidance:compat": "node scripts/security-guidance-codex-compat.mjs",
|
|
45
46
|
"route:receipt": "node scripts/dispatch-receipt.mjs",
|
|
46
47
|
"trismart": "node scripts/trismart.mjs",
|
|
@@ -102,6 +103,7 @@
|
|
|
102
103
|
"kb/verify-citation.mjs",
|
|
103
104
|
"kb/retrieval-result.mjs",
|
|
104
105
|
"kb/forge-update.mjs",
|
|
106
|
+
"kb/download-retry.mjs",
|
|
105
107
|
"kb/corpus-release-identity.mjs",
|
|
106
108
|
"kb/capability-only.mjs",
|
|
107
109
|
"kb/capability-summaries/",
|
|
@@ -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.5.
|
|
4
|
+
"version": "4.5.11",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|