ruvnet-brain 4.3.34 → 4.3.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/coverage-integrity.mjs +58 -2
- package/scripts/approved-runtime.mjs +247 -26
- package/scripts/build-bundle.mjs +27 -1
- package/scripts/code-release-corpus.mjs +276 -0
- package/scripts/corpus-candidate.mjs +19 -3
- package/scripts/corpus-coverage-sidecar.mjs +202 -0
- package/scripts/corpus-currency.mjs +71 -0
- package/scripts/corpus-dispatch-decision.mjs +138 -0
- package/scripts/corpus-next-seed.mjs +250 -72
- package/scripts/corpus-reconcile.mjs +448 -75
- package/scripts/corpus-store-failure.mjs +82 -0
- package/scripts/corpus-watchdog.mjs +334 -0
- package/scripts/fixture-denominator.mjs +72 -0
- package/scripts/knowledge-input-digest.mjs +163 -0
- package/scripts/oracle/repo-recall.mjs +142 -21
- package/scripts/oracle/retrieval-accuracy.mjs +74 -6
- package/scripts/public-verification-inputs.mjs +18 -2
- package/scripts/rehearse-corpus-pipeline.mjs +651 -31
- package/scripts/rehearse-seed-selection.mjs +234 -0
- package/scripts/release.mjs +163 -18
- package/scripts/retrieval-canary.mjs +42 -20
- package/scripts/single-source-check.mjs +15 -2
- package/scripts/source-coverage.mjs +19 -3
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/corpus-dispatch-decision.mjs — the ONE decision the nightly corpus dispatcher makes.
|
|
3
|
+
//
|
|
4
|
+
// WHY A MODULE (2026-09-29 nightly redesign). The decision used to live as bash in
|
|
5
|
+
// corpus-nightly-dispatch.yml and compared the approved runtime against main HEAD: whenever main
|
|
6
|
+
// carried a commit newer than the newest install-verified release -- which under "every main commit
|
|
7
|
+
// is a release" is exactly the window between a code release landing and its verification finishing,
|
|
8
|
+
// and forever if that verification failed -- the nightly stood down, silently. The corpus is built at
|
|
9
|
+
// the APPROVED runtime's own source, so main HEAD is irrelevant to it and is no longer consulted.
|
|
10
|
+
//
|
|
11
|
+
// decide() is pure and returns one of three verdicts:
|
|
12
|
+
// arm dispatch protected-release mode=corpus at the approved release's sourceSha
|
|
13
|
+
// stand-down a correct, quiet non-event (kill switch off, newest release not yet verified, or an
|
|
14
|
+
// approved runtime older than the first release whose pipeline understands this workflow)
|
|
15
|
+
// fail evidence is present but does not hold -- loud, never a silent skip
|
|
16
|
+
//
|
|
17
|
+
// minPipelineVersion: the dispatched workflow text is always main's, but the scripts it runs come from
|
|
18
|
+
// the approved release's checkout. A runtime older than MIN_PIPELINE_VERSION does not know the flags
|
|
19
|
+
// the new workflow passes (--approved-tag, --no-change-out), so the nightly stands down rather than
|
|
20
|
+
// let new workflow text drive old scripts.
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { pathToFileURL } from 'node:url';
|
|
25
|
+
|
|
26
|
+
export const MIN_PIPELINE_VERSION = '4.3.36'; // sync-version-ignore: a fixed pipeline floor, not the current version
|
|
27
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
28
|
+
const HEX40 = /^[0-9a-f]{40}$/;
|
|
29
|
+
const CODE_TAG = /^v\d+\.\d+\.\d+$/;
|
|
30
|
+
// scripts/approved-runtime.mjs names the release it refused: "code release vX.Y.Z (the newest) carries ...".
|
|
31
|
+
const REFUSED_NEWEST = /code release (v\d+\.\d+\.\d+) \(the newest\)/;
|
|
32
|
+
|
|
33
|
+
/** -1 / 0 / 1; throws on anything that is not plain x.y.z. */
|
|
34
|
+
export function compareVersions(left, right) {
|
|
35
|
+
const a = SEMVER.exec(String(left || ''));
|
|
36
|
+
const b = SEMVER.exec(String(right || ''));
|
|
37
|
+
if (!a || !b) throw new Error(`cannot compare versions ${JSON.stringify(left)} and ${JSON.stringify(right)}`);
|
|
38
|
+
for (let i = 1; i <= 3; i += 1) {
|
|
39
|
+
const delta = Number(a[i]) - Number(b[i]);
|
|
40
|
+
if (delta) return Math.sign(delta);
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The owner's kill switch: CORPUS_NIGHTLY reading `off` (any case, surrounding space ignored). */
|
|
46
|
+
export function killSwitchOff(nightlyVar) {
|
|
47
|
+
return String(nightlyVar ?? '').trim().toLowerCase() === 'off';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param nightlyVar repository variable CORPUS_NIGHTLY (armed unless it reads `off`, any case; the owner's
|
|
52
|
+
* approval of the code release is the consent)
|
|
53
|
+
* @param resolution { status: 'resolved', release: {tag, version, sourceSha} }
|
|
54
|
+
* | { status: 'not-yet-verified' } | { status: 'invalid', reason }
|
|
55
|
+
*/
|
|
56
|
+
export function decide({ nightlyVar, resolution, minPipelineVersion = MIN_PIPELINE_VERSION } = {}) {
|
|
57
|
+
if (killSwitchOff(nightlyVar)) {
|
|
58
|
+
return { verdict: 'stand-down', reason: 'repository variable CORPUS_NIGHTLY is off (the owner kill switch)' };
|
|
59
|
+
}
|
|
60
|
+
if (resolution?.status === 'not-yet-verified') {
|
|
61
|
+
return { verdict: 'stand-down', reason: 'the newest code release has not reached install-verified yet (no aggregate)' };
|
|
62
|
+
}
|
|
63
|
+
if (resolution?.status === 'invalid' && CODE_TAG.test(String(resolution.tag || ''))
|
|
64
|
+
&& compareVersions(resolution.tag.slice(1), minPipelineVersion) < 0) {
|
|
65
|
+
// VERSION SKEW, not tampering (found 2026-09-29): this checkout's verifier is a newer generation
|
|
66
|
+
// than the newest release's aggregate (e.g. D7's retired-fixture denominator), so it cannot judge
|
|
67
|
+
// it. A runtime below the floor would stand down even if it verified, so standing down loses
|
|
68
|
+
// nothing -- and an aggregate that claims a runtime AT or above the floor still fails loud below.
|
|
69
|
+
return { verdict: 'stand-down', reason: `the newest code release ${resolution.tag} predates ${minPipelineVersion}; `
|
|
70
|
+
+ `its install evidence is not judged by this pipeline (${resolution.reason || 'unverifiable here'})` };
|
|
71
|
+
}
|
|
72
|
+
if (resolution?.status !== 'resolved') {
|
|
73
|
+
return { verdict: 'fail', reason: `the newest code release carries install-verification evidence that does not hold: ${resolution?.reason || 'unknown resolution'}` };
|
|
74
|
+
}
|
|
75
|
+
const { tag, version, sourceSha } = resolution.release || {};
|
|
76
|
+
if (!SEMVER.test(String(version || '')) || tag !== `v${version}` || !HEX40.test(String(sourceSha || ''))) {
|
|
77
|
+
return { verdict: 'fail', reason: `resolved release identity is malformed (${JSON.stringify(resolution.release || null)})` };
|
|
78
|
+
}
|
|
79
|
+
if (compareVersions(version, minPipelineVersion) < 0) {
|
|
80
|
+
return { verdict: 'stand-down', reason: `approved runtime ${tag} predates ${minPipelineVersion}, the first release whose pipeline this workflow can drive` };
|
|
81
|
+
}
|
|
82
|
+
return { verdict: 'arm', reason: `building the corpus at approved runtime ${tag} @ ${sourceSha}`, tag, version, sourceSha };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** approved-runtime.mjs --resolve's exit status + stdout (+ stderr, naming a refused release) -> a resolution. */
|
|
86
|
+
export function resolutionFrom({ status, stdout, stderr = '' }) {
|
|
87
|
+
if (String(status) === '3') return { status: 'not-yet-verified' };
|
|
88
|
+
if (String(status) !== '0') {
|
|
89
|
+
const tag = REFUSED_NEWEST.exec(String(stderr))?.[1] ?? null;
|
|
90
|
+
const detail = String(stderr).split(': ').slice(-1)[0].trim().slice(0, 300);
|
|
91
|
+
return { status: 'invalid', tag, reason: `approved-runtime.mjs --resolve exited ${status}${detail ? ` (${detail})` : ''}` };
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const release = JSON.parse(String(stdout || ''));
|
|
95
|
+
return { status: 'resolved', release };
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return { status: 'invalid', reason: `approved-runtime.mjs --resolve printed no release (${error.message})` };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const arg = (argv, name) => {
|
|
102
|
+
const index = argv.indexOf(name);
|
|
103
|
+
return index >= 0 ? argv[index + 1] : undefined;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* CLI: --nightly <value> --resolve-status <n|skipped> --release <file> [--resolve-stderr <file>]
|
|
108
|
+
* --github-output <file>. Exit 1 only on `fail`.
|
|
109
|
+
* --is-off <value> prints true|false (lets the workflow skip the ~555 MB resolve when the switch is off)
|
|
110
|
+
*/
|
|
111
|
+
export function main(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
|
|
112
|
+
if (argv.includes('--is-off')) {
|
|
113
|
+
stdout.write(`${killSwitchOff(arg(argv, '--is-off'))}\n`);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
const nightlyVar = arg(argv, '--nightly') ?? '';
|
|
117
|
+
const status = arg(argv, '--resolve-status');
|
|
118
|
+
const releaseFile = arg(argv, '--release');
|
|
119
|
+
const stderrFile = arg(argv, '--resolve-stderr');
|
|
120
|
+
const githubOutput = arg(argv, '--github-output');
|
|
121
|
+
const resolution = status === 'skipped' ? null
|
|
122
|
+
: resolutionFrom({ status,
|
|
123
|
+
stdout: releaseFile && fs.existsSync(releaseFile) ? fs.readFileSync(releaseFile, 'utf8') : '',
|
|
124
|
+
stderr: stderrFile && fs.existsSync(stderrFile) ? fs.readFileSync(stderrFile, 'utf8') : '' });
|
|
125
|
+
const decision = decide({ nightlyVar, resolution });
|
|
126
|
+
const lines = decision.verdict === 'arm'
|
|
127
|
+
? ['armed=true', `approved_sha=${decision.sourceSha}`, `approved_version=${decision.version}`, `approved_tag=${decision.tag}`]
|
|
128
|
+
: ['armed=false'];
|
|
129
|
+
lines.push(`verdict=${decision.verdict}`);
|
|
130
|
+
if (githubOutput) fs.appendFileSync(path.resolve(githubOutput), `${lines.join('\n')}\n`);
|
|
131
|
+
const annotation = decision.verdict === 'fail' ? '::error::' : '::notice::';
|
|
132
|
+
stdout.write(`${annotation}corpus-nightly-dispatch ${decision.verdict}: ${decision.reason}\n`);
|
|
133
|
+
return decision.verdict === 'fail' ? 1 : 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
|
137
|
+
process.exitCode = main();
|
|
138
|
+
}
|
|
@@ -11,27 +11,56 @@
|
|
|
11
11
|
// unsigned or half-uploaded release — it falls back to the committed data/corpus-seed.json, which is
|
|
12
12
|
// the bootstrap/recovery input and is content-addressed independently.
|
|
13
13
|
//
|
|
14
|
-
// "Compatible"
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// "Compatible" means THIS RUNTIME CAN CONSUME THE SEED (ADR-0091 D4). It is NOT "the generation
|
|
15
|
+
// shipped the same runtime version as the approved pin": that rule rejected every generation the
|
|
16
|
+
// moment a code release moved the approved runtime, so the seed chain reset to the weeks-stale
|
|
17
|
+
// bootstrap on every release (ADR-0091 section 3.4). The runtime's executables never come from a
|
|
18
|
+
// seed anyway -- build-bundle.mjs copies runtime modules from the checkout and only named store
|
|
19
|
+
// files from the seed, and verifyApprovedRuntime's backward check still refuses any unpinned
|
|
20
|
+
// executable in the candidate. So --pin is gone from this script entirely. What CAN make a seed
|
|
21
|
+
// unusable is judged here, from small files, BEFORE the ~500 MB archive is ever fetched:
|
|
22
|
+
// 1. the receipt (schema 3, as before) records every store's embedding model and dimensions, and
|
|
23
|
+
// they must equal what this runtime's forge build produces (kb/forge-corpus.mjs
|
|
24
|
+
// FORGE_BUILD_FINGERPRINT) -- vectors from another model are not searchable by this one;
|
|
25
|
+
// 2. the generation's detached .recall.json must be the one its receipt binds, must have been
|
|
26
|
+
// measured against THIS runtime's frozen fixture digest, and must pass THIS runtime's
|
|
27
|
+
// readRecallReport -- the same reader and arguments corpus-seed.yml's downstream seed
|
|
28
|
+
// re-check uses, so that re-check (which has no fallback) only ever sees a generation that
|
|
29
|
+
// already passed here.
|
|
30
|
+
// "This runtime" is --runtime-root: the source tree whose readers will consume the seed. On the
|
|
31
|
+
// nightly that is the approved runtime's own sourceSha (ADR-0091 D3), which usually trails main.
|
|
32
|
+
// It defaults to this checkout, which is right whenever the checkout IS the build source.
|
|
33
|
+
//
|
|
34
|
+
// A generation that fails any check is SKIPPED with its reason, never a failure: the walk moves to
|
|
35
|
+
// the next older one. At most SEARCH_BOUND (5) generations are judged, so one bad format change can
|
|
36
|
+
// never turn into an unbounded walk of release history; after that the committed bootstrap is used.
|
|
17
37
|
//
|
|
18
38
|
// "Verified" is decided by evidence that is checkable without downloading 500 MB here: the tag must
|
|
19
39
|
// be the content-addressed corpus-sha256-<digest> form, the release must not be a draft, and it must
|
|
20
|
-
// carry
|
|
21
|
-
// own archive digest equal to the digest in the tag. The full
|
|
22
|
-
// downstream where the archive is actually fetched (corpus-seed.yml
|
|
23
|
-
// before reconciliation,
|
|
40
|
+
// carry exactly one of each of ruvnet-brain.zip, its detached .sig, corpus-receipt.json and both
|
|
41
|
+
// detached reports, with the receipt's own archive digest equal to the digest in the tag. The full
|
|
42
|
+
// byte-level proof still happens downstream where the archive is actually fetched (corpus-seed.yml
|
|
43
|
+
// re-checks sha256 and byte length before reconciliation, corpus-reconcile.mjs checks the ledger
|
|
44
|
+
// schema after extraction, and corpus-candidate.mjs re-derives the whole candidate from the bytes).
|
|
45
|
+
//
|
|
46
|
+
// ADR-0091 D6: --require-coverage is the CODE-RELEASE mode (ci.yml's release-qe and warm-brain jobs,
|
|
47
|
+
// and release.mjs's publish-time re-check). A code release assembles single-pass from the
|
|
48
|
+
// generation's sealed coverage, so a generation that did not publish its coverage sidecar
|
|
49
|
+
// (CORPUS-COVERAGE.json + coverage-receipt.json, D6.2) -- every generation published before D6 --
|
|
50
|
+
// is skipped as incompatible FOR THAT PURPOSE, with its reason. It remains a valid nightly seed:
|
|
51
|
+
// without the flag nothing about the nightly's selection changes.
|
|
24
52
|
//
|
|
25
53
|
// Usage:
|
|
26
|
-
// node scripts/corpus-next-seed.mjs --repo owner/name [--
|
|
27
|
-
// [--bootstrap data/corpus-seed.json] [--out <file>]
|
|
54
|
+
// node scripts/corpus-next-seed.mjs --repo owner/name [--runtime-root <source tree>]
|
|
55
|
+
// [--bootstrap data/corpus-seed.json] [--require-coverage] [--out <file>]
|
|
28
56
|
|
|
57
|
+
import crypto from 'node:crypto';
|
|
29
58
|
import fs from 'node:fs';
|
|
30
59
|
import os from 'node:os';
|
|
31
60
|
import path from 'node:path';
|
|
32
61
|
import { spawnSync } from 'node:child_process';
|
|
33
62
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
34
|
-
import {
|
|
63
|
+
import { COVERAGE_ASSET, COVERAGE_RECEIPT_ASSET, bindCoverageToReceipt, verifyCoverageSidecar } from './corpus-coverage-sidecar.mjs';
|
|
35
64
|
|
|
36
65
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
37
66
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
@@ -39,6 +68,13 @@ const CORPUS_TAG = /^corpus-sha256-([0-9a-f]{64})$/;
|
|
|
39
68
|
const ARCHIVE_ASSET = 'ruvnet-brain.zip';
|
|
40
69
|
const SIGNATURE_ASSET = 'ruvnet-brain.zip.sig';
|
|
41
70
|
const RECEIPT_ASSET = 'corpus-receipt.json';
|
|
71
|
+
const RECALL_ASSET = 'ruvnet-brain.zip.recall.json';
|
|
72
|
+
const ACCURACY_ASSET = 'ruvnet-brain.zip.accuracy.json';
|
|
73
|
+
// Every asset corpus-seed.yml requires of a digest-derived seed, plus the two judged here. A release
|
|
74
|
+
// missing one would pass this resolver and then fail the downstream step that has no fallback.
|
|
75
|
+
const REQUIRED_ASSETS = [ARCHIVE_ASSET, SIGNATURE_ASSET, RECEIPT_ASSET, RECALL_ASSET, ACCURACY_ASSET];
|
|
76
|
+
/** ADR-0091 D4: judge at most this many of the newest generations, then use the committed bootstrap. */
|
|
77
|
+
export const SEARCH_BOUND = 5;
|
|
42
78
|
|
|
43
79
|
const defaultRun = (command, args, options) => spawnSync(command, args, { encoding: 'utf8', ...options });
|
|
44
80
|
|
|
@@ -61,103 +97,215 @@ export function validateBootstrapSeed(seed) {
|
|
|
61
97
|
return failures;
|
|
62
98
|
}
|
|
63
99
|
|
|
100
|
+
/** "forge-corpus-v1|Xenova/bge-base-en-v1.5@<rev>:768:cls" -> { model, dimensions }. */
|
|
101
|
+
export function parseBuildFingerprint(fingerprint) {
|
|
102
|
+
const match = /\|([^@|]+)@[^:|]+:(\d+):[^|]*$/.exec(String(fingerprint || ''));
|
|
103
|
+
if (!match) throw new Error(`cannot read the embedding model from FORGE_BUILD_FINGERPRINT (${fingerprint})`);
|
|
104
|
+
return { model: match[1], dimensions: Number(match[2]) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* What the consuming runtime expects of a seed, read from THAT runtime's own source tree -- never
|
|
109
|
+
* restated here. A tree that cannot answer is a code defect, so this throws (a loud red night)
|
|
110
|
+
* rather than quietly seeding from the bootstrap every night, which would look exactly like "no new
|
|
111
|
+
* generation yet".
|
|
112
|
+
*/
|
|
113
|
+
export async function loadCompatibilityProfile({ runtimeRoot = ROOT, fixtureFile = null } = {}) {
|
|
114
|
+
const root = path.resolve(runtimeRoot);
|
|
115
|
+
const load = (relative) => import(pathToFileURL(path.join(root, relative)).href);
|
|
116
|
+
const [recall, forge] = await Promise.all([load('scripts/oracle/repo-recall.mjs'), load('kb/forge-corpus.mjs')]);
|
|
117
|
+
if (typeof recall.readRecallReport !== 'function' || typeof recall.loadFixture !== 'function') {
|
|
118
|
+
throw new Error(`${root} has no repo-recall reader to judge a seed with`);
|
|
119
|
+
}
|
|
120
|
+
const { model, dimensions } = parseBuildFingerprint(forge.FORGE_BUILD_FINGERPRINT);
|
|
121
|
+
const fixture = recall.loadFixture(fixtureFile || path.join(root, recall.DEFAULT_FIXTURE_FILE));
|
|
122
|
+
return {
|
|
123
|
+
runtimeRoot: root,
|
|
124
|
+
model,
|
|
125
|
+
dimensions,
|
|
126
|
+
fixtureSha256: fixture.fixtureSha256,
|
|
127
|
+
fixtureStores: fixture.questions.map((question) => question.store),
|
|
128
|
+
floorValue: recall.ABSOLUTE_FLOOR,
|
|
129
|
+
readRecallReport: recall.readRecallReport,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function download(run, { repo, tag, pattern, dir }) {
|
|
134
|
+
const result = run('gh', ['release', 'download', tag, '--repo', repo, '--pattern', pattern, '--dir', dir],
|
|
135
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
136
|
+
const file = path.join(dir, pattern);
|
|
137
|
+
return !result.error && result.status === 0 && fs.existsSync(file) ? file : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Small files only (a receipt, a recall report): read whole.
|
|
141
|
+
const sha256Of = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
142
|
+
|
|
64
143
|
/**
|
|
65
144
|
* One published release, judged. Returns null when it cannot serve as a seed, with the reason
|
|
66
|
-
* recorded on `rejected` so a no-op night is explainable rather than silent.
|
|
145
|
+
* recorded on `rejected` so a no-op night is explainable rather than silent. Never downloads the
|
|
146
|
+
* archive: every check below reads the release's asset list, its receipt, or its recall report.
|
|
67
147
|
*/
|
|
68
|
-
function judgeRelease({ run, repo, tag, digest,
|
|
148
|
+
function judgeRelease({ run, repo, tag, digest, profile, rejected, requireCoverage = false }) {
|
|
149
|
+
// `indeterminate` marks a rejection that says nothing about the release itself -- the network or
|
|
150
|
+
// `gh` failed. A publish-time re-check (ADR-0091 D6.6) must not read one as "no newer generation".
|
|
151
|
+
const reject = (reason, { indeterminate = false } = {}) => {
|
|
152
|
+
rejected.push({ tag, reason, ...(indeterminate ? { indeterminate: true } : {}) });
|
|
153
|
+
return null;
|
|
154
|
+
};
|
|
69
155
|
let view;
|
|
70
156
|
try {
|
|
71
157
|
view = ghJson(run, ['release', 'view', tag, '--repo', repo, '--json', 'tagName,isDraft,assets']);
|
|
72
158
|
} catch (error) {
|
|
73
|
-
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
if (view?.tagName !== tag || view.isDraft) {
|
|
77
|
-
rejected.push({ tag, reason: 'release is a draft or names another tag' });
|
|
78
|
-
return null;
|
|
159
|
+
return reject(`release view failed (${error.message})`, { indeterminate: true });
|
|
79
160
|
}
|
|
161
|
+
if (view?.tagName !== tag || view.isDraft) return reject('release is a draft or names another tag');
|
|
80
162
|
const assets = Array.isArray(view.assets) ? view.assets : [];
|
|
81
163
|
const named = (name) => assets.filter((asset) => asset?.name === name);
|
|
82
|
-
for (const name of
|
|
83
|
-
if (named(name).length !== 1) {
|
|
84
|
-
rejected.push({ tag, reason: `unverified: expected exactly one ${name} asset` });
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
164
|
+
for (const name of REQUIRED_ASSETS) {
|
|
165
|
+
if (named(name).length !== 1) return reject(`unverified: expected exactly one ${name} asset`);
|
|
87
166
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return null;
|
|
167
|
+
if (requireCoverage && [COVERAGE_ASSET, COVERAGE_RECEIPT_ASSET].some((name) => named(name).length !== 1)) {
|
|
168
|
+
return reject(`incompatible for a code release: no coverage sidecar (${COVERAGE_ASSET} + ${COVERAGE_RECEIPT_ASSET}; `
|
|
169
|
+
+ 'published before ADR-0091 D6.2)');
|
|
92
170
|
}
|
|
171
|
+
const archive = named(ARCHIVE_ASSET)[0];
|
|
172
|
+
if (!Number.isSafeInteger(archive.size) || archive.size < 1) return reject('unverified: archive asset has no usable byte length');
|
|
93
173
|
|
|
94
174
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'corpus-next-seed-'));
|
|
95
175
|
try {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
if (download.error || download.status !== 0) {
|
|
99
|
-
rejected.push({ tag, reason: 'unverified: corpus receipt could not be downloaded' });
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
176
|
+
const receiptFile = download(run, { repo, tag, pattern: RECEIPT_ASSET, dir: scratch });
|
|
177
|
+
if (!receiptFile) return reject('unverified: corpus receipt could not be downloaded', { indeterminate: true });
|
|
102
178
|
let receipt;
|
|
103
|
-
try { receipt = JSON.parse(fs.readFileSync(
|
|
104
|
-
catch (error) {
|
|
179
|
+
try { receipt = JSON.parse(fs.readFileSync(receiptFile, 'utf8')); }
|
|
180
|
+
catch (error) { return reject(`unverified: corpus receipt unreadable (${error.message})`); }
|
|
105
181
|
|
|
106
182
|
// ADR-086 Step 15 / A6 moved the corpus receipt to schemaVersion 3 (it now binds the detached
|
|
107
183
|
// retrieval-accuracy report). This reader has to move with it: left at 2 it would reject every
|
|
108
184
|
// schema-3 generation as unverified and silently fall back to the committed bootstrap seed every
|
|
109
185
|
// night — a degradation that looks exactly like "no new generation yet".
|
|
110
186
|
if (receipt.schemaVersion !== 3 || receipt.kind !== 'ruvnet-brain-corpus-candidate') {
|
|
111
|
-
|
|
112
|
-
return null;
|
|
187
|
+
return reject('unverified: receipt schema or kind is not a schema-3 corpus candidate');
|
|
113
188
|
}
|
|
114
|
-
if (!receipt.accuracyReport?.file ||
|
|
189
|
+
if (!receipt.accuracyReport?.file || !HEX64.test(String(receipt.accuracyReport.sha256 || ''))
|
|
115
190
|
|| !Number.isSafeInteger(receipt.accuracyReport.bytes)) {
|
|
116
|
-
|
|
117
|
-
return null;
|
|
191
|
+
return reject('unverified: receipt carries no retrieval-accuracy binding');
|
|
118
192
|
}
|
|
119
193
|
if (receipt.archive?.sha256 !== digest || receipt.archive?.bytes !== archive.size) {
|
|
120
|
-
|
|
121
|
-
|
|
194
|
+
return reject('unverified: receipt archive identity disagrees with the content-addressed tag');
|
|
195
|
+
}
|
|
196
|
+
if (receipt.recallReport?.file !== RECALL_ASSET || !HEX64.test(String(receipt.recallReport?.sha256 || ''))
|
|
197
|
+
|| !Number.isSafeInteger(receipt.recallReport?.bytes)) {
|
|
198
|
+
return reject('unverified: receipt carries no repo-recall binding');
|
|
122
199
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
200
|
+
|
|
201
|
+
// D4 check 1 -- embedding model and dimensions, per store, from the receipt alone.
|
|
202
|
+
const stores = Array.isArray(receipt.stores) ? receipt.stores : [];
|
|
203
|
+
if (!stores.length) return reject('unverified: receipt lists no stores');
|
|
204
|
+
const foreign = stores.filter((row) => row?.model !== profile.model || row?.dimensions !== profile.dimensions);
|
|
205
|
+
if (foreign.length) {
|
|
206
|
+
const first = foreign[0];
|
|
207
|
+
return reject(`incompatible: ${foreign.length} store(s) embedded with a different model/dimensions `
|
|
208
|
+
+ `(e.g. ${first?.name}: ${first?.model}/${first?.dimensions}); this runtime builds ${profile.model}/${profile.dimensions}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// D4 check 2 -- the recall report, the one small file that decides whether the downstream seed
|
|
212
|
+
// re-check can pass. Downloaded only now, after the receipt already qualified.
|
|
213
|
+
const recallFile = download(run, { repo, tag, pattern: RECALL_ASSET, dir: scratch });
|
|
214
|
+
if (!recallFile) return reject('unverified: repo-recall report could not be downloaded', { indeterminate: true });
|
|
215
|
+
if (sha256Of(recallFile) !== receipt.recallReport.sha256 || fs.statSync(recallFile).size !== receipt.recallReport.bytes) {
|
|
216
|
+
return reject('unverified: repo-recall report is not the one the receipt binds');
|
|
126
217
|
}
|
|
218
|
+
let claimed = null;
|
|
219
|
+
try { claimed = JSON.parse(fs.readFileSync(recallFile, 'utf8')); } catch { /* the reader names it below */ }
|
|
220
|
+
const claimedFixture = claimed?.fixture?.sha256 ?? null;
|
|
221
|
+
if (claimedFixture !== profile.fixtureSha256) {
|
|
222
|
+
return reject(`incompatible: recall report was measured against fixture ${String(claimedFixture).slice(0, 12)}, `
|
|
223
|
+
+ `this runtime's frozen fixture is ${profile.fixtureSha256.slice(0, 12)}`);
|
|
224
|
+
}
|
|
225
|
+
// ADR-0091 D7.3: a report that claims retired questions is verified against the generation's own
|
|
226
|
+
// sealed coverage (its D6.2 sidecar), recomputed by the reader -- in the nightly mode too, or one
|
|
227
|
+
// retirement would push every night onto the bootstrap. No sidecar means the claim is unverifiable,
|
|
228
|
+
// and the generation is skipped.
|
|
229
|
+
const claimsRetirement = claimed?.retirement !== undefined;
|
|
230
|
+
if (claimsRetirement && !requireCoverage && [COVERAGE_ASSET, COVERAGE_RECEIPT_ASSET].some((name) => named(name).length !== 1)) {
|
|
231
|
+
return reject('incompatible: recall report claims retired question(s) but the generation published no coverage sidecar '
|
|
232
|
+
+ `(${COVERAGE_ASSET} + ${COVERAGE_RECEIPT_ASSET}) to verify them against`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// D6.2 -- the code-release mode's extra check, from two more small files: the sidecar must name
|
|
236
|
+
// THIS generation, bind THESE coverage bytes, and the coverage must describe this receipt's stores.
|
|
237
|
+
let coverage = null;
|
|
238
|
+
let coverageBytes = null;
|
|
239
|
+
if (requireCoverage || claimsRetirement) {
|
|
240
|
+
const sidecarFile = download(run, { repo, tag, pattern: COVERAGE_RECEIPT_ASSET, dir: scratch });
|
|
241
|
+
const coverageFile = sidecarFile && download(run, { repo, tag, pattern: COVERAGE_ASSET, dir: scratch });
|
|
242
|
+
if (!sidecarFile || !coverageFile) return reject('unverified: coverage sidecar could not be downloaded', { indeterminate: true });
|
|
243
|
+
try {
|
|
244
|
+
coverageBytes = fs.readFileSync(coverageFile);
|
|
245
|
+
const verified = verifyCoverageSidecar({ sidecar: JSON.parse(fs.readFileSync(sidecarFile, 'utf8')), coverageBytes,
|
|
246
|
+
generationTag: tag, archiveSha256: digest, archiveBytes: archive.size });
|
|
247
|
+
bindCoverageToReceipt({ coverage: verified.coverage, receipt });
|
|
248
|
+
coverage = { asset: COVERAGE_ASSET, sha256: sha256Of(coverageFile), bytes: coverageBytes.length,
|
|
249
|
+
receiptAsset: COVERAGE_RECEIPT_ASSET, receiptSha256: sha256Of(sidecarFile), degraded: verified.degraded };
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return reject(`unverified: coverage sidecar does not bind this generation (${error.message})`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
profile.readRecallReport({
|
|
257
|
+
reportFile: recallFile,
|
|
258
|
+
archive: { file: ARCHIVE_ASSET, sha256: digest, bytes: archive.size },
|
|
259
|
+
expectedFixtureSha256: profile.fixtureSha256,
|
|
260
|
+
// The same bar corpus-seed.yml's seed re-check uses: a published seed is graded against the
|
|
261
|
+
// fixed ABSOLUTE_FLOOR, never re-judged by a later, higher committed floor.
|
|
262
|
+
floorValue: profile.floorValue,
|
|
263
|
+
// Ignored by a pre-D7 reader, which then fails closed on a retirement-bearing report's totals.
|
|
264
|
+
coverageBytes,
|
|
265
|
+
...(profile.fixtureStores ? { fixtureStores: profile.fixtureStores } : {}),
|
|
266
|
+
});
|
|
267
|
+
} catch (error) {
|
|
268
|
+
return reject(`incompatible: recall report fails this runtime's reader (${error.message})`);
|
|
269
|
+
}
|
|
270
|
+
|
|
127
271
|
return {
|
|
128
272
|
origin: 'published-generation',
|
|
129
273
|
tag,
|
|
130
274
|
asset: ARCHIVE_ASSET,
|
|
131
275
|
sha256: digest,
|
|
132
276
|
bytes: archive.size,
|
|
277
|
+
// The nightly descriptor keeps its pre-D7 shape even when a retirement claim fetched the coverage.
|
|
278
|
+
...(coverage && requireCoverage ? { coverage } : {}),
|
|
133
279
|
sourceCommit: typeof receipt.builderSourceSha === 'string' ? receipt.builderSourceSha : null,
|
|
134
|
-
|
|
280
|
+
// Informational only since D4: the runtime that BUILT the seed, which may be older than the
|
|
281
|
+
// runtime about to consume it. Nothing gates on it.
|
|
282
|
+
brainVersion: receipt.archiveManifestVersion ?? null,
|
|
135
283
|
};
|
|
136
284
|
} finally {
|
|
137
285
|
fs.rmSync(scratch, { recursive: true, force: true });
|
|
138
286
|
}
|
|
139
287
|
}
|
|
140
288
|
|
|
141
|
-
export function resolveNextCorpusSeed({
|
|
142
|
-
repo, run = defaultRun, root = ROOT,
|
|
289
|
+
export async function resolveNextCorpusSeed({
|
|
290
|
+
repo, run = defaultRun, root = ROOT, runtimeRoot = null, fixtureFile = null, bootstrapFile,
|
|
291
|
+
limit = 100, searchBound = SEARCH_BOUND, profile = null, requireCoverage = false,
|
|
143
292
|
} = {}) {
|
|
144
293
|
if (!/^[^/\s]+\/[^/\s]+$/.test(String(repo || ''))) throw new Error('--repo must be owner/name');
|
|
145
|
-
|
|
146
|
-
const approved = readApprovedRuntime(pinFile || path.join(root, 'data/approved-runtime.json'));
|
|
147
|
-
const pinFailures = validateApprovedRuntime(approved);
|
|
148
|
-
if (pinFailures.length) throw new Error(`approved runtime pin is invalid: ${pinFailures.join('; ')}`);
|
|
294
|
+
if (!Number.isSafeInteger(searchBound) || searchBound < 1) throw new Error('search bound must be a positive integer');
|
|
149
295
|
|
|
150
296
|
const bootstrapPath = path.resolve(bootstrapFile || path.join(root, 'data/corpus-seed.json'));
|
|
151
297
|
const bootstrap = JSON.parse(fs.readFileSync(bootstrapPath, 'utf8'));
|
|
152
298
|
const bootstrapFailures = validateBootstrapSeed(bootstrap);
|
|
153
299
|
if (bootstrapFailures.length) throw new Error(`committed bootstrap seed is invalid: ${bootstrapFailures.join('; ')}`);
|
|
154
300
|
|
|
301
|
+
const compatibility = profile || await loadCompatibilityProfile({ runtimeRoot: runtimeRoot || root, fixtureFile });
|
|
302
|
+
|
|
155
303
|
const rejected = [];
|
|
156
304
|
let listed = [];
|
|
157
305
|
try {
|
|
158
306
|
listed = ghJson(run, ['release', 'list', '--repo', repo, '--limit', String(limit), '--json', 'tagName,isDraft,createdAt']) || [];
|
|
159
307
|
} catch (error) {
|
|
160
|
-
rejected.push({ tag: null, reason: `release list failed (${error.message})
|
|
308
|
+
rejected.push({ tag: null, reason: `release list failed (${error.message})`, indeterminate: true });
|
|
161
309
|
}
|
|
162
310
|
|
|
163
311
|
// Every corpus-shaped tag that is NOT usable gets an explicit reason. A silently skipped row is
|
|
@@ -174,9 +322,15 @@ export function resolveNextCorpusSeed({
|
|
|
174
322
|
}
|
|
175
323
|
candidates.sort((left, right) => right.createdAt - left.createdAt);
|
|
176
324
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
325
|
+
const judged = candidates.slice(0, searchBound);
|
|
326
|
+
for (const skipped of candidates.slice(searchBound)) {
|
|
327
|
+
rejected.push({ tag: skipped.tag, reason: `not judged: older than the ${searchBound} newest generations (ADR-0091 D4 search bound)` });
|
|
328
|
+
}
|
|
329
|
+
const expects = { model: compatibility.model, dimensions: compatibility.dimensions, fixtureSha256: compatibility.fixtureSha256 };
|
|
330
|
+
for (const candidate of judged) {
|
|
331
|
+
const resolved = judgeRelease({ run, repo, tag: candidate.tag, digest: candidate.digest, profile: compatibility, rejected,
|
|
332
|
+
requireCoverage });
|
|
333
|
+
if (resolved) return { seed: resolved, rejected, judged: judged.length, expects };
|
|
180
334
|
}
|
|
181
335
|
|
|
182
336
|
return {
|
|
@@ -190,36 +344,60 @@ export function resolveNextCorpusSeed({
|
|
|
190
344
|
brainVersion: null,
|
|
191
345
|
},
|
|
192
346
|
rejected,
|
|
193
|
-
|
|
347
|
+
judged: judged.length,
|
|
348
|
+
expects,
|
|
194
349
|
};
|
|
195
350
|
}
|
|
196
351
|
|
|
197
|
-
const arg = (name, fallback) => {
|
|
198
|
-
const index =
|
|
199
|
-
return index >= 0 &&
|
|
352
|
+
const arg = (argv, name, fallback) => {
|
|
353
|
+
const index = argv.indexOf(name);
|
|
354
|
+
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
|
200
355
|
};
|
|
201
356
|
|
|
202
|
-
function main() {
|
|
357
|
+
export async function main(argv = process.argv.slice(2), { run = defaultRun, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
358
|
+
// Loud, not ignored: a caller still passing --pin is on the pre-D4 contract and should be told.
|
|
359
|
+
if (argv.includes('--pin')) {
|
|
360
|
+
stderr.write('[corpus-next-seed] --pin was removed by ADR-0091 D4: seed selection no longer depends on the approved runtime pin\n');
|
|
361
|
+
return 2;
|
|
362
|
+
}
|
|
203
363
|
let result;
|
|
204
364
|
try {
|
|
205
|
-
result = resolveNextCorpusSeed({
|
|
206
|
-
repo: arg('--repo', process.env.GITHUB_REPOSITORY),
|
|
207
|
-
|
|
208
|
-
|
|
365
|
+
result = await resolveNextCorpusSeed({
|
|
366
|
+
repo: arg(argv, '--repo', process.env.GITHUB_REPOSITORY),
|
|
367
|
+
runtimeRoot: arg(argv, '--runtime-root', null),
|
|
368
|
+
fixtureFile: arg(argv, '--fixture', null),
|
|
369
|
+
bootstrapFile: arg(argv, '--bootstrap', null),
|
|
370
|
+
requireCoverage: argv.includes('--require-coverage'),
|
|
371
|
+
run,
|
|
209
372
|
});
|
|
210
373
|
} catch (error) {
|
|
211
|
-
|
|
374
|
+
stderr.write(`[corpus-next-seed] ${error.message}\n`);
|
|
212
375
|
return 1;
|
|
213
376
|
}
|
|
214
|
-
for (const row of result.rejected)
|
|
215
|
-
const out = arg('--out');
|
|
377
|
+
for (const row of result.rejected) stderr.write(`[corpus-next-seed] skipped ${row.tag || '(list)'}: ${row.reason}\n`);
|
|
378
|
+
const out = arg(argv, '--out', null);
|
|
216
379
|
const serialized = `${JSON.stringify(result.seed, null, 2)}\n`;
|
|
217
380
|
if (out) fs.writeFileSync(path.resolve(out), serialized);
|
|
218
|
-
|
|
219
|
-
|
|
381
|
+
stdout.write(serialized);
|
|
382
|
+
stderr.write(`[corpus-next-seed] ${result.seed.origin} ${result.seed.tag} (judged ${result.judged} generation(s) against `
|
|
383
|
+
+ `${result.expects.model}/${result.expects.dimensions}, fixture ${result.expects.fixtureSha256.slice(0, 12)})\n`);
|
|
220
384
|
return 0;
|
|
221
385
|
}
|
|
222
386
|
|
|
223
|
-
|
|
224
|
-
|
|
387
|
+
// REALPATH BOTH SIDES (ADR-0091 D12.2, applied here because this file is rewritten anyway): a plain
|
|
388
|
+
// argv[1]-vs-import.meta.url comparison is false under a symlinked path such as macOS's /var/folders
|
|
389
|
+
// temp directories, and the CLI then exits 0 having done nothing.
|
|
390
|
+
function isMain() {
|
|
391
|
+
try {
|
|
392
|
+
return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
393
|
+
} catch {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (isMain()) {
|
|
399
|
+
main().then((code) => { process.exitCode = code; }).catch((error) => {
|
|
400
|
+
console.error(`[corpus-next-seed] ${error?.message || error}`);
|
|
401
|
+
process.exitCode = 1;
|
|
402
|
+
});
|
|
225
403
|
}
|