ruvnet-brain 4.0.36 → 4.2.2-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +28 -5
  2. package/bin/install.mjs +405 -23
  3. package/data/model-catalog.json +104 -15
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/commands/brain-console.md +72 -9
  8. package/plugin/commands/configure.md +67 -21
  9. package/plugin/commands/rvcb.md +72 -9
  10. package/plugin/hooks/codex-hooks.json +40 -33
  11. package/plugin/hooks/hook-contracts.json +14 -24
  12. package/plugin/hooks/hooks.json +7 -42
  13. package/plugin/mcp/server.mjs +23 -6
  14. package/plugin/scripts/adr-currency-gate.mjs +150 -0
  15. package/plugin/scripts/capability-registry.mjs +10 -1
  16. package/plugin/scripts/codex-hook-adapter.mjs +121 -19
  17. package/plugin/scripts/codex-hook-wrapper.mjs +61 -4
  18. package/plugin/scripts/continuation-gate.mjs +148 -8
  19. package/plugin/scripts/decision-gate.mjs +428 -0
  20. package/plugin/scripts/decision-outcomes.mjs +231 -0
  21. package/plugin/scripts/degradation-watch.mjs +271 -0
  22. package/plugin/scripts/ground-ruvnet.sh +51 -11
  23. package/plugin/scripts/hijack-ruvnet.sh +11 -3
  24. package/plugin/scripts/hook-registry.mjs +48 -3
  25. package/plugin/scripts/hook-shim.mjs +58 -3
  26. package/plugin/scripts/identifier-preflight.mjs +134 -0
  27. package/plugin/scripts/learn-capture.sh +50 -1
  28. package/plugin/scripts/learn-flush.mjs +5 -5
  29. package/plugin/scripts/lesson-bridge.mjs +343 -0
  30. package/plugin/scripts/lesson-hooks.sh +26 -0
  31. package/plugin/scripts/lesson-promote.mjs +50 -0
  32. package/plugin/scripts/lesson-store.mjs +6 -1
  33. package/plugin/scripts/mcp-readiness.mjs +107 -0
  34. package/plugin/scripts/protect-brain-state.sh +9 -0
  35. package/plugin/scripts/runtime-preferences.mjs +40 -0
  36. package/plugin/scripts/session-snapshot-hook.mjs +15 -6
  37. package/plugin/scripts/spend-guard.mjs +125 -0
  38. package/plugin/scripts/unprompted-runtime.mjs +12 -2
  39. package/plugin/scripts/update-apply.mjs +7 -2
  40. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +20 -5
  41. package/plugin/skills/ruvnet-brain/SKILL.md +3 -3
  42. package/scripts/brain-score.mjs +261 -0
  43. package/scripts/brain-stamp.mjs +5 -1
  44. package/scripts/build-bundle.mjs +38 -4
  45. package/scripts/card-from-source.mjs +114 -0
  46. package/scripts/console-engine.mjs +1 -1
  47. package/scripts/corpus-candidate.mjs +294 -0
  48. package/scripts/corpus-reconcile.mjs +273 -0
  49. package/scripts/corpus-seed-publish.mjs +110 -0
  50. package/scripts/health-repair.mjs +11 -2
  51. package/scripts/ingest-new-repos.mjs +122 -0
  52. package/scripts/ingest-repo.mjs +66 -6
  53. package/scripts/learning-replay-cli.mjs +8 -3
  54. package/scripts/learning-replay-fixture.mjs +25 -6
  55. package/scripts/learning-replay-proof.mjs +38 -0
  56. package/scripts/nightly-wrapper.sh +55 -7
  57. package/scripts/onboarding-console.mjs +21 -1
  58. package/scripts/org-repo-count.mjs +119 -0
  59. package/scripts/rebuild-gists-from-receipts.mjs +246 -0
  60. package/scripts/release-transaction.mjs +30 -2
  61. package/scripts/release.mjs +147 -0
  62. package/scripts/repo-count-detector.mjs +62 -0
  63. package/scripts/restore-local-ingests.mjs +116 -0
  64. package/scripts/rvf-generation.mjs +44 -0
  65. package/scripts/selfcheck.mjs +9 -1
  66. package/scripts/stabilization-receipt.mjs +11 -1
  67. package/scripts/sync-census.mjs +0 -0
  68. package/scripts/sync-commands.mjs +117 -0
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ // Deterministically reconstruct gist passages from tracked, exact-version source receipts.
3
+ // This intentionally does not call the GitHub API, build vectors, or publish artifacts.
4
+
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
+ const NAME = 'ruv-gists';
12
+ const HEX_GIST = /^[a-f0-9]{20,64}$/;
13
+ const HEX40 = /^[a-f0-9]{40}$/;
14
+ const HEX64 = /^[a-f0-9]{64}$/;
15
+
16
+ function fail(message) {
17
+ throw new Error(`[rebuild-gists-from-receipts] ${message}`);
18
+ }
19
+
20
+ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
21
+ const jsonLine = (value) => JSON.stringify(value).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
22
+
23
+ function validDate(value) {
24
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value) && Number.isFinite(Date.parse(value));
25
+ }
26
+
27
+ function validateFilename(filename, label) {
28
+ if (typeof filename !== 'string' || !filename || filename.includes('\0')
29
+ || filename.split(/[\\/]/).some((segment) => !segment || segment === '.' || segment === '..')) {
30
+ fail(`${label} has an unsafe or missing filename`);
31
+ }
32
+ }
33
+
34
+ export function validateSourceReceipts(source) {
35
+ if (![1, 2].includes(source?.schemaVersion)) fail('source receipt schemaVersion must be 1 or 2');
36
+ if (!/^[A-Za-z0-9-]{1,39}$/.test(String(source.owner || ''))) fail('source receipt owner is malformed');
37
+ if (!validDate(source.generated)) fail('source receipt generated timestamp is malformed');
38
+ if (!source.gists || typeof source.gists !== 'object' || Array.isArray(source.gists)) {
39
+ fail('source receipt gists must be an object');
40
+ }
41
+ if (Object.keys(source.gists).length === 0) fail('source receipt has no gist receipts');
42
+
43
+ for (const [gistId, receipt] of Object.entries(source.gists)) {
44
+ const label = `gist ${gistId}`;
45
+ if (!HEX_GIST.test(gistId)) fail(`${label} id is malformed`);
46
+ if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) fail(`${label} receipt is malformed`);
47
+ if (receipt.complete !== true) fail(`${label} receipt is incomplete`);
48
+ if (!HEX40.test(String(receipt.versionSha || ''))) fail(`${label} versionSha is malformed`);
49
+ if (!validDate(receipt.updatedAt) || !validDate(receipt.ingestedAt)) fail(`${label} timestamps are malformed`);
50
+ if (!Array.isArray(receipt.files) || receipt.files.length === 0) fail(`${label} has no complete file inventory`);
51
+ const filenames = new Set();
52
+ for (const file of receipt.files) {
53
+ validateFilename(file?.filename, label);
54
+ if (filenames.has(file.filename)) fail(`${label} has duplicate filename ${file.filename}`);
55
+ filenames.add(file.filename);
56
+ if (file.included === true) {
57
+ if (!HEX64.test(String(file.sha256 || '')) || !Number.isSafeInteger(file.bytes) || file.bytes < 0) {
58
+ fail(`${label}/${file.filename} has malformed included-file identity`);
59
+ }
60
+ } else if (file.included !== false || typeof file.reason !== 'string' || !file.reason
61
+ || !(file.size === null || (Number.isSafeInteger(file.size) && file.size >= 0))) {
62
+ fail(`${label}/${file.filename} has malformed exclusion evidence`);
63
+ }
64
+ }
65
+ const expectedDigest = sha256(JSON.stringify(receipt.files));
66
+ if (!HEX64.test(String(receipt.contentDigest || '')) || receipt.contentDigest !== expectedDigest) {
67
+ fail(`${label} contentDigest does not match its file inventory`);
68
+ }
69
+ }
70
+ return source;
71
+ }
72
+
73
+ export function rawUrlFor({ owner, gistId, versionSha, filename }) {
74
+ if (!/^[A-Za-z0-9-]{1,39}$/.test(String(owner || '')) || !HEX_GIST.test(String(gistId || ''))
75
+ || !HEX40.test(String(versionSha || ''))) fail('cannot construct raw URL from malformed source identity');
76
+ validateFilename(filename, `gist ${gistId}`);
77
+ const encodedFile = filename.split('/').map(encodeURIComponent).join('/');
78
+ return `https://gist.githubusercontent.com/${owner}/${gistId}/raw/${versionSha}/${encodedFile}`;
79
+ }
80
+
81
+ export function paragraphChunks(text, size = 3200) {
82
+ if (typeof text !== 'string') fail('passage content must be text');
83
+ if (!Number.isSafeInteger(size) || size < 1) fail('chunk size must be a positive integer');
84
+ const out = [];
85
+ let buffer = '';
86
+ for (const paragraph of text.split(/\n\n+/)) {
87
+ if (buffer && buffer.length + paragraph.length + 2 > size) {
88
+ out.push(buffer);
89
+ buffer = '';
90
+ }
91
+ buffer = buffer ? `${buffer}\n\n${paragraph}` : paragraph;
92
+ }
93
+ if (buffer.trim()) out.push(buffer);
94
+ return out;
95
+ }
96
+
97
+ export function provenanceBanner({ owner, gistId, filename, updatedAt }) {
98
+ return `SOURCE: GitHub gist by @${owner} — "${filename.replace(/\s+/g, ' ').trim().slice(0, 160)}"\n`
99
+ + `GIST STATUS: rUv's own notes / release announcement — may describe PROPOSED or UNRELEASED work.\n`
100
+ + `Treat as intent, not as confirmed shipped behavior: verify against repo source before asserting.\n`
101
+ + `updated: ${updatedAt.slice(0, 10)} · https://gist.github.com/${owner}/${gistId}\n\n`;
102
+ }
103
+
104
+ async function mapBounded(items, concurrency, operation) {
105
+ const results = new Array(items.length);
106
+ let next = 0;
107
+ async function worker() {
108
+ while (next < items.length) {
109
+ const index = next++;
110
+ results[index] = await operation(items[index]);
111
+ }
112
+ }
113
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
114
+ return results;
115
+ }
116
+
117
+ export async function reconstructGists(source, { fetchFn = globalThis.fetch, concurrency = 6 } = {}) {
118
+ validateSourceReceipts(source);
119
+ if (typeof fetchFn !== 'function') fail('fetch implementation is unavailable');
120
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 32) {
121
+ fail('concurrency must be an integer from 1 through 32');
122
+ }
123
+
124
+ const jobs = [];
125
+ for (const [gistId, receipt] of Object.entries(source.gists)) {
126
+ for (const file of receipt.files) {
127
+ if (file.included) jobs.push({ gistId, receipt, file });
128
+ }
129
+ }
130
+ const bodies = await mapBounded(jobs, concurrency, async ({ gistId, receipt, file }) => {
131
+ const url = rawUrlFor({ owner: source.owner, gistId, versionSha: receipt.versionSha, filename: file.filename });
132
+ const response = await fetchFn(url, {
133
+ headers: { accept: 'text/plain', 'user-agent': 'ruvnet-brain-gist-receipt-rebuild' },
134
+ });
135
+ if (!response?.ok) fail(`${gistId}/${file.filename} raw fetch failed: HTTP ${response?.status ?? 'unknown'}`);
136
+ const bytes = Buffer.from(await response.arrayBuffer());
137
+ if (bytes.length !== file.bytes) fail(`${gistId}/${file.filename} byte count ${bytes.length} differs from receipt ${file.bytes}`);
138
+ const digest = sha256(bytes);
139
+ if (digest !== file.sha256) fail(`${gistId}/${file.filename} sha256 ${digest} differs from receipt ${file.sha256}`);
140
+ let body;
141
+ try {
142
+ body = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
143
+ } catch {
144
+ fail(`${gistId}/${file.filename} is not valid UTF-8 text`);
145
+ }
146
+ return body;
147
+ });
148
+
149
+ const passages = [];
150
+ const entries = {};
151
+ let bodyIndex = 0;
152
+ let id = 0;
153
+ for (const [gistId, receipt] of Object.entries(source.gists)) {
154
+ for (const file of receipt.files) {
155
+ if (!file.included) continue;
156
+ const body = bodies[bodyIndex++];
157
+ const chunks = paragraphChunks(body);
158
+ const title = file.filename.replace(/\s+/g, ' ').trim().slice(0, 180) || file.filename;
159
+ const banner = provenanceBanner({ owner: source.owner, gistId, filename: file.filename, updatedAt: receipt.updatedAt });
160
+ for (const [chunkIndex, chunk] of chunks.entries()) {
161
+ const passageId = String(id++);
162
+ const passagePath = `${gistId.slice(0, 8)}/${file.filename}${chunks.length > 1 ? `#${chunkIndex}` : ''}`;
163
+ const text = banner + chunk;
164
+ passages.push({ id: passageId, text, path: passagePath, title });
165
+ entries[passageId] = { path: passagePath, kind: 'doc', title, chunk: chunkIndex, preview: text.slice(0, 200) };
166
+ }
167
+ }
168
+ }
169
+ const passageBody = passages.map(jsonLine).join('\n') + '\n';
170
+ const meta = {
171
+ model: NAME,
172
+ dimensions: 0,
173
+ metric: 'cosine',
174
+ name: NAME,
175
+ generated: source.generated,
176
+ repo: `gists/${source.owner}`,
177
+ note: "rUv's public gists — announcements and thinking, PROPOSED unless confirmed in repo source.",
178
+ entries,
179
+ };
180
+ return {
181
+ passages,
182
+ passageBody,
183
+ meta,
184
+ sources: { ...source, schemaVersion: 2, passagesSha256: sha256(passageBody), gists: source.gists },
185
+ };
186
+ }
187
+
188
+ export function writeReconstruction(result, { outDir }) {
189
+ const output = path.resolve(outDir || '');
190
+ if (!result?.passageBody || !result.meta || !result.sources) fail('complete reconstruction result is required');
191
+ fs.mkdirSync(output, { recursive: true });
192
+ const files = {
193
+ passagesFile: path.join(output, `${NAME}.passages.jsonl`),
194
+ metaFile: path.join(output, `${NAME}.meta.json`),
195
+ sourcesFile: path.join(output, `${NAME}.sources.json`),
196
+ };
197
+ const contents = new Map([
198
+ [files.passagesFile, result.passageBody],
199
+ [files.metaFile, `${JSON.stringify(result.meta, null, 2)}\n`],
200
+ [files.sourcesFile, `${JSON.stringify(result.sources, null, 2)}\n`],
201
+ ]);
202
+ const staged = [];
203
+ try {
204
+ for (const [file, content] of contents) {
205
+ const temporary = `${file}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
206
+ fs.writeFileSync(temporary, content, { flag: 'wx' });
207
+ staged.push([temporary, file]);
208
+ }
209
+ for (const [temporary, file] of staged) fs.renameSync(temporary, file);
210
+ } finally {
211
+ for (const [temporary] of staged) fs.rmSync(temporary, { force: true });
212
+ }
213
+ return files;
214
+ }
215
+
216
+ function option(argv, name, fallback) {
217
+ const index = argv.indexOf(name);
218
+ return index >= 0 && argv[index + 1] && !argv[index + 1].startsWith('--') ? argv[index + 1] : fallback;
219
+ }
220
+
221
+ export async function main(argv = process.argv.slice(2)) {
222
+ const allowed = new Set(['--sources', '--out-dir', '--concurrency']);
223
+ for (let index = 0; index < argv.length; index += 2) {
224
+ if (!allowed.has(argv[index]) || !argv[index + 1] || argv[index + 1].startsWith('--')) fail(`unknown or incomplete option ${argv[index] || '(missing)'}`);
225
+ }
226
+ const sourcesFile = path.resolve(option(argv, '--sources', path.join(ROOT, 'kb', `${NAME}.sources.json`)));
227
+ const outDir = path.resolve(option(argv, '--out-dir', path.dirname(sourcesFile)));
228
+ const concurrency = Number(option(argv, '--concurrency', '6'));
229
+ let source;
230
+ try {
231
+ source = JSON.parse(fs.readFileSync(sourcesFile, 'utf8'));
232
+ } catch (error) {
233
+ fail(`cannot read source receipts ${sourcesFile}: ${error.message}`);
234
+ }
235
+ const result = await reconstructGists(source, { concurrency });
236
+ const written = writeReconstruction(result, { outDir });
237
+ process.stdout.write(`${JSON.stringify({ ok: true, passages: result.passages.length, passagesSha256: result.sources.passagesSha256, ...written }, null, 2)}\n`);
238
+ return 0;
239
+ }
240
+
241
+ if (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) {
242
+ main().then((code) => { process.exitCode = code; }).catch((error) => {
243
+ console.error(error.message);
244
+ process.exitCode = 1;
245
+ });
246
+ }
@@ -55,10 +55,36 @@ export const ALLOWED_TRANSITIONS = Object.freeze({
55
55
  aborted: [],
56
56
  });
57
57
 
58
+ /**
59
+ * THE DIGEST SERIALISER MUST AGREE WITH THE ONE THAT WRITES THE FILE.
60
+ *
61
+ * It did not, and it silently corrupted the terminal receipt of EVERY successful release. Measured
62
+ * 2026-08-20 on v4.0.36 and v4.0.90-dev — both `channels-converged` receipts, both digest=BAD, both
63
+ * with the same missing key:
64
+ *
65
+ * canonicalJson({a: undefined}) -> {"a":undefined} key KEPT (and not even valid JSON)
66
+ * JSON.stringify({a: undefined}) -> {} key DROPPED
67
+ *
68
+ * The converge observation carries optional fields — `hosts.verifier.error`, `claudeOnly`,
69
+ * `codexOnly`, `dual` — and on a clean run `verified.error` is `undefined`. So the digest was
70
+ * computed over `…"error":undefined…` while the bytes written omitted `error` entirely: a digest
71
+ * over a shape that can never be read back. `runReleaseTransaction` appends a receipt, reads it
72
+ * back and verifies it, so the publish then died with `release receipt digest mismatch` AFTER npm
73
+ * and GitHub had both been promoted — the release shipped and the rail reported failure.
74
+ *
75
+ * Proven by reconstruction: restoring `observation.hosts.verifier.error = undefined` on the stored
76
+ * receipt makes `digestReceipt` reproduce the stored digest exactly.
77
+ *
78
+ * Arrays have the same asymmetry (`JSON.stringify([undefined])` is `[null]`), so both are matched
79
+ * here. This does not change the digest of any receipt that never held an undefined value, which is
80
+ * every receipt that currently verifies.
81
+ */
58
82
  export const canonicalJson = (value) => {
59
- if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
83
+ if (Array.isArray(value)) return `[${value.map((item) => (item === undefined ? 'null' : canonicalJson(item))).join(',')}]`;
60
84
  if (value && typeof value === 'object') {
61
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
85
+ return `{${Object.keys(value).sort()
86
+ .filter((key) => value[key] !== undefined)
87
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
62
88
  }
63
89
  return JSON.stringify(value);
64
90
  };
@@ -86,6 +112,8 @@ export const transactionIdFor = (identity) => crypto.createHash('sha256').update
86
112
  bundleSha256: identity.bundleSha256,
87
113
  bundleSignatureSha256: identity.bundleSignatureSha256 || null,
88
114
  bundleDigestSha256: identity.bundleDigestSha256 || null,
115
+ ...(identity.corpusSeedSha256 ? { corpusSeedSha256: identity.corpusSeedSha256 } : {}),
116
+ ...(identity.generationLedgerSha256 ? { generationLedgerSha256: identity.generationLedgerSha256 } : {}),
89
117
  })).digest('hex');
