ruvnet-brain 4.0.8 → 4.0.24

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 (63) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +109 -42
  3. package/console/app.js +32 -11
  4. package/console/style.css +5 -0
  5. package/package.json +1 -1
  6. package/plugin/.claude-plugin/plugin.json +2 -2
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/scripts/advocacy-outcomes.mjs +808 -0
  9. package/plugin/scripts/anticipate.sh +80 -14
  10. package/plugin/scripts/capability-registry.mjs +994 -0
  11. package/plugin/scripts/codex-hook-wrapper.mjs +1 -0
  12. package/plugin/scripts/continuation-gate.mjs +169 -1
  13. package/plugin/scripts/gates.mjs +146 -0
  14. package/plugin/scripts/goal-match.mjs +398 -0
  15. package/plugin/scripts/hijack-ruvnet.sh +69 -1
  16. package/plugin/scripts/hook-registry.mjs +616 -0
  17. package/plugin/scripts/hook-shim.mjs +13 -2
  18. package/plugin/scripts/learn-flush.mjs +37 -2
  19. package/plugin/scripts/learning-enable.mjs +382 -0
  20. package/plugin/scripts/lesson-promote.mjs +262 -0
  21. package/plugin/scripts/lesson-provenance.mjs +43 -0
  22. package/plugin/scripts/lesson-store.mjs +67 -56
  23. package/plugin/scripts/memory-doctor.mjs +345 -0
  24. package/plugin/scripts/nightly-controller.mjs +98 -0
  25. package/plugin/scripts/project-identity.mjs +89 -0
  26. package/plugin/scripts/ruflo-bin.mjs +81 -0
  27. package/plugin/scripts/runtime-preferences.mjs +18 -0
  28. package/plugin/scripts/session-snapshot-hook.mjs +4 -1
  29. package/plugin/scripts/session-start-core.mjs +19 -2
  30. package/plugin/scripts/unprompted-runtime.mjs +22 -7
  31. package/plugin/scripts/user-settings.mjs +672 -0
  32. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  33. package/scripts/advocacy-outcomes.mjs +4 -808
  34. package/scripts/capability-registry.mjs +4 -876
  35. package/scripts/console-runtime-identity.mjs +74 -0
  36. package/scripts/corpus-qa.mjs +44 -6
  37. package/scripts/distill-project.mjs +9 -1
  38. package/scripts/doc-currency.mjs +45 -4
  39. package/scripts/gates.mjs +4 -146
  40. package/scripts/goal-match.mjs +4 -398
  41. package/scripts/health-repair.mjs +88 -11
  42. package/scripts/hook-registry.mjs +4 -567
  43. package/scripts/host-install-matrix.mjs +155 -0
  44. package/scripts/issue-watch.mjs +108 -0
  45. package/scripts/learning-enable.mjs +4 -380
  46. package/scripts/lesson-promote.mjs +4 -262
  47. package/scripts/memory-doctor.mjs +4 -342
  48. package/scripts/model-router-catalog.mjs +34 -0
  49. package/scripts/nightly-controller.mjs +4 -66
  50. package/scripts/nightly-wrapper.sh +23 -1
  51. package/scripts/onboarding-console.mjs +34 -12
  52. package/scripts/proactivity-metrics.mjs +8 -1
  53. package/scripts/publication-receipt.mjs +10 -1
  54. package/scripts/qe/ux-suite.mjs +72 -1
  55. package/scripts/release-abort-stale.mjs +111 -0
  56. package/scripts/release-convergence-watchdog.mjs +119 -0
  57. package/scripts/release-transaction-provider.mjs +76 -8
  58. package/scripts/release-transaction.mjs +55 -17
  59. package/scripts/rvf-generation.mjs +17 -0
  60. package/scripts/self-update.mjs +63 -10
  61. package/scripts/staged-host-verifier.mjs +27 -54
  62. package/scripts/sync-version.mjs +10 -10
  63. package/scripts/user-settings.mjs +4 -640
@@ -25,17 +25,54 @@ const tagSha = (tag, root) => {
25
25
  return rows.find(([, ref]) => ref?.endsWith('^{}'))?.[0] || rows[0]?.[0] || '';
26
26
  };
27
27
 
