ruvnet-brain 4.3.33 → 4.3.35

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.
@@ -0,0 +1,234 @@
1
+ // scripts/rehearse-seed-selection.mjs — the ADR-0091 D4 half of the corpus rehearsal.
2
+ //
3
+ // Before D4 the rehearsal handed candidate N straight to generation N+1 and never exercised seed
4
+ // SELECTION at all (the audit's V4 gap). This module lets it do what the nightly does:
5
+ // 1. "publish" candidate N into a LOCAL release registry, from the exact files release.mjs tried to
6
+ // upload (the recorded `gh release create`), so nothing is invented about the asset set;
7
+ // 2. optionally cut a simulated CODE RELEASE in the disposable checkout between generations (a
8
+ // version bump through the repo's own scripts/sync-version.mjs, committed) -- the event that,
9
+ // before D4, reset the seed chain to the bootstrap (ADR-0091 section 3.4);
10
+ // 3. register newer, INCOMPATIBLE decoy generations that must be skipped before any archive
11
+ // download: one whose receipt names a different embedding model, one whose recall report was
12
+ // measured against a different frozen fixture;
13
+ // 4. answer the real corpus-next-seed.mjs resolver through a fake `gh` served from that registry,
14
+ // which REFUSES any archive download and records every call;
15
+ // 5. check every candidate's runtime surface with the real verifyApprovedRuntime against a pin
16
+ // derived from the CHECKOUT's own files, never from the candidate -- so a seed's executable that
17
+ // leaked into the archive shows up as "no approved code release pinned".
18
+ //
19
+ // It publishes nothing and calls no network: the registry is a directory, the `gh` is a function.
20
+
21
+ import crypto from 'node:crypto';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { spawnSync } from 'node:child_process';
25
+
26
+ const ARCHIVE_ASSET = 'ruvnet-brain.zip';
27
+ const SIGNATURE_ASSET = 'ruvnet-brain.zip.sig';
28
+
29
+ const sha256Text = (text) => crypto.createHash('sha256').update(text).digest('hex');
30
+ function sha256File(file) {
31
+ const hash = crypto.createHash('sha256');
32
+ const fd = fs.openSync(file, 'r');
33
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
34
+ try {
35
+ let bytes;
36
+ while ((bytes = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, bytes));
37
+ } finally { fs.closeSync(fd); }
38
+ return hash.digest('hex');
39
+ }
40
+ const clone = (from, to) => fs.copyFileSync(from, to, fs.constants.COPYFILE_FICLONE);
41
+
42
+ // ---------------------------------------------------------------------------------------------
43
+ // The local registry
44
+ // ---------------------------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Register one generation. `files` are local paths whose basenames become asset names. `declared`
48
+ * overrides an asset's listed size without writing its bytes (a decoy's archive is listed, never
49
+ * served). A `.sig` placeholder is added when the files carry none, and the release records that it
50
+ * is a placeholder: the rehearsal's publish path is the non-promoting one, which signs nothing, and
51
+ * the resolver checks the signature's PRESENCE only (verification happens in the updater).
52
+ */
53
+ export function registerGeneration({ registryDir, tag, createdAt, files = [], declared = {}, note = null }) {
54
+ const dir = path.join(registryDir, tag);
55
+ fs.mkdirSync(path.join(dir, 'assets'), { recursive: true });
56
+ const assets = [];
57
+ for (const file of files) {
58
+ const name = path.basename(file);
59
+ clone(file, path.join(dir, 'assets', name));
60
+ assets.push({ name, size: fs.statSync(file).size, state: 'uploaded' });
61
+ }
62
+ for (const [name, size] of Object.entries(declared)) {
63
+ fs.writeFileSync(path.join(dir, 'assets', name), '');
64
+ assets.push({ name, size, state: 'uploaded', declaredOnly: true });
65
+ }
66
+ let signaturePlaceholder = false;
67
+ if (!assets.some(({ name }) => name === SIGNATURE_ASSET)) {
68
+ fs.writeFileSync(path.join(dir, 'assets', SIGNATURE_ASSET), 'rehearsal placeholder: not a signature\n');
69
+ assets.push({ name: SIGNATURE_ASSET, size: fs.statSync(path.join(dir, 'assets', SIGNATURE_ASSET)).size, state: 'uploaded' });
70
+ signaturePlaceholder = true;
71
+ }
72
+ const release = { tagName: tag, isDraft: false, createdAt, assets, signaturePlaceholder, note };
73
+ fs.writeFileSync(path.join(dir, 'release.json'), `${JSON.stringify(release, null, 2)}\n`);
74
+ return release;
75
+ }
76
+
77
+ /** The files a recorded `gh release create` would have uploaded: its existing-file arguments. */
78
+ export function uploadedFilesOf(createArgs) {
79
+ return (createArgs || []).filter((value) => path.isAbsolute(String(value)) && fs.existsSync(value) && fs.statSync(value).isFile());
80
+ }
81
+
82
+ /**
83
+ * A `gh` answering the three calls corpus-next-seed.mjs makes, from the registry. An archive download
84
+ * is REFUSED and recorded, so the rehearsal can assert the resolver judged from small files only.
85
+ */
86
+ export function registryGh(registryDir) {
87
+ const calls = [];
88
+ const releases = () => fs.readdirSync(registryDir)
89
+ .map((tag) => JSON.parse(fs.readFileSync(path.join(registryDir, tag, 'release.json'), 'utf8')));
90
+ const run = (command, args) => {
91
+ const line = `${command} ${args.join(' ')}`;
92
+ calls.push(line);
93
+ if (command !== 'gh') return { status: 127, stderr: `unexpected command ${command}` };
94
+ if (args[0] === 'release' && args[1] === 'list') {
95
+ return { status: 0, stdout: JSON.stringify(releases().map(({ tagName, isDraft, createdAt }) => ({ tagName, isDraft, createdAt }))) };
96
+ }
97
+ const release = releases().find((row) => row.tagName === args[2]);
98
+ if (args[0] === 'release' && args[1] === 'view') {
99
+ if (!release) return { status: 1, stderr: 'release not found' };
100
+ return { status: 0, stdout: JSON.stringify({ tagName: release.tagName, isDraft: release.isDraft,
101
+ assets: release.assets.map(({ name, size, state }) => ({ name, size, state })) }) };
102
+ }
103
+ if (args[0] === 'release' && args[1] === 'download') {
104
+ const pattern = args[args.indexOf('--pattern') + 1];
105
+ const dir = args[args.indexOf('--dir') + 1];
106
+ if (pattern === ARCHIVE_ASSET) return { status: 1, stderr: 'rehearsal registry refuses archive downloads to the resolver' };
107
+ const asset = release?.assets.find(({ name }) => name === pattern);
108
+ if (!asset || asset.declaredOnly) return { status: 1, stderr: 'asset not found' };
109
+ fs.copyFileSync(path.join(registryDir, release.tagName, 'assets', pattern), path.join(dir, pattern));
110
+ return { status: 0, stdout: '' };
111
+ }
112
+ return { status: 1, stderr: `unsupported gh call in the rehearsal registry: ${line}` };
113
+ };
114
+ return {
115
+ run,
116
+ calls,
117
+ downloads: () => calls.filter((line) => / release download /.test(line)),
118
+ archiveDownloadAttempts: () => calls.filter((line) => / release download /.test(line) && line.includes(`--pattern ${ARCHIVE_ASSET} `)),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Two newer generations the resolver must SKIP, each rebound to its own digest so every identity
124
+ * check that precedes the compatibility check still passes -- only the property under test differs:
125
+ * model-mismatch -- one store's receipt row names another model/dimensions (rejected from the
126
+ * receipt alone; its recall report must never be fetched);
127
+ * fixture-mismatch -- the recall report was measured against another frozen fixture (rejected
128
+ * after the small recall download; its archive must never be fetched).
129
+ */
130
+ export function registerIncompatibleDecoys({ registryDir, real, createdAtMs, scratchDir }) {
131
+ const realReceipt = JSON.parse(fs.readFileSync(real.receiptFile, 'utf8'));
132
+ const realRecall = JSON.parse(fs.readFileSync(real.recallFile, 'utf8'));
133
+ const decoys = [];
134
+ const make = (kind, offsetMs, mutate) => {
135
+ const digest = sha256Text(`rehearsal-decoy:${kind}:${real.sha256}`);
136
+ const tag = `corpus-sha256-${digest}`;
137
+ const dir = path.join(scratchDir, `decoy-${kind}`);
138
+ fs.mkdirSync(dir, { recursive: true });
139
+ const recall = { ...realRecall, archive: { ...realRecall.archive, sha256: digest, bytes: real.bytes } };
140
+ const receipt = { ...realReceipt, archive: { ...realReceipt.archive, sha256: digest, bytes: real.bytes } };
141
+ mutate({ recall, receipt });
142
+ const recallFile = path.join(dir, 'ruvnet-brain.zip.recall.json');
143
+ fs.writeFileSync(recallFile, JSON.stringify(recall));
144
+ receipt.recallReport = { file: path.basename(recallFile), sha256: sha256File(recallFile), bytes: fs.statSync(recallFile).size };
145
+ const receiptFile = path.join(dir, 'corpus-receipt.json');
146
+ fs.writeFileSync(receiptFile, JSON.stringify(receipt));
147
+ registerGeneration({
148
+ registryDir, tag, createdAt: new Date(createdAtMs + offsetMs).toISOString(),
149
+ files: [receiptFile, recallFile, real.accuracyFile], declared: { [ARCHIVE_ASSET]: real.bytes },
150
+ note: `rehearsal decoy (${kind}); its archive is listed, never served`,
151
+ });
152
+ decoys.push({ kind, tag });
153
+ };
154
+ make('fixture-mismatch', 1000, ({ recall }) => {
155
+ recall.fixture = { ...recall.fixture, sha256: sha256Text('rehearsal-decoy: a different frozen fixture') };
156
+ });
157
+ make('model-mismatch', 2000, ({ receipt }) => {
158
+ receipt.stores = receipt.stores.map((row, index) => (index === 0 ? { ...row, model: 'Xenova/all-MiniLM-L6-v2', dimensions: 384 } : row));
159
+ });
160
+ return decoys;
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------------------------
164
+ // A code release between generations
165
+ // ---------------------------------------------------------------------------------------------
166
+
167
+ export function nextPatchVersion(version) {
168
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version || ''));
169
+ if (!match) throw new Error(`cannot bump a non x.y.z version (${version})`);
170
+ return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`;
171
+ }
172
+
173
+ /**
174
+ * Cut a code release in the disposable checkout the way CONTRIBUTING.md's "set version" commit does:
175
+ * edit the ONE hand-edited number (plugin/.claude-plugin/plugin.json) and let scripts/sync-version.mjs
176
+ * propagate it (kb/package.json -- a SHIPPED runtime file -- among others). Only the paths this bump
177
+ * changed are committed; the rehearsal's own scratch edits in the checkout stay uncommitted.
178
+ */
179
+ export function simulateCodeRelease({ checkoutRoot }) {
180
+ const git = (args) => {
181
+ const result = spawnSync('git', args, { cwd: checkoutRoot, encoding: 'utf8' });
182
+ if (result.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${String(result.stderr).trim().slice(0, 400)}`);
183
+ return String(result.stdout);
184
+ };
185
+ const dirty = () => new Set(git(['status', '--porcelain']).split('\n').filter(Boolean).map((line) => line.slice(3)));
186
+ const pluginFile = path.join(checkoutRoot, 'plugin', '.claude-plugin', 'plugin.json');
187
+ const plugin = JSON.parse(fs.readFileSync(pluginFile, 'utf8'));
188
+ const from = plugin.version;
189
+ const to = nextPatchVersion(from);
190
+ const before = dirty();
191
+ plugin.version = to;
192
+ fs.writeFileSync(pluginFile, `${JSON.stringify(plugin, null, 2)}\n`);
193
+ const sync = spawnSync(process.execPath, [path.join(checkoutRoot, 'scripts', 'sync-version.mjs')], { cwd: checkoutRoot, encoding: 'utf8' });
194
+ if (sync.status !== 0) throw new Error(`sync-version.mjs failed in the disposable checkout: ${String(sync.stderr || sync.stdout).trim().slice(0, 400)}`);
195
+ const committed = [...dirty()].filter((file) => !before.has(file)).sort();
196
+ if (!committed.includes('kb/package.json')) throw new Error('the simulated code release did not change kb/package.json, the shipped runtime manifest');
197
+ git(['add', '--', ...committed]);
198
+ git(['-c', 'user.email=rehearsal@localhost', '-c', 'user.name=corpus rehearsal', 'commit', '-q', '-m', `chore(release): set version ${to} (rehearsal)`]);
199
+ return { from, to, head: git(['rev-parse', 'HEAD']).trim(), committed };
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------------------------
203
+ // The runtime surface, judged against the CHECKOUT
204
+ // ---------------------------------------------------------------------------------------------
205
+
206
+ /**
207
+ * A pin built from the checkout's own bytes, for the runtime-shaped rows of a candidate manifest.
208
+ * build-bundle.mjs copies the kb module graph and kb/package*.json from <checkout>/kb, and
209
+ * verify-bundle.mjs, the signing key and primer/ from the checkout root or scripts/. A row is pinned
210
+ * only when a checkout file at one of those places has EXACTLY its bytes; anything else (a file that
211
+ * could only have come from the seed) stays unpinned, so verifyApprovedRuntime's backward check names
212
+ * it. Never emitted from the candidate itself, which would pin whatever leaked.
213
+ */
214
+ export function checkoutRuntimePin({ checkoutRoot, manifest, approvedCodeSha, version, api }) {
215
+ const traced = [];
216
+ const untraced = [];
217
+ for (const row of manifest.files.filter((entry) => api.isRuntimeFile(entry.path))) {
218
+ const candidates = [path.join('kb', row.path), row.path, path.join('scripts', row.path)];
219
+ const source = candidates.find((relative) => {
220
+ const file = path.join(checkoutRoot, relative);
221
+ return fs.existsSync(file) && fs.statSync(file).isFile() && fs.statSync(file).size === row.bytes && sha256File(file) === row.sha256;
222
+ });
223
+ if (source) {
224
+ const file = path.join(checkoutRoot, source);
225
+ traced.push({ path: row.path, sha256: sha256File(file), bytes: fs.statSync(file).size, source });
226
+ } else untraced.push(row.path);
227
+ }
228
+ const pin = api.emitApprovedRuntime({
229
+ manifest: { schemaVersion: 1, kind: 'ruvnet-brain-archive-manifest', version, releaseTag: `v${version}`,
230
+ files: traced.map(({ path: p, sha256, bytes }) => ({ path: p, sha256, bytes })) },
231
+ approvedCodeSha,
232
+ });
233
+ return { pin, traced: traced.length, untraced };
234
+ }
@@ -60,8 +60,8 @@ const recallNotes = (receipt) => {
60
60
  + ` about themselves from their own content; ${r.exactFileTop5}/${r.questions} return the exact`
61
61
  + ` labeled file in the top 5 (floor ${r.floor}).`,
62
62
  'NOT measured: generated-answer correctness, citation support, or unscoped whole-corpus discovery.',
63
- `ADR-086 C3 was NOT met and is NOT claimed — its measurement ships as ${receipt.accuracyReport.file}`
64
- + ' for inspection.',
63
+ `ADR-086 C3 was NOT met and is NOT claimed — its diagnostic measurement (a declared question sample`
64
+ + ` when its coverage.bounded says so) ships as ${receipt.accuracyReport.file} for inspection.`,
65
65
  ];