90
118
 
91
119
  export function signReceipt(receipt, privateKey) {
@@ -37,6 +37,7 @@ import { verifyPayload } from './release-payload.mjs';
37
37
 
38
38
  const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
39
39
  const PUBLISH = process.argv.includes('--publish');
40
+ const CORPUS_SEED = process.argv.includes('--corpus-seed');
40
41
  let protectedCandidate = null;
41
42
  let sealedPackageArtifact = null;
42
43
  let publicationReceiptPath = null;
@@ -56,6 +57,147 @@ function runOrDie(label, cmd, args, opts = {}) {
56
57
  }
57
58
  }
58
59
 
60
+ const cliArg = (argv, name) => {
61
+ const index = argv.indexOf(name);
62
+ return index >= 0 ? argv[index + 1] : undefined;
63
+ };
64
+ const isHex = (value, length) => new RegExp(`^[a-f0-9]{${length}}$`).test(String(value || ''));
65
+ const sha256File = (file) => {
66
+ const hash = crypto.createHash('sha256');
67
+ const fd = fs.openSync(file, 'r');
68
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
69
+ try {
70
+ let bytes;
71
+ while ((bytes = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, bytes));
72
+ } finally {
73
+ fs.closeSync(fd);
74
+ }
75
+ return hash.digest('hex');
76
+ };
77
+ const exactFileIdentity = (value) => value && typeof value.file === 'string'
78
+ && isHex(value.sha256, 64) && Number.isSafeInteger(value.bytes) && value.bytes >= 0;
79
+
80
+ function corpusFailure(message) {
81
+ throw new Error(`[corpus-seed] ${message}`);
82
+ }
83
+
84
+ export function runProtectedCorpusSeed({
85
+ argv = process.argv.slice(2),
86
+ env = process.env,
87
+ root = ROOT,
88
+ run = (command, args, options) => spawnSync(command, args, { encoding: 'utf8', ...options }),
89
+ } = {}) {
90
+ if (env.GITHUB_ACTIONS !== 'true' || env.GITHUB_WORKFLOW !== 'protected-release'
91
+ || env.GITHUB_REPOSITORY !== 'stuinfla/ruvnet-brain') {
92
+ corpusFailure('publication is allowed only inside the protected-release GitHub workflow for stuinfla/ruvnet-brain');
93
+ }
94
+
95
+ const tag = cliArg(argv, '--corpus-tag');
96
+ const bundleFile = cliArg(argv, '--corpus-bundle');
97
+ const receiptFile = cliArg(argv, '--corpus-receipt');
98
+ const target = cliArg(argv, '--target');
99
+ const repo = cliArg(argv, '--repo') || env.GITHUB_REPOSITORY;
100
+ const digestMatch = String(tag || '').match(/^corpus-sha256-([a-f0-9]{64})$/);
101
+ if (!digestMatch) corpusFailure('corpus tag must be corpus-sha256- followed by 64 lowercase hex characters');
102
+ if (repo !== env.GITHUB_REPOSITORY || repo !== 'stuinfla/ruvnet-brain') corpusFailure('repository does not match the protected workflow');
103
+
104
+ for (const [label, file] of [['bundle', bundleFile], ['receipt', receiptFile]]) {
105
+ if (!file || !path.isAbsolute(file)) corpusFailure(`${label} must be an absolute regular file`);
106
+ try {
107
+ const stat = fs.lstatSync(file);
108
+ if (!stat.isFile() || stat.isSymbolicLink()) corpusFailure(`${label} must be an absolute regular file`);
109
+ } catch (error) {
110
+ if (String(error.message).startsWith('[corpus-seed]')) throw error;
111
+ corpusFailure(`${label} must be an absolute regular file (${error.message})`);
112
+ }
113
+ }
114
+
115
+ const headResult = run('git', ['rev-parse', 'HEAD'], {
116
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
117
+ });
118
+ if (headResult.error || headResult.status !== 0) corpusFailure('cannot resolve release HEAD');
119
+ const head = String(headResult.stdout || '').trim();
120
+
121
+ let receipt;
122
+ try {
123
+ receipt = JSON.parse(fs.readFileSync(receiptFile, 'utf8'));
124
+ } catch (error) {
125
+ corpusFailure(`corpus receipt is unreadable/corrupt (${error.message})`);
126
+ }
127
+ if (!isHex(target, 40) || target !== head || target !== env.GITHUB_SHA || target !== receipt.builderSourceSha) {
128
+ corpusFailure('target must exactly equal HEAD, GITHUB_SHA, and the corpus receipt builderSourceSha');
129
+ }
130
+
131
+ const boundaryIdentities = [receipt.privateFence, receipt.eligibilityPolicy, receipt.generationLedger];
132
+ const storeBindingsValid = Number.isSafeInteger(receipt.storeCount) && receipt.storeCount > 0
133
+ && Array.isArray(receipt.stores) && receipt.stores.length === receipt.storeCount
134
+ && receipt.stores.every((store) => typeof store?.name === 'string' && store.name.length > 0
135
+ && /^[a-f0-9]{7,64}$/.test(String(store.sourceCommit || ''))
136
+ && typeof store.builtUtc === 'string' && Number.isFinite(Date.parse(store.builtUtc))
137
+ && typeof store.model === 'string' && store.model.length > 0
138
+ && Number.isSafeInteger(store.dimensions) && store.dimensions > 0
139
+ && Array.isArray(store.files) && store.files.length > 0 && store.files.every(exactFileIdentity));
140
+ const emptyFailureArrays = ['duplicateRvfDigests', 'unreceiptedRvfFiles', 'missingSidecars']
141
+ .every((key) => Array.isArray(receipt[key]) && receipt[key].length === 0);
142
+ const privateExclusionsValid = Array.isArray(receipt.excludedPrivateStores)
143
+ && receipt.excludedPrivateStores.every((name) => typeof name === 'string' && name.length > 0);
144
+ const generatorFile = path.join(root, 'scripts/corpus-candidate.mjs');
145
+ const generatorValid = fs.existsSync(generatorFile)
146
+ && receipt.generator?.corpusCandidateSha256 === sha256File(generatorFile);
147
+ if (receipt.schemaVersion !== 1 || receipt.kind !== 'ruvnet-brain-corpus-candidate'
148
+ || !receipt.createdAt || !receipt.coverageGeneration || !storeBindingsValid || !emptyFailureArrays
149
+ || !privateExclusionsValid || !boundaryIdentities.every(exactFileIdentity) || !exactFileIdentity(receipt.archive)
150
+ || !generatorValid
151
+ || receipt.archive.file !== path.basename(bundleFile)) {
152
+ corpusFailure('corpus receipt bindings are incomplete or invalid');
153
+ }
154
+
155
+ const archiveSha256 = sha256File(bundleFile);
156
+ if (archiveSha256 !== receipt.archive.sha256 || fs.statSync(bundleFile).size !== receipt.archive.bytes) {
157
+ corpusFailure('archive bytes do not match the corpus receipt');
158
+ }
159
+ if (digestMatch[1] !== archiveSha256) corpusFailure('corpus tag digest does not match the receipt and archive');
160
+
161
+ const viewArgs = ['release', 'view', tag, '--json', 'tagName', '--repo', repo];
162
+ const view = run('gh', viewArgs, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
163
+ if (!view.error && view.status === 0) corpusFailure(`release ${tag} already exists; refusing to overwrite immutable corpus seed`);
164
+ const viewError = String(view.error?.message || view.stderr || view.stdout || '');
165
+ if (!/(release not found|no release found)/i.test(viewError)) corpusFailure(`cannot prove ${tag} is absent (${viewError.trim() || `gh exited ${view.status}`})`);
166
+
167
+ const notes = [
168
+ 'Content-addressed RuvNet Brain corpus seed.',
169
+ `Archive SHA-256: ${archiveSha256}`,
170
+ `Receipt SHA-256: ${sha256File(receiptFile)}`,
171
+ `Stores: ${receipt.storeCount}`,
172
+ `Coverage generation: ${receipt.coverageGeneration}`,
173
+ 'This published prerelease is immutable and must never be replaced.',
174
+ ].join('\n');
175
+ const createArgs = [
176
+ 'release', 'create', tag,
177
+ '--prerelease', '--latest=false',
178
+ '--target', target,
179
+ '--repo', repo,
180
+ '--title', `Immutable corpus seed ${archiveSha256.slice(0, 16)}`,
181
+ '--notes', notes,
182
+ bundleFile, receiptFile,
183
+ ];
184
+ const create = run('gh', createArgs, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
185
+ if (create.error || create.status !== 0) {
186
+ corpusFailure(`protected corpus publication failed (${String(create.error?.message || create.stderr || create.stdout || '').trim()})`);
187
+ }
188
+ return { tag, target, repository: repo, archiveSha256, receiptSha256: sha256File(receiptFile) };
189
+ }
190
+
191
+ if (CORPUS_SEED) {
192
+ try {
193
+ const result = runProtectedCorpusSeed();
194
+ console.log(JSON.stringify({ ok: true, mode: 'corpus-seed', ...result }, null, 2));
195
+ } catch (error) {
196
+ console.error(error.message);
197
+ process.exitCode = 1;
198
+ }
199
+ } else {
200
+
59
201
  console.log(`\n${c.b('RuvNet Brain — release / definition-of-done')} ${c.dim('· ' + (PUBLISH ? 'PUBLISH' : 'check-only') + ' · shipping ' + V())}\n`);
60
202
 
61
203
  // The local CLI remains useful as a read-only preflight, but publication authority lives only in
@@ -195,6 +337,8 @@ if (PUBLISH) {
195
337
  bundleSignaturePath: path.join(payloadRoot, 'ruvnet-brain.zip.sig'),
196
338
  bundleDigestPath: path.join(payloadRoot, 'ruvnet-brain.zip.sha256'),
197
339
  packagePath: byRole.get('npm'),
340
+ corpusSeedPath: byRole.get('corpus-seed'),
341
+ generationLedgerPath: byRole.get('generation-ledger'),
198
342
  };
199
343
  for (const asset of Object.values(assets)) {
200
344
  if (!fs.existsSync(asset)) {
@@ -224,6 +368,8 @@ if (PUBLISH) {
224
368
  bundleSha256,
225
369
  bundleSignatureSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.bundleSignaturePath)).digest('hex'),
226
370
  bundleDigestSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.bundleDigestPath)).digest('hex'),
371
+ corpusSeedSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.corpusSeedPath)).digest('hex'),
372
+ generationLedgerSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.generationLedgerPath)).digest('hex'),
227
373
  };
