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.
- package/README.md +3 -3
- package/bin/install.mjs +109 -42
- package/console/app.js +32 -11
- package/console/style.css +5 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/advocacy-outcomes.mjs +808 -0
- package/plugin/scripts/anticipate.sh +80 -14
- package/plugin/scripts/capability-registry.mjs +994 -0
- package/plugin/scripts/codex-hook-wrapper.mjs +1 -0
- package/plugin/scripts/continuation-gate.mjs +169 -1
- package/plugin/scripts/gates.mjs +146 -0
- package/plugin/scripts/goal-match.mjs +398 -0
- package/plugin/scripts/hijack-ruvnet.sh +69 -1
- package/plugin/scripts/hook-registry.mjs +616 -0
- package/plugin/scripts/hook-shim.mjs +13 -2
- package/plugin/scripts/learn-flush.mjs +37 -2
- package/plugin/scripts/learning-enable.mjs +382 -0
- package/plugin/scripts/lesson-promote.mjs +262 -0
- package/plugin/scripts/lesson-provenance.mjs +43 -0
- package/plugin/scripts/lesson-store.mjs +67 -56
- package/plugin/scripts/memory-doctor.mjs +345 -0
- package/plugin/scripts/nightly-controller.mjs +98 -0
- package/plugin/scripts/project-identity.mjs +89 -0
- package/plugin/scripts/ruflo-bin.mjs +81 -0
- package/plugin/scripts/runtime-preferences.mjs +18 -0
- package/plugin/scripts/session-snapshot-hook.mjs +4 -1
- package/plugin/scripts/session-start-core.mjs +19 -2
- package/plugin/scripts/unprompted-runtime.mjs +22 -7
- package/plugin/scripts/user-settings.mjs +672 -0
- package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
- package/scripts/advocacy-outcomes.mjs +4 -808
- package/scripts/capability-registry.mjs +4 -876
- package/scripts/console-runtime-identity.mjs +74 -0
- package/scripts/corpus-qa.mjs +44 -6
- package/scripts/distill-project.mjs +9 -1
- package/scripts/doc-currency.mjs +45 -4
- package/scripts/gates.mjs +4 -146
- package/scripts/goal-match.mjs +4 -398
- package/scripts/health-repair.mjs +88 -11
- package/scripts/hook-registry.mjs +4 -567
- package/scripts/host-install-matrix.mjs +155 -0
- package/scripts/issue-watch.mjs +108 -0
- package/scripts/learning-enable.mjs +4 -380
- package/scripts/lesson-promote.mjs +4 -262
- package/scripts/memory-doctor.mjs +4 -342
- package/scripts/model-router-catalog.mjs +34 -0
- package/scripts/nightly-controller.mjs +4 -66
- package/scripts/nightly-wrapper.sh +23 -1
- package/scripts/onboarding-console.mjs +34 -12
- package/scripts/proactivity-metrics.mjs +8 -1
- package/scripts/publication-receipt.mjs +10 -1
- package/scripts/qe/ux-suite.mjs +72 -1
- package/scripts/release-abort-stale.mjs +111 -0
- package/scripts/release-convergence-watchdog.mjs +119 -0
- package/scripts/release-transaction-provider.mjs +76 -8
- package/scripts/release-transaction.mjs +55 -17
- package/scripts/rvf-generation.mjs +17 -0
- package/scripts/self-update.mjs +63 -10
- package/scripts/staged-host-verifier.mjs +27 -54
- package/scripts/sync-version.mjs +10 -10
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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:
|
|
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
|
-
|
|
304
|
-
|
|
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)) {
|
package/scripts/self-update.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 {
|
|
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:
|
|
70
|
+
return { verdict: 'FAIL', source, error: error.message, fixtures: {} };
|
|
98
71
|
} finally {
|
|
99
72
|
fs.rmSync(prepared.temp, { recursive: true, force: true });
|
|
100
73
|
}
|
package/scripts/sync-version.mjs
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
// the
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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++;
|