28
- const assetBytes = (asset) => {
28
+ // THE RELEASE BUNDLE OUTGREW spawnSync's BUFFER, AND THAT IS WHAT BROKE EVERY PUBLISH (#77).
29
+ //
30
+ // This buffered the whole asset in memory via `spawnSync(..., encoding: 'buffer')`. The knowledge
31
+ // bundle is ~529MB, far past spawnSync's maxBuffer, so the download died with
32
+ // cannot download transaction asset ruvnet-brain.zip: spawnSync gh ENOBUFS
33
+ // and the caller reported it as `staged GitHub payload mismatch` — sending three days of
34
+ // investigation after a corruption that never existed. Every asset actually matched the sealed
35
+ // identity byte-for-byte, GitHub's own digest agreed, and the publish still failed.
36
+ //
37
+ // This was never a digest problem and it was never version drift. It is a size ceiling that the
38
+ // corpus crossed, so it would have broken EVERY release from that moment on regardless of content,
39
+ // and it is why hand-publishing became the only way to ship.
40
+ //
41
+ // Assets now stream to a temp file and are hashed incrementally, so peak memory is one 1MB chunk
42
+ // instead of the whole bundle and there is no ceiling to outgrow. Small assets (receipts) still
43
+ // come back as bytes, because callers parse them as JSON.
44
+ const assetToFile = (asset, destination) => {
29
45
  const result = spawnSync('gh', ['api', asset.url, '-H', 'Accept: application/octet-stream'], {
30
- encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'], timeout: ASSET_DOWNLOAD_TIMEOUT_MS,
46
+ stdio: ['ignore', fs.openSync(destination, 'w'), 'pipe'], timeout: ASSET_DOWNLOAD_TIMEOUT_MS,
31
47
  });
32
48
  if (result.error || result.signal || result.status !== 0) {
33
49
  throw new Error(`cannot download transaction asset ${asset.name}: ${result.error?.message || result.signal || `exit ${result.status}`}`);
34
50
  }
35
- return Buffer.from(result.stdout);
51
+ return destination;
36
52
  };
53
+ const withTempAsset = (asset, fn) => {
54
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-asset-'));
55
+ const file = path.join(dir, path.basename(asset.name) || 'asset.bin');
56
+ try { return fn(assetToFile(asset, file)); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
57
+ };
58
+ const assetBytes = (asset) => withTempAsset(asset, (file) => fs.readFileSync(file));
37
59
  const assetReceipt = (asset) => JSON.parse(assetBytes(asset).toString('utf8'));
38
60
  const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
61
+ // Streamed digest — never materialises the asset in memory, so a bundle of any size can be verified.
62
+ const sha256File = (file) => {
63
+ const hash = crypto.createHash('sha256');
64
+ const fd = fs.openSync(file, 'r');
65
+ try {
66
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
67
+ for (;;) {
68
+ const read = fs.readSync(fd, buffer, 0, buffer.length, null);
69
+ if (read <= 0) break;
70
+ hash.update(buffer.subarray(0, read));
71
+ }
72
+ } finally { fs.closeSync(fd); }
73
+ return hash.digest('hex');
74
+ };
75
+ const assetDigest = (asset) => withTempAsset(asset, sha256File);
39
76
  const OBSERVATION_POLICY = {
40
77
  maxElapsedMs: Number(process.env.RUVNET_NPM_VISIBILITY_TIMEOUT_MS || 180_000),
41
78
  maxAttempts: Number(process.env.RUVNET_NPM_VISIBILITY_ATTEMPTS || 14),
@@ -86,7 +123,7 @@ export function liveReleaseProvider({
86
123
  const digestAsset = (asset, force = false) => {
87
124
  const key = `${asset.id}:${asset.size}`;
88
125
  if (!force && assetDigestCache.has(key)) return assetDigestCache.get(key);
89
- const digest = sha256(assetBytes(asset));
126
+ const digest = assetDigest(asset); // streamed — the 529MB bundle must never be buffered
90
127
  assetDigestCache.set(key, digest);
91
128
  return digest;
92
129
  };
@@ -111,10 +148,13 @@ export function liveReleaseProvider({
111
148
  const remote = release.assets?.find((asset) => asset.name === name);
112
149
  if (!remote) throw new Error(`staged GitHub asset missing: ${name}`);
113
150
  const file = path.join(temp, name);
114
- fs.writeFileSync(file, assetBytes(remote), { flag: 'wx', mode: 0o600 });
151
+ // Streamed straight to disk. Buffering here would reintroduce the ENOBUFS ceiling the
152
+ // 529MB bundle already crossed once — the defect that broke every publish (#77).
153
+ assetToFile(remote, file);
154
+ fs.chmodSync(file, 0o600);
115
155
  assets[key] = file;
116
156
  }
117
- if (sha256(fs.readFileSync(assets.bundlePath)) !== identity.bundleSha256) {
157
+ if (sha256File(assets.bundlePath) !== identity.bundleSha256) {
118
158
  throw new Error('staged GitHub bundle digest does not match transaction identity');
119
159
  }
120
160
  if (identity.bundleSignatureSha256
@@ -341,10 +381,35 @@ export function liveReleaseProvider({
341
381
  if (current !== expected) throw new Error(`refusing compensation: npm latest is ${current}, expected ${expected}`);
342
382
  command('npm', ['dist-tag', 'add', `${PACKAGE}@${prior}`, 'latest']);
343
383
  },
344
- async finalize(identity, receipt, _hostVerifier) {
384
+ async finalize(identity, receipt, hostVerifier) {
345
385
  if (!candidateReceipt || !publicationReceipt) {
346
386
  throw new Error('final convergence requires candidate and publication receipt paths');
347
387
  }
388
+ // THE HOST VERDICT IS MEASURED, NOT DECLARED — and it is measured FIRST.
389
+ //
390
+ // finalize() used to accept the verifier as `_hostVerifier` and ignore it, then build
391
+ // `hosts = { verdict: 'PASS', … }` as a literal. Every release therefore asserted host
392
+ // convergence with no host verified, while tests/helpers/release-transaction-fixture.mjs:153
393
+ // called the seam faithfully — a fault-injection suite certifying wiring the real publisher
394
+ // never ran. Fable 5 and GPT-5.6-Sol independently made this their #1 finding.
395
+ //
396
+ // It runs BEFORE publication and sealing because the order encodes the meaning: you seal a
397
+ // release you have verified, not the reverse. It is also the cheapest way to fail — an
398
+ // unusable artifact stops here instead of after a 20-minute publish.
399
+ if (!hostVerifier || typeof hostVerifier.verify !== 'function') {
400
+ return { verdict: 'FAIL', hostVerifierError: 'no host verifier was supplied to finalize' };
401
+ }
402
+ // No `assets` override: stagedHostVerifier is constructed in release.mjs:238 with the exact
403
+ // staged bytes for this candidate and defaults to them (staged-host-verifier.mjs:57), so
404
+ // passing nothing is what keeps production verifying the artifact actually being shipped.
405
+ const verified = await hostVerifier.verify({ source: 'final', identity });
406
+ if (verified.verdict !== 'PASS') {
407
+ return {
408
+ verdict: 'FAIL',
409
+ hosts: { verdict: verified.verdict, verifier: { error: verified.error, fixtures: verified.fixtures } },
410
+ previousReceiptDigest: receipt.receiptDigest,
411
+ };
412
+ }
348
413
  if (!fs.existsSync(path.resolve(root, publicationReceipt))) {
349
414
  const publication = spawnSync(process.execPath, [
350
415
  'scripts/publication-receipt.mjs', '--candidate', candidateReceipt, '--out', publicationReceipt,
@@ -360,11 +425,14 @@ export function liveReleaseProvider({
360
425
  return { verdict: 'FAIL', sealError: String(seal.stderr || seal.error?.message) };
361
426
  }
362
427
  const publication = JSON.parse(fs.readFileSync(path.resolve(root, publicationReceipt), 'utf8'));
428
+
429
+ // `verified` was measured at the top of finalize, before publication and sealing.
363
430
  const hosts = {
364
- verdict: 'PASS',
431
+ verdict: verified.verdict,
365
432
  claudeOnly: publication.installed?.claudeOnly,
366
433
  codexOnly: publication.installed?.codexOnly,
367
434
  dual: publication.installed?.dual,
435
+ verifier: { artifactSha256: verified.artifactSha256, fixtures: verified.fixtures, error: verified.error },
368
436
  };
369
437
  const observed = publication.postPublicationChecks?.find(({ name }) => name === 'published-surface-probe');
370
438
  const result = {
@@ -4,22 +4,44 @@ import crypto from 'node:crypto';
4
4
  export const RECEIPT_PREFIX = 'release-transaction-';
5
5
  export const TERMINAL_STATES = new Set(['channels-converged', 'aborted']);
6
6
 
7
+ // `aborted` WAS UNREACHABLE, AND THAT BRICKED THE RELEASE RAIL (found 2026-08-07, issue #77).
8
+ //
9
+ // `aborted` has always been in TERMINAL_STATES, but no state below listed it as a target — so it
10
+ // was a terminal state nothing could ever enter. Combined with `manual-intervention-required`
11
+ // (which has NO outgoing transitions and is NOT terminal), an interrupted release had exactly two
12
+ // destinations and both were permanent non-terminal dead ends.
13
+ //
14
+ // That is not theoretical. The v4.0.7 transaction (b2ac9b69…) stopped at `npm-stage-intent` and
15
+ // stayed there. `runReleaseTransaction` treats every non-terminal receipt from another transaction
16
+ // as competing, so it refused EVERY later release with
17
+ // `pending release b2ac9b69… blocks <new>`
18
+ // and there was no legal move that could clear it. npm was then hand-moved to 4.0.12, which also
19
+ // disqualified the provider's `settled` escape hatch (it requires receipt.version === npmLatest).
20
+ // So the rail deadlocked itself, hand-publishing became the only way to ship, and hand-publishing
21
+ // is precisely how npm and GitHub came to name different generations — the whole of #77.
22
+ //
23
+ // The fix is to give abandonment a legal move, which is what `aborted` was declared for. Any
24
+ // non-terminal state may now abort. This LOOSENS nothing about a live release: `aborted` is
25
+ // terminal, so a transaction that aborts can never resume and claim to have shipped, and the
26
+ // competing-transaction guard still refuses two genuinely in-flight releases.
27
+ const ABORTABLE = ['aborted'];
28
+
7
29
  export const ALLOWED_TRANSITIONS = Object.freeze({
8
- 'remote-prepared': ['asset-upload-intent', 'manual-intervention-required'],
9
- 'asset-upload-intent': ['npm-stage-intent', 'manual-intervention-required'],
10
- 'npm-stage-intent': ['npm-candidate-staged', 'manual-intervention-required'],
11
- 'npm-candidate-staged': ['remote-materialization-intent', 'manual-intervention-required'],
12
- 'remote-materialization-intent': ['prepared', 'manual-intervention-required'],
13
- prepared: ['github-promote-intent', 'manual-intervention-required'],
14
- 'github-promote-intent': ['github-promoted-nonlatest', 'manual-intervention-required'],
15
- 'github-promoted-nonlatest': ['npm-promote-intent', 'manual-intervention-required'],
16
- 'npm-promote-intent': ['npm-promoted', 'compensation-intent', 'manual-intervention-required'],
17
- 'npm-promoted': ['github-latest-intent', 'compensation-intent', 'manual-intervention-required'],
18
- 'github-latest-intent': ['defaults-promoted', 'compensation-intent', 'manual-intervention-required'],
19
- 'compensation-intent': ['compensated', 'manual-intervention-required'],
20
- compensated: ['github-promote-intent', 'npm-promote-intent', 'manual-intervention-required'],
21
- 'defaults-promoted': ['finalize-intent', 'manual-intervention-required'],
22
- 'finalize-intent': ['channels-converged', 'manual-intervention-required'],
30
+ 'remote-prepared': ['asset-upload-intent', 'manual-intervention-required', ...ABORTABLE],
31
+ 'asset-upload-intent': ['npm-stage-intent', 'manual-intervention-required', ...ABORTABLE],
32
+ 'npm-stage-intent': ['npm-candidate-staged', 'manual-intervention-required', ...ABORTABLE],
33
+ 'npm-candidate-staged': ['remote-materialization-intent', 'manual-intervention-required', ...ABORTABLE],
34
+ 'remote-materialization-intent': ['prepared', 'manual-intervention-required', ...ABORTABLE],
35
+ prepared: ['github-promote-intent', 'manual-intervention-required', ...ABORTABLE],
36
+ 'github-promote-intent': ['github-promoted-nonlatest', 'manual-intervention-required', ...ABORTABLE],
37
+ 'github-promoted-nonlatest': ['npm-promote-intent', 'manual-intervention-required', ...ABORTABLE],
38
+ 'npm-promote-intent': ['npm-promoted', 'compensation-intent', 'manual-intervention-required', ...ABORTABLE],
39
+ 'npm-promoted': ['github-latest-intent', 'compensation-intent', 'manual-intervention-required', ...ABORTABLE],
40
+ 'github-latest-intent': ['defaults-promoted', 'compensation-intent', 'manual-intervention-required', ...ABORTABLE],
41
+ 'compensation-intent': ['compensated', 'manual-intervention-required', ...ABORTABLE],
42
+ compensated: ['github-promote-intent', 'npm-promote-intent', 'manual-intervention-required', ...ABORTABLE],
43
+ 'defaults-promoted': ['finalize-intent', 'manual-intervention-required', ...ABORTABLE],
44
+ 'finalize-intent': ['channels-converged', 'manual-intervention-required', ...ABORTABLE],
23
45
  'manual-intervention-required': [],
24
46
  'channels-converged': [],
25
47
  aborted: [],
@@ -300,8 +322,24 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
300
322
  if (decision.action === 'upload-assets') {
301
323
  await transition('asset-upload-intent', { payloadId: identity.payloadId || null });
302
324
  await adapter.uploadAssets(draft, assets, identity);
303
- const observed = await adapter.observeSnapshot(identity, draft);
304
- if (!observed.github?.assetsExact) throw new Error('staged GitHub payload mismatch');
325
+ // FORCE the digests, and never report a read failure as a mismatch (2026-08-07, #77).
326
+ //
327
+ // This read `observeSnapshot(identity, draft)` then `if (!observed.github?.assetsExact)`.
328
+ // Two distinct failures collapsed into one misleading sentence:
329
+ // · the snapshot's own catch returns `{ readError }`, leaving `github` UNDEFINED — so a
330
+ // transient API error or an OOM hashing the ~529MB bundle reported "payload mismatch",
331
+ // which sends you hunting a corruption that never happened. Verified against the real
332
+ // staged draft: all four assets (zip, .sig, .sha256, .tgz) matched the sealed identity
333
+ // byte-for-byte, and GitHub's own asset digest agreed — yet this line still threw.
334
+ // · digests are memoised by `${asset.id}:${asset.size}`, so a value computed BEFORE the
335
+ // upload finished could satisfy a later check from cache. Immediately after uploading is
336
+ // exactly when that cache must not be trusted, so this observation forces a re-read.
337
+ const observed = await adapter.observeSnapshot(identity, draft, { forceAssets: true });
338
+ if (observed.readError) {
339
+ throw new Error(`could not read the staged GitHub payload (this is NOT a digest mismatch): ${observed.readError}`);
340
+ }
341
+ if (!observed.github) throw new Error('staged GitHub draft not observable after upload');
342
+ if (!observed.github.assetsExact) throw new Error('staged GitHub payload mismatch');
305
343
  await transition('npm-stage-intent', { github: observed.github });
306
344
  continue;
307
345
  }
@@ -83,14 +83,31 @@ export function verifyRvfGenerations(dir, {
83
83
  releaseTag = getVersionTag(),
84
84
  requiredStores = [],
85
85
  allowMissingFiles = false,
86
+ verifyBytes = true,
86
87
  } = {}) {
87
88
  const manifest = readRvfGenerations(dir);
88
89
  const failures = [];
89
90
  if (manifest.brainVersion !== version) failures.push(`brainVersion=${manifest.brainVersion}, expected ${version}`);
90
91
  if (manifest.releaseTag !== releaseTag) failures.push(`releaseTag=${manifest.releaseTag}, expected ${releaseTag}`);
92
+ // `verifyBytes: false` verifies ONLY the committed ledger fields above.
93
+ //
94
+ // WHY: the `.rvf` binaries are gitignored (.gitignore:28 `kb/*.rvf`), so a byte comparison is a
95
+ // statement about the machine running the check, not about the commit being pushed. This ledger
96
+ // records 72 stores; a working checkout routinely has a different set — the nightly rebuilds RVFs
97
+ // and regenerates the ledger together (kb/forge-refresh.mjs:242), so between those two moments any
98
+ // developer tree disagrees. Wired into the pre-push gate this was unsatisfiable: it blocked EVERY
99
+ // push, including tags, and its own remedy line ("Run: node scripts/sync-version.mjs") could not
100
+ // clear it because the byte check lives under `if (CHECK)` and write mode never regenerates. That
101
+ // is what forced the 4.0.7 emergency promotion.
102
+ //
103
+ // release.mjs:100 already states the governing principle for this repo: "A verdict is only about
104
+ // the exact committed candidate." Bytes are verified where they are actually present and actually
105
+ // shipped — scripts/build-bundle.mjs:204-214, at bundle assembly — which is exactly what the
106
+ // deferral comment in sync-version.mjs promised but never had a caller for.
91
107
  for (const store of requiredStores) {
92
108
  if (!manifest.stores[store]) failures.push(`${store}: no generation record`);
93
109
  }
110
+ if (!verifyBytes) return { manifest, failures };
94
111
  for (const [store, generation] of Object.entries(manifest.stores)) {
95
112
  const file = path.join(dir, generation.file || `${store}.big.rvf`);
96
113
  if (!fs.existsSync(file)) {
@@ -16,7 +16,7 @@
16
16
  import fs from 'node:fs';
17
17
  import os from 'node:os';
18
18
  import path from 'node:path';
19
- import { execFileSync } from 'node:child_process';
19
+ import { execFileSync, spawnSync } from 'node:child_process';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { FULL_HINTS, KEEP_DIRS } from './full-hints.mjs';
22
22
  import { withSubmoduleSymlinksDetached } from './git-clone-refresh.mjs';
@@ -228,6 +228,42 @@ if (!APPLY) {
228
228
  }
229
229
 
230
230
  const NOTIFY = (t, m, p) => { try { execFileSync('sh', [path.join(ROOT, 'scripts/notify.sh'), t, m, p || 'default']); } catch { /* alerting never breaks the pipeline */ } };
231
+
232
+ // ---- child steps that can explain themselves -------------------------------------------------
233
+ // With stdio:'inherit' a failing child's Error carries NO output: e.stdout and e.stderr are both
234
+ // null and e.message is only "Command failed: <argv>" (verified 2026-08-06). That empty reason is
235
+ // what propagated into the [FATAL] summary, which is in turn what nightly-wrapper.sh samples for
236
+ // the escalation — so six identical nightly failures escalated saying nothing, while the log had
237
+ // the answer the whole time.
238
+ //
239
+ // Capture, then RE-EMIT verbatim so the log is byte-identical to what stdio:'inherit' produced,
240
+ // and keep a tail for the failure record.
241
+ // captureStdout=false (clone/fetch/reset/symbols): stdout stays inherited so those steps stream
242
+ // live and survive a kill; only stderr is buffered (small: stacks and warnings).
243
+ // captureStdout=true (every step that carries a corpus-QA verdict — [refresh] and [qa]): BOTH
244
+ // streams, because corpus-qa prints its verdict table and every '↳ <reason>' line via
245
+ // console.log — i.e. on STDOUT. Capturing stderr alone would have captured nothing for the
246
+ // exact failure this exists to explain. Measured output is ~34 lines/step, so it costs nothing.
247
+ const STEP_TAIL_LINES = 14;
248
+ function runStep(label, file, args, opts = {}, { captureStdout = false } = {}) {
249
+ const r = spawnSync(file, args, {
250
+ ...opts,
251
+ stdio: ['inherit', captureStdout ? 'pipe' : 'inherit', 'pipe'],
252
+ maxBuffer: 64 * 1024 * 1024,
253
+ });
254
+ if (r.stdout?.length) process.stdout.write(r.stdout);
255
+ if (r.stderr?.length) process.stderr.write(r.stderr);
256
+ if (r.error) { r.error.step = label; throw r.error; }
257
+ if (r.status !== 0) {
258
+ const tail = Buffer.concat([r.stdout || Buffer.alloc(0), r.stderr || Buffer.alloc(0)]).toString()
259
+ .split('\n').map((l) => l.trim()).filter(Boolean).slice(-STEP_TAIL_LINES).join(' | ');
260
+ const err = new Error(`${label} exited ${r.status}${tail ? ` — ${tail}` : ' — (child produced no output)'}`);
261
+ err.step = label;
262
+ err.status = r.status;
263
+ err.output = tail;
264
+ throw err;
265
+ }
266
+ }
231
267
  const failures = []; // per-repo build failures collected here; ANY failure aborts before publish (see below)
232
268
  for (const p of todo) {
233
269
  const dir = clonePath(p.name);
@@ -235,15 +271,15 @@ for (const p of todo) {
235
271
  if (!fs.existsSync(path.join(dir, '.git'))) {
236
272
  console.log(`[clone] ${p.name}`);
237
273
  fs.mkdirSync(CLONE_DIR, { recursive: true });
238
- execFileSync('git', ['clone', '--depth', '1', `https://github.com/${p.owner || p.org || 'ruvnet'}/${p.repo || p.name}`, dir], { stdio: 'inherit' });
274
+ runStep(`[clone] ${p.name}`, 'git', ['clone', '--depth', '1', `https://github.com/${p.owner || p.org || 'ruvnet'}/${p.repo || p.name}`, dir]);
239
275
  } else {
240
276
  // Some cached clones deduplicate large submodules with symlinks to another clone. Git refuses
241
277
  // even a fetch when a gitlink path is a symlink ("expected submodule path ... not to be a
242
278
  // symbolic link"). Detach only those declared submodule symlinks for fetch/reset, then restore
243
279
  // them in finally so a network/reset failure cannot strand the cache in a half-repaired state.
244
280
  withSubmoduleSymlinksDetached(dir, () => {
245
- execFileSync('git', ['-C', dir, 'fetch', '--depth', '1', 'origin'], { stdio: 'inherit' });
246
- execFileSync('git', ['-C', dir, 'reset', '--hard', 'origin/HEAD'], { stdio: 'inherit' });
281
+ runStep(`[fetch] ${p.name}`, 'git', ['-C', dir, 'fetch', '--depth', '1', 'origin']);
282
+ runStep(`[reset] ${p.name}`, 'git', ['-C', dir, 'reset', '--hard', 'origin/HEAD']);
247
283
  });
248
284
  }
249
285
  const env = { ...process.env, KB_MODEL_CACHE: MODEL_CACHE };
@@ -255,26 +291,43 @@ for (const p of todo) {
255
291
  if (full) buildArgs.push('--full', full);
256
292
  if (keep) buildArgs.push('--keep', keep);
257
293
  console.log(`[refresh] ${kb}`);
258
- execFileSync(NODE, buildArgs, { cwd: path.join(ROOT, 'kb'), env, stdio: 'inherit' });
294
+ // captureStdout: forge-refresh runs corpus-qa against its CANDIDATE dir and refuses to promote
295
+ // on a FAIL — so this step, not the [qa] step below, is where the six 2026-08-03..08-06 nightly
296
+ // failures actually died, and the verdict table naming the offending chunk is on ITS stdout.
297
+ // Steps produce ~34 lines at most (largest measured [refresh] section in logs/nightly.log), so
298
+ // buffering costs nothing; the tradeoff is that the log flushes at step end rather than live.
299
+ runStep(`[refresh] ${kb}`, NODE, buildArgs, { cwd: path.join(ROOT, 'kb'), env }, { captureStdout: true });
259
300
  console.log(`[symbols] ${kb}`);
260
- execFileSync(NODE, ['scripts/build-symbols.mjs', '--name', kb], { cwd: ROOT, env, stdio: 'inherit' });
301
+ runStep(`[symbols] ${kb}`, NODE, ['scripts/build-symbols.mjs', '--name', kb], { cwd: ROOT, env });
261
302
  // Corpus QA gate (scripts/corpus-qa.mjs): structural (passages>0, full-bodies>0 where
262
303
  // FULL_HINTS demands them, vectors==passages, embed.json present) + deterministic
263
304
  // self-retrieval round trip on every canonical store. Non-zero exit throws -> failures[] ->
264
305
  // the run aborts before stamp/bundle/publish. This is the machine version of the
265
306
  // 2026-07-10 hand-verification: a store that embeds wrong or reads wrong cannot ship.
266
307
  console.log(`[qa] ${kb}`);
267
- execFileSync(NODE, ['scripts/corpus-qa.mjs', '--store', kb], { cwd: ROOT, env, stdio: 'inherit' });
268
- } catch (e) { console.error(`[FAIL] ${p.name}: ${e.message}`); failures.push({ name: p.name, error: e.message }); }
308
+ // captureStdout: corpus-qa's verdict table and its '↳ <reason>' lines go to stdout.
309
+ runStep(`[qa] ${kb}`, NODE, ['scripts/corpus-qa.mjs', '--store', kb], { cwd: ROOT, env }, { captureStdout: true });
310
+ } catch (e) {
311
+ console.error(`[FAIL] ${p.name}: ${e.message}`);
312
+ failures.push({ name: p.name, step: e.step || '(unknown step)', error: e.message, reason: e.output || '' });
313
+ }
269
314
  }
270
315
 
271
316
  // A per-repo build failure used to be logged and swallowed before the run re-stamped and re-bundled
272
317
  // partial data. Fail loud: if ANY repo failed, abort before stamp/bundle. This rebuild process has no
273
318
  // publication authority; a later protected release may consume only a fully prepared candidate.
274
319
  if (failures.length) {
275
- NOTIFY('🔴 Nightly brain rebuild ABORTED', `${failures.length} repo build(s) failed — no candidate prepared. See logs/nightly.log`, 'urgent');
320
+ // Carry the REASON, not just the count. The wrapper samples this block's tail for the push
321
+ // escalation, so whatever is not printed here is not in the alert either.
322
+ const first = failures[0];
323
+ NOTIFY('🔴 Nightly brain rebuild ABORTED',
324
+ `${failures.length} repo build(s) failed — no candidate prepared. First: ${first.name} ${first.step} — ${first.reason || first.error}`,
325
+ 'urgent');
276
326
  console.error(`\n[FATAL] ${failures.length} repo build(s) failed this run — aborting before stamp/bundle. No candidate prepared:`);
277
- for (const f of failures) console.error(` - ${f.name}: ${f.error}`);
327
+ for (const f of failures) {
328
+ console.error(` - ${f.name} ${f.step}: ${f.error}`);
329
+ if (f.reason) console.error(` reason: ${f.reason}`);
330
+ }
278
331
  console.error('Fix the failing repo(s) and re-run.');
279
332
  process.exit(1);
280
333
  }
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
+ import { runHostMatrix } from './host-install-matrix.mjs';
6
7
 
7
8
  const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
8
9
  const locate = (name) => {
@@ -18,11 +19,9 @@ const run = (name, args, options) => {
18
19
  return result;
19
20
  };
20
21
 
21
- export function classifyDoctorResult(result) {
22
- const output = `${result.stdout || ''}\n${result.stderr || ''}`;
23
- if (!result.error && result.status === 0) return { accepted: true, status: 'PASS', output };
24
- return { accepted: false, status: 'FAIL', output };
25
- }
22
+ // The doctor verdict has ONE rule, in host-install-matrix.mjs. This name is kept because
23
+ // tests/unit/staged-host-verifier.test.mjs pins it, but it is now an alias, not a second copy.
24
+ export { classifyDoctor as classifyDoctorResult } from './host-install-matrix.mjs';
26
25
 
27
26
  const preparePackage = ({ packagePath, bundlePath }) => {
28
27
  const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-staged-host-'));
@@ -39,62 +38,36 @@ const preparePackage = ({ packagePath, bundlePath }) => {
39
38
  return { temp, packageRoot };
40
39
  };
41
40
 
42
- const fixturePath = (mode, temp) => {
43
- const bin = path.join(temp, `bin-${mode}`);
44
- fs.mkdirSync(bin);
45
- const hosts = mode === 'claude' ? ['claude'] : mode === 'codex' ? ['codex'] : ['claude', 'codex'];
46
- const desired = ['node', 'npm', ...hosts];
47
- for (const name of desired) {
48
- const target = locate(name);
49
- if (!target) throw new Error(`${name} CLI unavailable for ${mode} host fixture`);
50
- fs.symlinkSync(target, path.join(bin, name));
51
- }
52
- return `${bin}:/usr/bin:/bin`;
53
- };
54
-
55
41
  export function stagedHostVerifier({ assets, identity }) {
56
42
  return {
57
43
  async verify({ source, assets: observedAssets = assets }) {
58
44
  const prepared = preparePackage(observedAssets);
59
- const results = {};
60
45
  try {
61
- for (const mode of ['claude', 'codex', 'dual']) {
62
- const home = path.join(prepared.temp, `home-${mode}`);
63
- const brainHome = path.join(home, '.cache', 'ruvnet-brain');
64
- fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
65
- if (mode !== 'claude') fs.mkdirSync(path.join(home, '.codex'), { recursive: true });
66
- const env = {
67
- ...process.env,
68
- HOME: home,
69
- CODEX_HOME: path.join(home, '.codex'),
70
- RUVNET_BRAIN_HOME: brainHome,
71
- RUVNET_BRAIN_KB: path.join(brainHome, 'kb'),
72
- RUVNET_CLAUDE_MARKETPLACE_SOURCE: prepared.packageRoot,
73
- RUVNET_CODEX_HOOK_TRUST_MODE: 'bypass',
74
- CI: 'true',
75
- PATH: fixturePath(mode, prepared.temp),
76
- };
77
- const installer = path.join(prepared.packageRoot, 'bin', 'install.mjs');
78
- run(process.execPath, [installer, '--local', '--yes', '--force', '--no-nightly-prompt',
79
- '--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline', '--no-selfcheck'], {
80
- cwd: prepared.packageRoot, env, timeout: 1_200_000,
81
- });
82
- const doctor = spawnSync(process.execPath, [installer, '--doctor', '--hooks'], {
83
- cwd: prepared.packageRoot, env, timeout: 180_000,
84
- });
85
- const classified = classifyDoctorResult(doctor);
86
- if (!classified.accepted) {
87
- throw new Error(`doctor failed for ${mode}: ${classified.output.slice(-5000) || doctor.error?.message}`);
88
- }
89
- results[mode] = {
90
- status: classified.status,
91
- doctorExit: doctor.status,
92
- version: identity.version,
93
- };
46
+ // The loop, the mode names, the env and the doctor verdict all live in
47
+ // scripts/host-install-matrix.mjs, shared with the published-side check in
48
+ // publication-receipt.mjs. This file used to carry its own copy, which had drifted to
49
+ // different mode names (claude/codex/dual vs claudeOnly/codexOnly/dual) and a different
50
+ // install shape than the one that runs after publication — so the two halves of a release
51
+ // were judging different things and could not be compared. Only the STAGED-vs-PUBLISHED
52
+ // difference is real, and it is now a named variant rather than a second implementation.
53
+ const matrix = runHostMatrix({
54
+ packageRoot: prepared.packageRoot,
55
+ version: identity.version,
56
+ variant: 'staged',
57
+ locate,
58
+ temp: prepared.temp,
59
+ });
60
+ if (matrix.verdict !== 'PASS') {
61
+ return { verdict: 'FAIL', source, error: matrix.error, fixtures: matrix.fixtures };
94
62
  }
95
- return { verdict: 'PASS', source, artifactSha256: sha256(observedAssets.packagePath), fixtures: results };
63
+ return {
64
+ verdict: 'PASS',
65
+ source,
66
+ artifactSha256: sha256(observedAssets.packagePath),
67
+ fixtures: matrix.fixtures,
68
+ };
96
69
  } catch (error) {
97
- return { verdict: 'FAIL', error: error.message, fixtures: results };
70
+ return { verdict: 'FAIL', source, error: error.message, fixtures: {} };
98
71
  } finally {
99
72
  fs.rmSync(prepared.temp, { recursive: true, force: true });
100
73
  }
@@ -113,16 +113,16 @@ for (const rel of ['kb/RVF-GENERATIONS.json']) {
113
113
 
114
114
  if (CHECK && fs.existsSync(path.join(ROOT, 'kb/RVF-GENERATIONS.json'))) {
115
115
  const kbDir = path.join(ROOT, 'kb');
116
- const requiredStores = fs.readdirSync(kbDir)
117
- .filter((file) => file.endsWith('.big.rvf'))
118
- .map((file) => file.slice(0, -'.big.rvf'.length));
119
- // CI/source-only clones intentionally omit the gitignored RVF binaries. In that shape, validate
120
- // the committed ledger's version fields but defer byte checks to bundle assembly, where the RVFs
121
- // are present. A checkout containing any canonical RVF remains fail-closed for every ledger row.
122
- const { failures } = verifyRvfGenerations(kbDir, {
123
- requiredStores,
124
- allowMissingFiles: requiredStores.length === 0,
125
- });
116
+ // Validate the committed ledger's version fields ONLY. The previous form scanned the working
117
+ // directory for `*.big.rvf` and byte-compared every ledger row against whatever this machine
118
+ // happened to have — but those binaries are gitignored, so the verdict described the machine, not
119
+ // the commit. It was unsatisfiable in practice (ledger: 72 stores; a real checkout: 71, because
120
+ // the nightly rebuilds RVFs and regenerates the ledger as one step) and it blocked every push,
121
+ // tags included, while pointing at a remedy that cannot clear it. Byte verification now happens
122
+ // where the bytes are genuinely present and genuinely shipped: scripts/build-bundle.mjs:204-214.
123
+ // This is the same rule release.mjs:100 already applies — a verdict is only about the committed
124
+ // candidate.
125
+ const { failures } = verifyRvfGenerations(kbDir, { verifyBytes: false });
126
126
  for (const failure of failures) {
127
127
  console.error(`[version] RVF GENERATION DRIFT: ${failure}`);
128
128
  drift++;