228
374
  const privatePem = process.env.RUVNET_SIGNING_KEY;
229
375
  if (!privatePem) throw new Error('RUVNET_SIGNING_KEY is required for signed transaction receipts');
@@ -254,3 +400,4 @@ if (PUBLISH) {
254
400
  } else {
255
401
  console.log(`\n${c.g(c.b('✓✓✓ PREFLIGHT PASS — NOT PUBLISHED'))} — the committed candidate passed every check-only gate.\n`);
256
402
  }
403
+ }
@@ -0,0 +1,62 @@
1
+ // repo-count-detector.mjs — THE ONE detector for corpus-size claims in public surfaces.
2
+ //
3
+ // Extracted from tests/unit/repo-count.test.mjs on 2026-08-10 so the WRITER (sync-census.mjs)
4
+ // and the GATE (repo-count.test.mjs) consult the same code. They had drifted by construction:
5
+ // the gate found every phrasing, while the writer enumerated a handful by hand and missed
6
+ // "71 of rUv's repos", "71 RuvNet building-block repos", "71 built today", and the
7
+ // HTML-entity variant. A checker and a fixer that disagree guarantee recurring red builds.
8
+
9
+ export const MIN_CORPUS_MAGNITUDE = 20;
10
+
11
+ // Deliberate, documented exceptions: the literal is correct AS WRITTEN because it describes a
12
+ // PAST state (a "before" baseline in a receipts table), not a current claim — not stale data.
13
+ // Anything else that disagrees with ALLOWED fails. Keyed `${file}::${matched text}` so a typo'd
14
+ // exemption can't silently swallow an unrelated real regression.
15
+ export const EXEMPT = new Set([
16
+ 'README.md::24 repos', // v1 (pre-2.0) baseline column in the "what 2.0 proved" before/after table
17
+ 'README.md::24→69 verified repos', // same v1-baseline row, "24→<current>" delta phrasing
18
+ // 2026-08-06: the corpus grew 69 → 71 (tonight's rebuild). The CURRENT claims were updated to 71
19
+ // in README.md and SKILL.md, but these two are HISTORICAL — they describe what release 2.0
20
+ // proved, inside a collapsed "Earlier — what 2.0 proved" section and its before/after table.
21
+ // Rewriting them to 71 would be the easy way to make this gate green and would falsify the record:
22
+ // 2.0 shipped with 69, not 71. A gate that pressures you into editing history is worse than the
23
+ // stale number it caught, so historical mentions are exempted by name and current ones are not.
24
+ 'README.md::69 verified repos', // "what 2.0 proved" summary line — a claim about 2.0, not today
25
+ 'README.md::69 repos', // the 2.0 column of the same v1→v2.0 before/after table
26
+ ]);
27
+
28
+ /** @param {string} src @returns {{text: string, n: number}[]} */
29
+ export function findRepoCountLiterals(src) {
30
+ const found = [];
31
+ // Covers "repo(s)" and the spelled-out "repositor(y|ies)" (SKILL.md line 8's own phrasing:
32
+ // "32 RuvNet (rUv / Reuven Cohen) repositories" — real prose puts several filler words/
33
+ // parentheticals between the number and the noun, so this can't be a fixed word count).
34
+ const REPO_WORD = '(?:repos?|repositor(?:y|ies))';
35
+ // Digit NOT immediately followed by "+": an open lower-bound like "20+ repos" is a true,
36
+ // deliberately-vague qualifier, not a precise count claim — exempt from matching at all rather
37
+ // than needing a per-mention exemption. Filler between the digit and the repo-word is capped at
38
+ // 50 chars and may not cross a sentence end / table-cell boundary (".", "|", newline), so this
39
+ // can't accidentally bridge two unrelated numbers separated by prose.
40
+ // `(?<!\d\.)` — never treat a SEMVER COMPONENT as a repo count. Real false positive: the primer's
41
+ // header line "…v3.4.21-dev · Built: 2026-07-20 · Covers: 69/192 repos" made the detector read the
42
+ // patch number `21` (the `-` after it is a word boundary) and then walk 40-odd filler chars to
43
+ // "repos", reporting a stale count on a line whose actual claim — 69/192 — was correct. The
44
+ // published number was right and the gate cried wolf, which is how a gate loses its authority.
45
+ // Two guards, both added after REAL false positives on the primer's own header line
46
+ // "…<version> · Built: <date> · Covers: 69/192 repos", whose actual claim (69/192) is correct:
47
+ // (?<!\d\.) — a SEMVER COMPONENT is never a repo count. The patch number was being read as
48
+ // one, because the `-` before a prerelease suffix is a word boundary.
49
+ // `·` in the filler exclusion — a middle dot separates INDEPENDENT facts. Without it the walk
50
+ // crossed from the build DATE into the count and reported `2026` as a repo count
51
+ // (found by this file's own detector tests, after the semver fix unmasked it).
52
+ const reSpaced = new RegExp(`(?<!\\d\\.)\\b(\\d{1,4})(?!\\+)\\b(?:(?![.|·\\n])[\\s\\S]){0,50}?\\b${REPO_WORD}\\b`, 'gi');
53
+ const reHyphen = new RegExp(`(?<!\\d\\.)\\b(\\d{1,4})(?!\\+)-repos?\\b`, 'gi');
54
+ for (const re of [reSpaced, reHyphen]) {
55
+ for (const m of src.matchAll(re)) {
56
+ const n = Number(m[1]);
57
+ if (n >= MIN_CORPUS_MAGNITUDE) found.push({ text: m[0], n });
58
+ }
59
+ }
60
+ return found;
61
+ }
62
+
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * restore-local-ingests.mjs — replay every ingest a bundle apply silently removed.
4
+ *
5
+ * WHAT HAPPENED, overnight 2026-08-13 → 08-14. A scheduled bundle apply extracted an Aug-8 bundle
6
+ * over the live brain cache. The outcome for each piece of the previous evening's work was decided
7
+ * entirely by whether it had been committed:
8
+ *
9
+ * SURVIVED capability-cards.md — 5 hand-written cards, committed and pushed that night. The live
10
+ * brain was restored from the repo copy in one command.
11
+ * DIED helix, rvQR, RuCelium, wifi-veil — ingested and VERIFIED ROUTING hours earlier, gone
12
+ * without a trace. Their cards became orphans pointing at stores that no longer existed,
13
+ * which is worse than absence: the router offers an answer whose source is missing.
14
+ *
15
+ * The owner, correctly and for the second time: "you can't ever let work go overnight — you must
16
+ * always push latest changes or things get overwritten."
17
+ *
18
+ * A `.rvf` is a 32MB gitignored build artifact and CANNOT be committed. So the rule's second half
19
+ * applies: when the artifact cannot be pushed, commit the RECIPE. `ingest-repo.mjs` now records each
20
+ * ingest into `kb/local-ingests.json`, and this replays anything the cache is missing.
21
+ *
22
+ * The wipe was not on either adversarial audit's list, because both read code and this only shows
23
+ * up if you watch the machine overnight. A defect that needs a night to appear needs a ledger to be
24
+ * seen at all.
25
+ *
26
+ * MEASURED 2026-08-19, Dream Cycle memory-durability night: this script cannot tell "the store root
27
+ * was wiped" from "the store root never existed on this host" — a fresh checkout, a CI runner, or
28
+ * this very nightly agent's own ephemeral container all read exactly one recorded ingest away from
29
+ * every other, i.e. IDENTICALLY to a real overnight wipe, because `missingIngests()` only diffs the
30
+ * ledger against `storesAt(root)`, and `storesAt()` silently returns `[]` on ENOENT (kb/store-root.mjs).
31
+ * `restore-local-ingests.mjs` had zero test coverage before tonight, and the repo's own dream-cycle
32
+ * operating notes told this very automation to read ANY non-zero exit as "the nightly bundle wiped
33
+ * local work" — so a routine fresh-checkout run would have logged a false wipe every single night.
34
+ *
35
+ * The fix follows the pattern this repo already uses correctly in `nightly-watchdog.mjs`
36
+ * (MISSING / NEVER-RAN / STALE, three states never collapsed into one alarm) and the general
37
+ * pattern for reconciling a durable recipe against a rebuildable cache (npm's `.package-lock.json`
38
+ * witness co-located with `node_modules`, Dynamo-style tombstones): check whether the root was ever
39
+ * initialized BEFORE treating its contents as evidence of loss.
40
+ */
41
+ import fs from 'node:fs';
42
+ import path from 'node:path';
43
+ import { execFileSync } from 'node:child_process';
44
+ import { fileURLToPath } from 'node:url';
45
+ import { storeRoot, storesAt } from '../kb/store-root.mjs';
46
+
47
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
48
+ const LEDGER = path.join(ROOT, 'kb', 'local-ingests.json');
49
+
50
+ export const OK = 'OK';
51
+ export const NEVER_MATERIALIZED = 'NEVER-MATERIALIZED';
52
+ export const WIPED = 'WIPED';
53
+
54
+ /** Which recorded ingests are absent from the store root retrieval actually reads. */
55
+ export function missingIngests({ ledgerFile = LEDGER, root = storeRoot() } = {}) {
56
+ let recorded = [];
57
+ try { recorded = JSON.parse(fs.readFileSync(ledgerFile, 'utf8')).ingests ?? []; } catch { return []; }
58
+ const present = new Set(storesAt(root));
59
+ return recorded.filter((e) => !present.has(e.store ?? e.name.toLowerCase()));
60
+ }
61
+
62
+ /**
63
+ * Classify a diff between the recipe ledger and the live store root into three states, never one:
64
+ * OK every recorded ingest is present.
65
+ * NEVER-MATERIALIZED root does not exist on this host at all — nothing was ever ingested here.
66
+ * Not evidence of loss: a fresh checkout, CI runner, or this agent's own
67
+ * ephemeral container all look like this on a routine first run.
68
+ * WIPED root exists (so this host DID ingest before) but recorded entries are gone —
69
+ * the actual signal `restore-local-ingests.mjs` exists to catch.
70
+ */
71
+ export function classify({ ledgerFile = LEDGER, root = storeRoot() } = {}) {
72
+ const missing = missingIngests({ ledgerFile, root });
73
+ if (!missing.length) return { state: OK, missing };
74
+ if (!fs.existsSync(root)) return { state: NEVER_MATERIALIZED, missing };
75
+ return { state: WIPED, missing };
76
+ }
77
+
78
+ const isMain = (() => {
79
+ try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
80
+ catch { return false; }
81
+ })();
82
+
83
+ if (isMain) {
84
+ const apply = process.argv.includes('--apply');
85
+ const { state, missing: gone } = classify();
86
+ if (state === OK) {
87
+ console.log(`[restore] every recorded local ingest is present in ${storeRoot()}`);
88
+ process.exit(0);
89
+ }
90
+ // NAMED, never counted. "4 stores missing" is the truncation that reads as completeness.
91
+ if (state === NEVER_MATERIALIZED) {
92
+ console.log(`\n[restore] ${storeRoot()} does not exist on this host — ${gone.length} recorded `
93
+ + `ingest(s) were never materialized here. This is NOT evidence of a wipe (fresh checkout, CI `
94
+ + `runner, or an ephemeral agent container all look like this):`);
95
+ } else {
96
+ console.log(`\n[restore] ${gone.length} recorded ingest(s) are NOT in ${storeRoot()}:`);
97
+ }
98
+ for (const e of gone) console.log(` ${e.name.padEnd(16)} ingested ${String(e.at).slice(0, 10)}`);
99
+ if (!apply) {
100
+ console.log('\n read-only. Re-run with --apply to re-ingest them.\n');
101
+ // NEVER-MATERIALIZED exits 2 (informational: nothing to restore FROM, just re-ingest) so a
102
+ // caller checking the exit code cannot mistake "never synced here" for "the nightly bundle
103
+ // wiped local work" — WIPED keeps exit 1, the real alarm.
104
+ process.exit(state === NEVER_MATERIALIZED ? 2 : 1);
105
+ }
106
+ let failed = 0;
107
+ for (const e of gone) {
108
+ console.log(`\n[restore] re-ingesting ${e.name}`);
109
+ try {
110
+ execFileSync(process.execPath, [path.join(ROOT, 'scripts', 'ingest-repo.mjs'), '--name', e.name],
111
+ { cwd: ROOT, stdio: 'inherit', env: process.env });
112
+ } catch { failed += 1; console.log(`[restore] ${e.name} FAILED — left recorded so the next run retries it`); }
113
+ }
114
+ console.log(`\n[restore] ${gone.length - failed}/${gone.length} restored.\n`);
115
+ process.exit(failed ? 1 : 0);
116
+ }