ruvnet-brain 4.5.9 → 4.5.10

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.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +47 -92
  3. package/bin/nightly-refresh.mjs +20 -34
  4. package/kb/download-retry.mjs +97 -0
  5. package/kb/forge-update.mjs +12 -10
  6. package/kb/verify-citation.mjs +10 -13
  7. package/package.json +2 -1
  8. package/plugin/.claude-plugin/plugin.json +1 -1
  9. package/plugin/.codex-plugin/plugin.json +1 -1
  10. package/plugin/scripts/automatic-update.mjs +125 -0
  11. package/plugin/scripts/completion-claim-evidence.mjs +9 -4
  12. package/plugin/scripts/continuation-gate.mjs +34 -10
  13. package/plugin/scripts/continuation-objective.mjs +45 -10
  14. package/plugin/scripts/continuity-journal.mjs +11 -11
  15. package/plugin/scripts/ground-ruvnet.sh +3 -0
  16. package/plugin/scripts/grounding-code-projection.mjs +6 -1
  17. package/plugin/scripts/grounding-turn-evidence.mjs +2 -2
  18. package/plugin/scripts/grounding-turn-gate.mjs +8 -14
  19. package/plugin/scripts/grounding-turn-mark.mjs +106 -20
  20. package/plugin/scripts/hijack-ruvnet.sh +4 -4
  21. package/plugin/scripts/hook-input.mjs +1 -1
  22. package/plugin/scripts/host-update.mjs +16 -15
  23. package/plugin/scripts/lesson-presentation.mjs +2 -1
  24. package/plugin/scripts/nightly-scheduler.mjs +34 -7
  25. package/plugin/scripts/project-progression-hook.mjs +22 -10
  26. package/plugin/scripts/project-progression-producer.mjs +11 -1
  27. package/plugin/scripts/project-progression-store.mjs +3 -0
  28. package/plugin/scripts/project-transition-hook.mjs +7 -1
  29. package/plugin/scripts/ruvnet-gate1-pattern.mjs +33 -0
  30. package/plugin/scripts/session-snapshot-hook.mjs +4 -3
  31. package/plugin/scripts/session-start-core.mjs +3 -1
  32. package/plugin/scripts/turn-capture-privacy.mjs +202 -0
  33. package/plugin/scripts/turn-outcome-capture.mjs +28 -13
  34. package/plugin/scripts/user-settings.mjs +23 -1
  35. package/scripts/brain-grade-groundtruth.mjs +1 -0
  36. package/scripts/brain-score.mjs +11 -11
  37. package/scripts/issue-watch.mjs +21 -10
  38. package/scripts/model-weekly-cycle.mjs +45 -9
  39. package/scripts/record-lesson.mjs +13 -7
  40. package/scripts/release-qualification-contract.mjs +31 -1
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 — [![RuvNet Brain version 4.5.9 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.5.9-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
10
+ ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.5.10 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.5.10-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](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 https from 'node:https';
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, redirects = 0) {
274
- return new Promise((resolve, reject) => {
275
- if (redirects > 10) return reject(new Error('too many redirects'));
276
- const req = https.get(
277
- url,
278
- { headers: { 'User-Agent': 'ruvnet-brain-installer', Accept: 'application/octet-stream' } },
279
- (res) => {
280
- const { statusCode = 0, headers } = res;
281
- if ([301, 302, 303, 307, 308].includes(statusCode) && headers.location) {
282
- res.resume(); // drain so the socket frees up
283
- const next = new URL(headers.location, url).toString();
284
- return resolve(download(next, dest, redirects + 1));
285
- }
286
- if (statusCode !== 200) {
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
- // ── fetch a small JSON payload (redirect-following, dependency-free) ──────────────────────────────
321
- function fetchJson(url, redirects = 0) {
322
- return new Promise((resolve, reject) => {
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
- `Downloading the brain (${APPROX_SIZE})`,
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(` downloading the brain (${APPROX_SIZE})…`);
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),
@@ -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.
@@ -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
- || path.resolve(registration.nodePath || '') !== path.resolve(process.execPath)
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
- const npxName = process.platform === 'win32' ? 'npx.cmd' : 'npx';
62
- const adjacent = path.join(path.dirname(process.execPath), npxName);
63
- const npx = fs.existsSync(adjacent) ? adjacent : npxName;
64
- const argv = ['--yes', registration.packageTarget.spec, '--update', '--no-nightly-prompt'];
65
- let executable = npx;
66
- let launchArgs = argv;
67
- if (process.platform === 'win32') {
68
- // This content-addressed runner is copied alone: keep its dependency closure standalone.
69
- // Like rufloInvocation, execute the managed package's declared JS entry, never a .cmd shell.
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, launchArgs, {
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
+ }
@@ -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
- let res;
777
- try { res = await fetch(url, { redirect: 'follow' }); }
778
- catch (e) { die(`network failure fetching ${url}\n ${e.message} — nothing changed locally.`, 2); }
779
- if (!res.ok) die(`canonical manifest returned HTTP ${res.status} for ${url} — nothing changed.`, 2);
780
- try { return await res.json(); } catch (e) { die(`canonical manifest was not valid JSON: ${e.message}`, 2); }
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
- let res;
784
- try { res = await fetch(url, { redirect: 'follow' }); }
785
- catch (e) { die(`network failure downloading ${kind} ${url}\n ${e.message} — nothing changed locally.`, failureCode); }
786
- if (!res.ok) die(`${kind} download returned HTTP ${res.status} for ${url} — nothing changed.`, failureCode);
787
- return Buffer.from(await res.arrayBuffer());
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:
@@ -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; so does a body without `chars:` once an earlier
37
- * hit carried one. Output with no `chars:` line at all (a reader older than this field) keeps the previous
38
- * bounded-span parsing; a header with no body keeps none. `path`/`title`/scores come only from the header lines between
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
- const markerRe = /^----- full document -----\r?\n/gm;
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:\s*(\d+)\b/m.exec(head);
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 (charsM && markerAt >= 0) {
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
- // A reader older than `chars:`: the body is the bounded span up to the next header.
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.9",
3
+ "version": "4.5.10",
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": {
@@ -102,6 +102,7 @@
102
102
  "kb/verify-citation.mjs",
103
103
  "kb/retrieval-result.mjs",
104
104
  "kb/forge-update.mjs",
105
+ "kb/download-retry.mjs",
105
106
  "kb/corpus-release-identity.mjs",
106
107
  "kb/capability-only.mjs",
107
108
  "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.9",
4
+ "version": "4.5.10",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.5.9",
3
+ "version": "4.5.10",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"