66
66
  };
67
67
  import { verifyBundle } from './verify-bundle.mjs';
@@ -164,6 +164,12 @@ export async function runProtectedCorpusSeed({
164
164
  } catch (error) {
165
165
  corpusFailure(`corpus receipt is unreadable/corrupt (${error.message})`);
166
166
  }
167
+ // EXACT equality, never "GITHUB_SHA or an ancestor of it" (independent review of ADR-0091 D3,
168
+ // 2026-09-28). Accepting an ancestor let the unattended corpus job promote an OLDER runtime over the
169
+ // current live code release as `releases/latest` — fresh installs then fail on a version mismatch
170
+ // and already-updated clients refuse it as incompatible. The corpus is built at the newest
171
+ // install-verified release's sourceSha, and that must BE the protected main commit this run executes.
172
+ // The format check runs first; the comparisons below never hand the value to a subprocess.
167
173
  if (!isHex(target, 40) || target !== head || target !== env.GITHUB_SHA || target !== receipt.builderSourceSha) {
168
174
  corpusFailure('target must exactly equal HEAD, GITHUB_SHA, and the corpus receipt builderSourceSha');
169
175
  }
@@ -210,10 +216,11 @@ export async function runProtectedCorpusSeed({
210
216
 
211
217
  // ADR-086 Step 15's second binding. The detached accuracy report travels beside the archive; this
212
218
  // proves (a) the file the receipt names is the file present here, byte for byte, (b) the report
213
- // was measured against THESE archive bytes, (c) every partition in both query modes passed
214
- // 20x>=19x with no timeouts and no bounded sampling, and (d) it was produced by the committed
215
- // benchmark against the committed oracle — so a swapped oracle or a patched benchmark is caught
216
- // here even though the receipt itself carries only {file, sha256, bytes}.
219
+ // was measured against THESE archive bytes, and (c) it was produced by the committed benchmark
220
+ // against the committed oracle — so a swapped oracle or a patched benchmark is caught here even
221
+ // though the receipt itself carries only {file, sha256, bytes}. It does NOT check the score or
222
+ // coverage completeness: C3 is a diagnostic (ADR-086 amendment 2026-09-15), and the corpus
223
+ // pipeline measures a declared question sample of it (ADR-0091 D2).
217
224
  const accuracyReportFile = `${bundleFile}.accuracy.json`;
218
225
  if (receipt.accuracyReport.file !== path.basename(accuracyReportFile)) {
219
226
  corpusFailure('corpus receipt names an accuracy report that is not the one beside this archive');
@@ -133,8 +133,21 @@ const checks = [
133
133
  } },
134
134
 
135
135
  // C — one release path
136
- { id: 'C1', area: 'release', scope: 'repo', title: 'Unattended corpus promotion stays disarmed until a code release is install-verified',
137
- run: () => ({ ok: !tracked.includes('data/approved-runtime.json'), detail: tracked.includes('data/approved-runtime.json') ? 'data/approved-runtime.json present (arms the 07:17 UTC nightly)' : 'absent' }) },
136
+ // ADR-0091 D3: the approved runtime is RESOLVED at run time from the newest install-verified code
137
+ // release (its signed public-verification aggregate), never committed; the nightly is armed only by
138
+ // the CORPUS_NIGHTLY repository variable. A committed pin, or a workflow still reading one, is drift.
139
+ { id: 'C1', area: 'release', scope: 'repo', title: 'Corpus promotion resolves its runtime pin from an install-verified release at run time; nothing commits one',
140
+ run: () => {
141
+ const problems = [];
142
+ if (tracked.includes('data/approved-runtime.json')) problems.push('data/approved-runtime.json is committed (the pin must be resolved, never committed)');
143
+ for (const w of workflows) if (read(w).includes('data/approved-runtime.json')) problems.push(`${w} still reads data/approved-runtime.json`);
144
+ const dispatcher = read('.github/workflows/corpus-nightly-dispatch.yml');
145
+ if (!/vars\.CORPUS_NIGHTLY/.test(dispatcher)) problems.push('corpus-nightly-dispatch.yml is not gated on the CORPUS_NIGHTLY repository variable');
146
+ for (const w of ['corpus-nightly-dispatch.yml', 'protected-release.yml', 'corpus-seed.yml']) {
147
+ if (!read(`.github/workflows/${w}`).includes('approved-runtime.mjs --resolve')) problems.push(`${w} does not resolve the approved runtime`);
148
+ }
149
+ return none(problems);
150
+ } },
138
151
  { id: 'C2', area: 'release', scope: 'repo', title: 'Every scheduled workflow pages the phone on failure',
139
152
  run: () => {
140
153
  const alerts = read('.github/workflows/ntfy-alerts.yml');