ruvnet-brain 4.3.21 → 4.3.25

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 (141) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +275 -60
  3. package/console/app.js +141 -9
  4. package/console/index.html +51 -24
  5. package/console/scope.css +137 -0
  6. package/console/scope.html +144 -0
  7. package/console/scope.js +209 -0
  8. package/console/tips.html +1 -0
  9. package/kb/corpus-release-identity.mjs +239 -0
  10. package/kb/update-storage-transaction.mjs +20 -3
  11. package/package.json +9 -2
  12. package/plugin/.claude-plugin/plugin.json +2 -2
  13. package/plugin/.codex-plugin/plugin.json +1 -1
  14. package/plugin/commands/checkpoint.md +61 -0
  15. package/plugin/hooks/codex-hooks.json +64 -1
  16. package/plugin/hooks/hook-contracts.json +299 -6
  17. package/plugin/hooks/hooks.json +81 -1
  18. package/plugin/mcp/server.mjs +23 -0
  19. package/plugin/scripts/advocacy-catalog.mjs +245 -0
  20. package/plugin/scripts/advocacy-route.mjs +460 -0
  21. package/plugin/scripts/continuation-gate.mjs +25 -2
  22. package/plugin/scripts/continuation-objective.mjs +7 -1
  23. package/plugin/scripts/continuity-hook-policy.mjs +190 -15
  24. package/plugin/scripts/coverage-integrity.mjs +7 -0
  25. package/plugin/scripts/gates.mjs +113 -10
  26. package/plugin/scripts/grounding-turn-gate.mjs +167 -0
  27. package/plugin/scripts/grounding-turn-mark.mjs +91 -0
  28. package/plugin/scripts/hook-shim.mjs +14 -0
  29. package/plugin/scripts/nightly-scheduler.mjs +37 -4
  30. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  31. package/plugin/scripts/project-progression-contract.mjs +16 -0
  32. package/plugin/scripts/project-progression-hook.mjs +3 -0
  33. package/plugin/scripts/project-progression-producer.mjs +252 -0
  34. package/plugin/scripts/project-progression-reader.mjs +271 -0
  35. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  36. package/plugin/scripts/project-progression-sources.mjs +220 -0
  37. package/plugin/scripts/project-progression-store.mjs +106 -13
  38. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  39. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  40. package/plugin/scripts/session-start-budget.mjs +59 -0
  41. package/plugin/scripts/session-start-core.mjs +234 -457
  42. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  43. package/plugin/scripts/session-start-health.mjs +64 -0
  44. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  45. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  46. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  47. package/plugin/scripts/session-start-signals.mjs +73 -0
  48. package/plugin/scripts/session-start-trace.mjs +86 -0
  49. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  50. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  51. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  52. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  53. package/scripts/adr-072-completion.mjs +1 -1
  54. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  55. package/scripts/approved-runtime.mjs +197 -0
  56. package/scripts/brain-novice-50.mjs +16 -1
  57. package/scripts/brain-score.mjs +23 -5
  58. package/scripts/build-bundle.mjs +971 -530
  59. package/scripts/build-concepts.mjs +36 -116
  60. package/scripts/console-engine.test.mjs +8 -7
  61. package/scripts/console-runtime-identity.mjs +4 -0
  62. package/scripts/corpus-aggregates.mjs +94 -77
  63. package/scripts/corpus-candidate.mjs +475 -222
  64. package/scripts/corpus-next-seed.mjs +225 -0
  65. package/scripts/corpus-promotion.mjs +58 -0
  66. package/scripts/corpus-reconcile.mjs +411 -105
  67. package/scripts/doc-currency.mjs +16 -1
  68. package/scripts/dual-host-deliberation.mjs +25 -2
  69. package/scripts/dual-host-suggest.mjs +17 -1
  70. package/scripts/falsify.mjs +13 -3
  71. package/scripts/gist-receipts.mjs +482 -87
  72. package/scripts/github-health-watch.mjs +12 -2
  73. package/scripts/handoff-asset.mjs +34 -0
  74. package/scripts/hook-retirement-check.mjs +8 -1
  75. package/scripts/host-registry.mjs +1 -1
  76. package/scripts/ingest-gists.mjs +74 -101
  77. package/scripts/job-heartbeat.sh +77 -14
  78. package/scripts/learning-replay-execution.mjs +10 -4
  79. package/scripts/nightly-gists.sh +27 -13
  80. package/scripts/nightly-two-run-proof.mjs +1 -1
  81. package/scripts/nightly-watchdog.mjs +61 -4
  82. package/scripts/onboarding-console.mjs +319 -27
  83. package/scripts/oracle/produce-questions.mjs +293 -0
  84. package/scripts/oracle/producer-hosts.mjs +235 -0
  85. package/scripts/oracle/repo-recall.mjs +448 -0
  86. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  87. package/scripts/oracle/source-tree.mjs +165 -0
  88. package/scripts/oracle/source-units.mjs +391 -0
  89. package/scripts/oracle/spike-run.mjs +98 -0
  90. package/scripts/oracle/unit-inventory.mjs +141 -0
  91. package/scripts/oracle/unit-sampling.mjs +128 -0
  92. package/scripts/oracle/validate-labels.mjs +250 -0
  93. package/scripts/private-overlay.mjs +248 -0
  94. package/scripts/product-integrity-contract.mjs +1 -1
  95. package/scripts/proxy/claude-proxied.sh +6 -0
  96. package/scripts/proxy/proxy-revert.sh +5 -0
  97. package/scripts/proxy/proxy-up.sh +6 -0
  98. package/scripts/proxy/proxy-verify.mjs +4 -0
  99. package/scripts/public-inputs.mjs +409 -0
  100. package/scripts/public-verification-inputs.mjs +112 -26
  101. package/scripts/public-verification-lane.mjs +1 -1
  102. package/scripts/published-surface-probe.mjs +34 -4
  103. package/scripts/qe/card-lane-gate.mjs +16 -1
  104. package/scripts/qe/session-start-gate.mjs +16 -1
  105. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  106. package/scripts/record-lesson.mjs +4 -1
  107. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  108. package/scripts/release-abort-stale.mjs +5 -1
  109. package/scripts/release-authority.mjs +104 -12
  110. package/scripts/release-channel-kind.mjs +86 -0
  111. package/scripts/release-convergence-watchdog.mjs +7 -2
  112. package/scripts/release-projection.mjs +177 -72
  113. package/scripts/release-transaction-provider.mjs +23 -6
  114. package/scripts/release.mjs +252 -17
  115. package/scripts/retrieval-canary.mjs +87 -0
  116. package/scripts/rvf-index-audit.mjs +573 -13
  117. package/scripts/rvf-wire.mjs +269 -0
  118. package/scripts/seal-gist-receipt.mjs +65 -0
  119. package/scripts/selfcheck.mjs +42 -21
  120. package/scripts/source-coverage.mjs +253 -24
  121. package/scripts/status-honesty.mjs +25 -0
  122. package/scripts/sync-census.mjs +0 -0
  123. package/scripts/sync-version.mjs +2 -0
  124. package/scripts/trismart.mjs +42 -0
  125. package/scripts/updater-manifest.mjs +162 -0
  126. package/scripts/verify-channels.mjs +17 -5
  127. package/scripts/wired-check.mjs +48 -10
  128. package/tri-smart-skill/QUICKSTART.md +37 -0
  129. package/tri-smart-skill/README.md +92 -0
  130. package/tri-smart-skill/install.cmd +14 -0
  131. package/tri-smart-skill/install.command +13 -0
  132. package/tri-smart-skill/install.mjs +51 -0
  133. package/tri-smart-skill/install.sh +9 -0
  134. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  135. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  136. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  137. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  138. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  139. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  140. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  141. package/scripts/corpus-seed-publish.mjs +0 -110
@@ -1,5 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  // Seal and independently verify the exact public corpus bytes used by a release.
3
+ //
4
+ // Schema 3 (2026-09-14, ADR-086 Step 15 / correction A6): the receipt additionally binds the
5
+ // DETACHED retrieval-accuracy report — `accuracyReport {file, sha256, bytes}` — that measured this
6
+ // exact archive through the customer query path. The report stays OUTSIDE the ZIP on purpose:
7
+ // inserting it into an already-measured archive would either change the archive's digest after it
8
+ // was measured or force a second assembly, and ADR-086 permits exactly one. Detached plus
9
+ // digest-bound preserves single assembly and exact-artifact identity at the same time.
10
+ //
11
+ // SCHEMA-2 CONSEQUENCE, stated plainly: a schema-2 seed carries no accuracy binding and is
12
+ // therefore UNPUBLISHABLE from this commit forward — verifyCorpusReceipt, runProtectedCorpusSeed
13
+ // and corpus-seed.yml's committed-seed loader all refuse it. data/corpus-seed.json must be
14
+ // re-pointed at a schema-3 seed in the same change that publishes one (ADR-086 Step 11's job).
15
+ //
16
+ // Schema 2 (2026-09-13): the receipt binds the FULL provenance/input closure that ships inside the
17
+ // sealed archive — ARCHIVE-MANIFEST.json, PRIVATE-STORES.json, RVF-GENERATIONS.json, SOURCE.json,
18
+ // the ruv-gists aggregate receipt, and every derived-store receipt (concepts, etc.) — instead of
19
+ // trusting a separate, unshipped assets/policy directory. Both createCorpusReceipt (writer) and
20
+ // verifyCorpusReceipt (reader) call the SAME deriveCorpusCandidate() so they can never drift from
21
+ // each other, and verification only ever trusts bytes actually extracted from the archive itself —
22
+ // there is no `--assets` escape hatch. The external, content-addressed corpus-sha256-<digest> tag is
23
+ // always derived OUTSIDE the archive from its own bytes; the archive's internal ARCHIVE-MANIFEST.json
24
+ // releaseTag/version is a completely separate identity domain and is never compared against it.
3
25
 
4
26
  import crypto from 'node:crypto';
5
27
  import fs from 'node:fs';
@@ -8,7 +30,10 @@ import path from 'node:path';
8
30
  import { spawnSync } from 'node:child_process';
9
31
  import { fileURLToPath } from 'node:url';
10
32
  import { extractZip } from '../kb/zip-extract.mjs';
11
- import { validatePublicInventory } from './public-inventory.mjs';
33
+ import { canonicalJson, digest, fileIdentity, sha256File, validateGistAggregateReceipt } from '../plugin/scripts/coverage-integrity.mjs';
34
+ import { auditCorpusStores, isPassingState } from './rvf-index-audit.mjs';
35
+ import { readDiagnosticAccuracyReport } from './oracle/retrieval-accuracy.mjs';
36
+ import { loadFixture, readRecallReport } from './oracle/repo-recall.mjs';
12
37
 
13
38
  const HERE = path.dirname(fileURLToPath(import.meta.url));
14
39
  const REQUIRED_SUFFIXES = [
@@ -19,6 +44,9 @@ const REQUIRED_SUFFIXES = [
19
44
  '.meta.json',
20
45
  ];
21
46
  const OPTIONAL_SHIPPED_SUFFIXES = ['.big.passages.jsonl', '.big.meta.json'];
47
+ const HEX64 = /^[a-f0-9]{64}$/;
48
+ const HEX_SOURCE = /^[a-f0-9]{40,64}$/i;
49
+ const HEX_COMMIT = /^[a-f0-9]{7,64}$/i;
22
50
 
23
51
  function fail(message) {
24
52
  throw new Error(`[corpus-candidate] ${message}`);
@@ -26,6 +54,8 @@ function fail(message) {
26
54
 
27
55
  function readJson(file, label) {
28
56
  if (!file || !fs.existsSync(file)) fail(`${label} missing (${file || 'no path supplied'})`);
57
+ const stat = fs.lstatSync(file);
58
+ if (!stat.isFile() || stat.isSymbolicLink()) fail(`${label} is not a trusted regular file`);
29
59
  try {
30
60
  return JSON.parse(fs.readFileSync(file, 'utf8'));
31
61
  } catch (error) {
@@ -33,214 +63,351 @@ function readJson(file, label) {
33
63
  }
34
64
  }
35
65
 
36
- function sha256File(file) {
37
- const hash = crypto.createHash('sha256');
38
- const fd = fs.openSync(file, 'r');
39
- const buffer = Buffer.allocUnsafe(1024 * 1024);
40
- try {
41
- let bytes;
42
- while ((bytes = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) {
43
- hash.update(buffer.subarray(0, bytes));
66
+ function findExactlyOne(root, name) {
67
+ const matches = [];
68
+ const visit = (dir) => {
69
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
70
+ const file = path.join(dir, entry.name);
71
+ if (entry.isSymbolicLink()) fail(`extracted archive contains a symbolic link: ${file}`);
72
+ if (entry.isDirectory()) visit(file);
73
+ else if (entry.isFile() && entry.name === name) matches.push(file);
44
74
  }
45
- } finally {
46
- fs.closeSync(fd);
47
- }
48
- return hash.digest('hex');
75
+ };
76
+ visit(root);
77
+ if (matches.length !== 1) fail(`archive must contain exactly one ${name}; found ${matches.length}`);
78
+ return matches[0];
49
79
  }
50
80
 
51
- function fileIdentity(file) {
52
- return { file: path.basename(file), sha256: sha256File(file), bytes: fs.statSync(file).size };
81
+ function filesUnder(root, prefix = '') {
82
+ const rows = [];
83
+ for (const entry of fs.readdirSync(path.join(root, prefix), { withFileTypes: true })
84
+ .sort((left, right) => left.name.localeCompare(right.name))) {
85
+ const relative = prefix ? path.join(prefix, entry.name) : entry.name;
86
+ const file = path.join(root, relative);
87
+ if (entry.isSymbolicLink()) fail(`archive tree contains a symbolic link: ${relative}`);
88
+ if (entry.isDirectory()) rows.push(...filesUnder(root, relative));
89
+ else if (entry.isFile()) rows.push({ path: relative.split(path.sep).join('/'), sha256: sha256File(file), bytes: fs.statSync(file).size });
90
+ else fail(`archive tree contains an unsupported entry: ${relative}`);
91
+ }
92
+ return rows;
53
93
  }
54
94
 
55
- function canonicalJson(value) {
56
- if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
57
- if (value && typeof value === 'object') {
58
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
95
+ function containedFile(root, relative, label) {
96
+ if (typeof relative !== 'string' || !relative || path.isAbsolute(relative) || relative.split(/[\\/]/).includes('..')) {
97
+ fail(`${label} path escapes the archive root`);
59
98
  }
60
- return JSON.stringify(value);
99
+ const resolved = path.resolve(root, relative);
100
+ const within = path.relative(root, resolved);
101
+ if (!within || within.startsWith('..') || path.isAbsolute(within)) fail(`${label} path escapes the archive root`);
102
+ if (!fs.existsSync(resolved)) fail(`${label} missing (${relative})`);
103
+ const stat = fs.lstatSync(resolved);
104
+ if (!stat.isFile() || stat.isSymbolicLink()) fail(`${label} is not a trusted regular file`);
105
+ return resolved;
61
106
  }
62
107
 
63
- function policyRows(policy) {
64
- if (Array.isArray(policy)) return policy;
65
- for (const key of ['sources', 'rows', 'repositories', 'repos']) {
66
- if (Array.isArray(policy?.[key])) return policy[key];
108
+ function normalizeBootstrapIdentity(bootstrapIdentity) {
109
+ if (bootstrapIdentity === null || bootstrapIdentity === undefined) return null;
110
+ const { tag, sha256, privateFenceEvidence = null } = bootstrapIdentity;
111
+ if (typeof tag !== 'string' || !tag) fail('bootstrap identity tag is missing');
112
+ if (!HEX64.test(String(sha256 || '').toLowerCase())) fail('bootstrap identity sha256 is missing or malformed');
113
+ if (privateFenceEvidence !== null) {
114
+ if (typeof privateFenceEvidence.file !== 'string' || !privateFenceEvidence.file
115
+ || !HEX64.test(String(privateFenceEvidence.sha256 || '')) || !Number.isSafeInteger(privateFenceEvidence.bytes)) {
116
+ fail('bootstrap private-fence evidence is malformed');
117
+ }
67
118
  }
68
- return [];
119
+ return { tag, sha256: sha256.toLowerCase(), privateFenceEvidence };
69
120
  }
70
121
 
71
- function assertPolicyCurrent(policy) {
72
- const rows = policyRows(policy);
73
- const eligible = (row) => row?.eligible !== false && (!row?.disposition || row.disposition === 'eligible');
74
- const stale = rows.filter((row) => eligible(row) && row?.status !== 'CURRENT');
75
- if (stale.length) fail(`eligibility policy has ${stale.length} eligible row(s) that are not CURRENT`);
76
- return {
77
- eligibleRepoCount: rows.filter((row) => eligible(row) && row?.kind === 'repository' && row?.status === 'CURRENT').length,
78
- gistCount: rows.filter((row) => eligible(row) && /gist/i.test(String(row?.kind || row?.type || ''))).length,
79
- };
80
- }
122
+ // The single derivation shared by the writer (createCorpusReceipt) and the reader
123
+ // (verifyCorpusReceipt): every field is either re-derived directly from bytes actually extracted
124
+ // from bundleFile, or is one of the three inputs that are not archive-derived (builderSourceSha,
125
+ // bootstrapIdentity, createdAt) — verification supplies those from the receipt being checked, so a
126
+ // receipt can never claim archive contents that were not really shipped.
127
+ async function deriveCorpusCandidate({ bundleFile, builderSourceSha, bootstrapIdentity, createdAt, accuracyReportFile, recallReportFile }) {
128
+ const bundle = path.resolve(bundleFile || '');
129
+ if (!fs.existsSync(bundle) || !fs.statSync(bundle).isFile()) fail(`bundle missing (${bundle || 'no path supplied'})`);
130
+ if (!HEX_SOURCE.test(builderSourceSha || '')) fail('builderSourceSha must be a 40-64 hex source identity');
131
+ const bootstrap = normalizeBootstrapIdentity(bootstrapIdentity);
132
+ const archive = fileIdentity(bundle);
133
+
134
+ // THE BLOCKING RETRIEVAL GATE (ADR-086 amendment, 2026-09-15). Both reports travel BESIDE the
135
+ // archive under fixed derived names, so any reader holding nothing but a downloaded release asset
136
+ // resolves the same files without the receipt carrying a path.
137
+ //
138
+ // What blocks: the frozen-fixture repo-recall gate. Every one of the 194 pre-existing human
139
+ // questions must complete without error, every repository must return content of its own, and the
140
+ // exact-file Hit@5 count may never fall below the accepted floor.
141
+ //
142
+ // What NO LONGER blocks: ADR-086's original C3 predicate. Measured against the real 4.3.25 archive
143
+ // it returns 680/1152 = 59.0% (diagnostic, c3Eligible:false) after both of its candidate
144
+ // explanations were tested and disproved. Holding publication on it shipped nothing. That swap is
145
+ // a DECLARED REDUCTION in release requirements, not a demonstration that C3 passed — so the C3
146
+ // report is still required to exist and still bound to these exact archive bytes, and is published
147
+ // alongside rather than quietly dropped.
148
+ const recall = readRecallReport({
149
+ reportFile: recallReportFile || `${bundle}.recall.json`,
150
+ archive,
151
+ expectedFixtureSha256: loadFixture().fixtureSha256,
152
+ });
153
+ const accuracyDiagnostic = readDiagnosticAccuracyReport({
154
+ reportFile: accuracyReportFile || `${bundle}.accuracy.json`,
155
+ archive,
156
+ });
81
157
 
82
- async function verifyArchive({ bundleFile, stores, privateStores }) {
83
- if (!bundleFile || !fs.existsSync(bundleFile)) fail(`bundle missing (${bundleFile || 'no path supplied'})`);
84
158
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'corpus-candidate-'));
85
159
  try {
86
- let extraction;
87
160
  try {
88
- extraction = await extractZip(bundleFile, tmp);
161
+ await extractZip(bundle, tmp);
89
162
  } catch (error) {
90
163
  fail(`cannot extract archive (${error.message})`);
91
164
  }
92
- const entries = extraction.entryNames;
93
- for (const privateStore of privateStores) {
94
- const escaped = privateStore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
95
- const privateFile = new RegExp(`(?:^|/)${escaped}(?:\\.|$)`, 'i');
96
- if (entries.some((entry) => privateFile.test(entry.replaceAll('\\', '/')))) {
97
- fail(`private store ${privateStore} is present in archive`);
98
- }
165
+
166
+ const manifestFile = findExactlyOne(tmp, 'ARCHIVE-MANIFEST.json');
167
+ const root = path.dirname(manifestFile);
168
+ const manifest = readJson(manifestFile, 'archive manifest');
169
+ if (manifest.schemaVersion !== 1 || manifest.kind !== 'ruvnet-brain-archive-manifest'
170
+ || typeof manifest.version !== 'string' || !manifest.version
171
+ || typeof manifest.releaseTag !== 'string' || !manifest.releaseTag
172
+ || !Array.isArray(manifest.files)) fail('archive manifest schema is invalid');
173
+
174
+ const actualFiles = filesUnder(root).filter((row) => row.path !== 'ARCHIVE-MANIFEST.json');
175
+ const byPath = (left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
176
+ if (canonicalJson([...actualFiles].sort(byPath)) !== canonicalJson([...manifest.files].sort(byPath))) {
177
+ fail('archive file set or bytes differ from ARCHIVE-MANIFEST.json');
178
+ }
179
+ if (manifest.fileCount !== actualFiles.length
180
+ || manifest.totalBytes !== actualFiles.reduce((total, row) => total + row.bytes, 0)) {
181
+ fail('archive manifest totals differ from its file rows');
99
182
  }
100
- for (const store of stores) {
101
- for (const expected of store.files) {
102
- const matches = entries.filter((entry) => path.posix.basename(entry.replaceAll('\\', '/')) === expected.file);
103
- if (matches.length !== 1) fail(`archive must contain exactly one ${expected.file}; found ${matches.length}`);
104
- const extracted = path.join(tmp, ...matches[0].replaceAll('\\', '/').split('/'));
105
- if (sha256File(extracted) !== expected.sha256) fail(`archive ${expected.file} differs from canonical assets`);
183
+
184
+ const privateFenceFile = path.join(root, 'PRIVATE-STORES.json');
185
+ const fence = readJson(privateFenceFile, 'private fence');
186
+ if (!Array.isArray(fence.privateStores)) fail('private fence has no privateStores array');
187
+ const privateStores = [...new Set(fence.privateStores.map((name) => String(name).toLowerCase()))].sort();
188
+ const privateSet = new Set(privateStores);
189
+
190
+ const ledgerFile = path.join(root, 'RVF-GENERATIONS.json');
191
+ const ledger = readJson(ledgerFile, 'RVF generation ledger');
192
+ if (!ledger.stores || typeof ledger.stores !== 'object' || Array.isArray(ledger.stores)) {
193
+ fail('RVF generation ledger has no stores object');
194
+ }
195
+
196
+ const sourceFile = path.join(root, 'SOURCE.json');
197
+ const source = readJson(sourceFile, 'source manifest');
198
+ if (!source.stores || typeof source.stores !== 'object' || Array.isArray(source.stores)) {
199
+ fail('source manifest has no stores object');
200
+ }
201
+
202
+ const rvfFiles = fs.readdirSync(root).filter((name) => /^.+\.big\.rvf$/.test(name)).sort();
203
+ const rvfByStore = new Map(rvfFiles.map((file) => [file.slice(0, -'.big.rvf'.length), file]));
204
+ const foldedStores = [...rvfByStore.keys()].map((store) => store.toLowerCase());
205
+ if (new Set(foldedStores).size !== foldedStores.length) fail('archive RVF store names have case-fold aliases');
206
+
207
+ const leaked = [...rvfByStore.keys()].filter((store) => privateSet.has(store.toLowerCase()));
208
+ if (leaked.length) fail(`private store(s) present in archive: ${leaked.join(', ')}`);
209
+
210
+ const unreceipted = [...rvfByStore.keys()].filter((store) => !ledger.stores[store]);
211
+ if (unreceipted.length) fail(`unreceipted RVF files: ${unreceipted.join(', ')}`);
212
+ const orphanLedger = Object.entries(ledger.stores)
213
+ .filter(([store, generation]) => generation.file !== `${store}.big.rvf` || !rvfByStore.has(store))
214
+ .map(([store]) => store);
215
+ if (orphanLedger.length) fail(`ledger rows without RVFs: ${orphanLedger.join(', ')}`);
216
+
217
+ const digestOwners = new Map();
218
+ for (const [store, file] of rvfByStore) {
219
+ const rvfDigest = sha256File(path.join(root, file));
220
+ const owners = digestOwners.get(rvfDigest) || [];
221
+ owners.push(store);
222
+ digestOwners.set(rvfDigest, owners);
223
+ }
224
+ const duplicateRvfDigests = [...digestOwners.entries()]
225
+ .filter(([, owners]) => owners.length > 1)
226
+ .map(([sha256, owners]) => ({ sha256, stores: owners.sort() }));
227
+ if (duplicateRvfDigests.length) fail(`duplicate RVF bytes: ${duplicateRvfDigests.map((row) => row.stores.join('/')).join(', ')}`);
228
+
229
+ // Derived-store receipts (concepts, etc.) — the "concepts inputs" closure. Optional: a fresh
230
+ // bootstrap round may not have built any derived store yet.
231
+ const derivedStores = [];
232
+ const derivedNames = new Set();
233
+ const classesFile = path.join(root, 'public-store-classes.json');
234
+ if (fs.existsSync(classesFile)) {
235
+ const classes = readJson(classesFile, 'public store classes');
236
+ if (classes.schemaVersion !== 1 || !Array.isArray(classes.derived)) fail('derived store classes are malformed');
237
+ for (const entry of classes.derived) {
238
+ const store = String(entry?.store || '').toLowerCase();
239
+ if (!store) fail('derived store class entry is missing a store name');
240
+ derivedNames.add(store);
241
+ const receiptFile = containedFile(root, entry.receipt, `derived ${store} receipt`);
242
+ const dReceipt = readJson(receiptFile, `derived ${store} receipt`);
243
+ const passagesFile = containedFile(root, `${store}.passages.jsonl`, `derived ${store} passages`);
244
+ if (dReceipt.schemaVersion !== 1 || dReceipt.kind !== 'ruvnet-brain-derived-store-receipt'
245
+ || String(dReceipt.store || '').toLowerCase() !== store || !Array.isArray(dReceipt.inputs) || !dReceipt.inputs.length
246
+ || !HEX64.test(String(dReceipt.passagesSha256 || '')) || dReceipt.passagesSha256 !== sha256File(passagesFile)) {
247
+ fail(`derived ${store} receipt does not bind its passage bytes`);
248
+ }
249
+ for (const input of dReceipt.inputs) {
250
+ const inputFile = containedFile(root, input?.path, `derived ${store} input`);
251
+ if (!HEX64.test(String(input.sha256 || '')) || input.sha256 !== sha256File(inputFile)) {
252
+ fail(`derived ${store} input receipt differs from ${input?.path || '(missing)'}`);
253
+ }
254
+ }
255
+ derivedStores.push({ store, receipt: fileIdentity(receiptFile) });
106
256
  }
107
257
  }
108
- const selectedStores = new Map(stores.map(({ name }) => [name.toLowerCase(), name]));
109
- const storeSuffixes = [...REQUIRED_SUFFIXES, ...OPTIONAL_SHIPPED_SUFFIXES]
110
- .sort((left, right) => right.length - left.length);
111
- const archiveFamilies = entries.map((entry) => path.posix.basename(entry.replaceAll('\\', '/')))
112
- .map((name) => {
113
- const suffix = storeSuffixes.find((candidate) => name.toLowerCase().endsWith(candidate));
114
- return suffix ? { name, store: name.slice(0, -suffix.length) } : null;
115
- }).filter(Boolean);
116
- const unclassified = [...new Set(archiveFamilies.filter(({ store }) => !selectedStores.has(store.toLowerCase()))
117
- .map(({ store }) => store))].sort();
118
- if (unclassified.length) fail(`unclassified archive store(s): ${unclassified.join(', ')}`);
119
- const aliases = archiveFamilies.filter(({ store }) => selectedStores.get(store.toLowerCase()) !== store)
120
- .map(({ name }) => name).sort();
121
- if (aliases.length) fail(`archive store-family alias differs by case: ${aliases.join(', ')}`);
122
- } finally {
123
- fs.rmSync(tmp, { recursive: true, force: true });
124
- }
125
- return fileIdentity(bundleFile);
126
- }
127
258
 
128
- async function buildReceipt({
129
- assetsDir,
130
- bundleFile,
131
- policyFile,
132
- builderSourceSha,
133
- createdAt,
134
- }) {
135
- const assets = path.resolve(assetsDir || '');
136
- if (!fs.existsSync(assets) || !fs.statSync(assets).isDirectory()) fail(`assets directory missing (${assets})`);
137
- if (!/^[a-f0-9]{40,64}$/i.test(builderSourceSha || '')) fail('builderSourceSha must be a 40-64 hex source identity');
138
-
139
- const fenceFile = path.join(assets, 'PRIVATE-STORES.json');
140
- const fence = readJson(fenceFile, 'private fence');
141
- if (!Array.isArray(fence.privateStores)) fail('private fence has no privateStores array');
142
- const privateStores = [...new Set(fence.privateStores.map((name) => String(name).toLowerCase()))].sort();
143
- const privateSet = new Set(privateStores);
144
- const ledgerFile = path.join(assets, 'RVF-GENERATIONS.json');
145
- const ledger = readJson(ledgerFile, 'RVF generation ledger');
146
- if (!ledger.stores || typeof ledger.stores !== 'object' || Array.isArray(ledger.stores)) {
147
- fail('RVF generation ledger has no stores object');
148
- }
149
- const policy = readJson(path.resolve(policyFile || ''), 'eligibility policy');
150
-
151
- const rvfFiles = fs.readdirSync(assets).filter((name) => /^.+\.big\.rvf$/.test(name)).sort();
152
- const rvfByStore = new Map(rvfFiles.map((file) => [file.slice(0, -'.big.rvf'.length), file]));
153
- const unreceipted = [...rvfByStore.keys()].filter((store) => !ledger.stores[store]);
154
- if (unreceipted.length) fail(`unreceipted RVF files: ${unreceipted.join(', ')}`);
155
- const orphanLedger = Object.entries(ledger.stores)
156
- .filter(([store, generation]) => generation.file !== `${store}.big.rvf` || !rvfByStore.has(store))
157
- .map(([store]) => store);
158
- if (orphanLedger.length) fail(`ledger rows without RVFs: ${orphanLedger.join(', ')}`);
159
-
160
- const digestOwners = new Map();
161
- for (const [store, file] of rvfByStore) {
162
- const digest = sha256File(path.join(assets, file));
163
- const owners = digestOwners.get(digest) || [];
164
- owners.push(store);
165
- digestOwners.set(digest, owners);
166
- }
167
- const duplicateRvfDigests = [...digestOwners.entries()]
168
- .filter(([, owners]) => owners.length > 1)
169
- .map(([sha256, owners]) => ({ sha256, stores: owners.sort() }));
170
- if (duplicateRvfDigests.length) fail(`duplicate RVF bytes: ${duplicateRvfDigests.map((row) => row.stores.join('/')).join(', ')}`);
171
- const publicInventory = validatePublicInventory({ assetsDir: assets, coverage: policy, ledger });
172
- const publicSet = new Set(publicInventory.publicStores);
173
- const counts = assertPolicyCurrent(policy);
174
-
175
- const missingSidecars = [];
176
- const stores = [];
177
- for (const [store, rvfFile] of [...rvfByStore].sort(([a], [b]) => a.localeCompare(b))) {
178
- const generation = ledger.stores[store];
179
- const rvfPath = path.join(assets, rvfFile);
180
- if (generation.file !== rvfFile || generation.sha256 !== sha256File(rvfPath) || generation.bytes !== fs.statSync(rvfPath).size) {
181
- fail(`${store}: generation receipt does not match RVF bytes`);
259
+ // The ruv-gists aggregate — validated with the SAME reader every other consumer of this
260
+ // receipt schema uses (plugin/scripts/coverage-integrity.mjs), so the "individual source
261
+ // receipt digest, not the aggregate observation digest" contract lives in one place.
262
+ let gistAggregate = null;
263
+ if (rvfByStore.has('ruv-gists')) {
264
+ const gistReceiptFile = path.join(root, 'ruv-gists.sources.json');
265
+ const gistPassagesFile = path.join(root, 'ruv-gists.passages.jsonl');
266
+ if (!fs.existsSync(gistReceiptFile)) fail('ruv-gists store is present without ruv-gists.sources.json');
267
+ const gistReceipt = readJson(gistReceiptFile, 'gist aggregate receipt');
268
+ validateGistAggregateReceipt({ receipt: gistReceipt, passagesFile: gistPassagesFile, expectedIds: null });
269
+ gistAggregate = { name: 'ruv-gists', receipt: fileIdentity(gistReceiptFile) };
182
270
  }
183
- if (!generation.sourceCommit || !generation.builtUtc || !generation.model || !Number.isInteger(generation.dimensions)) {
184
- fail(`${store}: generation receipt lacks sourceCommit, builtUtc, model, or dimensions provenance`);
271
+
272
+ const missingSidecars = [];
273
+ const stores = [];
274
+ for (const [store, rvfFile] of [...rvfByStore].sort(([a], [b]) => a.localeCompare(b))) {
275
+ const generation = ledger.stores[store];
276
+ const rvfPath = path.join(root, rvfFile);
277
+ if (generation.file !== rvfFile || generation.sha256 !== sha256File(rvfPath) || generation.bytes !== fs.statSync(rvfPath).size) {
278
+ fail(`${store}: generation receipt does not match RVF bytes`);
279
+ }
280
+ if (typeof generation.builtUtc !== 'string' || !Number.isFinite(Date.parse(generation.builtUtc))
281
+ || typeof generation.model !== 'string' || !generation.model || !Number.isInteger(generation.dimensions)
282
+ || !(generation.sourceCommit === null || HEX_COMMIT.test(String(generation.sourceCommit || '')))) {
283
+ fail(`${store}: generation receipt lacks builtUtc, model, dimensions, or sourceCommit provenance`);
284
+ }
285
+ const folded = store.toLowerCase();
286
+ const kind = folded === 'ruv-gists' ? 'gist-aggregate' : derivedNames.has(folded) ? 'derived' : 'repository';
287
+ if (kind === 'repository') {
288
+ // The semantic cross-check: a repository store's ledger sourceCommit (the SELECTED
289
+ // generation) must equal the exact upstream SHA the worker that built it actually cloned
290
+ // and checked out (SOURCE.json) — never merely trusted from the ledger alone.
291
+ const sourceRow = source.stores[store];
292
+ const sourceCommit = String(sourceRow?.sourceCommit || '').toLowerCase();
293
+ const ledgerCommit = String(generation.sourceCommit || '').toLowerCase();
294
+ if (!sourceRow || !sourceCommit || sourceCommit !== ledgerCommit) {
295
+ fail(`${store}: SOURCE manifest sourceCommit does not equal the selected generation sourceCommit`);
296
+ }
297
+ }
298
+ const files = [];
299
+ for (const suffix of REQUIRED_SUFFIXES) {
300
+ const file = path.join(root, `${store}${suffix}`);
301
+ if (!fs.existsSync(file)) missingSidecars.push(path.basename(file));
302
+ else files.push(fileIdentity(file));
303
+ }
304
+ for (const suffix of OPTIONAL_SHIPPED_SUFFIXES) {
305
+ const file = path.join(root, `${store}${suffix}`);
306
+ if (fs.existsSync(file)) files.push(fileIdentity(file));
307
+ }
308
+ stores.push({
309
+ name: store,
310
+ kind,
311
+ sourceCommit: generation.sourceCommit ?? null,
312
+ builtUtc: generation.builtUtc,
313
+ model: generation.model,
314
+ dimensions: generation.dimensions,
315
+ files,
316
+ });
185
317
  }
186
- if (privateSet.has(store.toLowerCase()) || !publicSet.has(store.toLowerCase())) continue;
187
- const files = [];
188
- for (const suffix of REQUIRED_SUFFIXES) {
189
- const file = path.join(assets, `${store}${suffix}`);
190
- if (!fs.existsSync(file)) missingSidecars.push(path.basename(file));
191
- else files.push(fileIdentity(file));
318
+ if (missingSidecars.length) fail(`missing sidecars: ${missingSidecars.join(', ')}`);
319
+ if (!stores.length) fail('zero public corpus stores in the archive');
320
+
321
+ // C2 gate (scripts/rvf-index-audit.mjs auditCorpusStores): every shipped RVF is reopened read-only
322
+ // and must (1) carry a hash-verified persisted INDEX_SEG when it is large enough to need one,
323
+ // (2) answer persisted k-NN queries with measured recall@10 >= 0.95 against exact neighbours
324
+ // brute-forced from its own stored vectors, and (3) have an id map, passage rows and source
325
+ // mapping that correspond one-to-one. Segment presence alone is not accepted; stores under the
326
+ // engine's 1,024-vector HNSW threshold are reported as PASS-SMALL-STORE, never as PASS. The full
327
+ // measured result is written next to the archive as <bundle>.rvf-audit.json — NOT into this
328
+ // receipt: the receipt is schemaVersion 2 and any field addition is Step 15's coordinated bump.
329
+ let rvfAudit;
330
+ try {
331
+ rvfAudit = await auditCorpusStores({
332
+ dir: root,
333
+ rvfPaths: [...rvfByStore.values()].map((file) => path.join(root, file)),
334
+ privateStores: privateSet,
335
+ });
336
+ } catch (error) {
337
+ fail(`RVF index audit could not run (${error.message})`);
192
338
  }
193
- for (const suffix of OPTIONAL_SHIPPED_SUFFIXES) {
194
- const file = path.join(assets, `${store}${suffix}`);
195
- if (fs.existsSync(file)) files.push(fileIdentity(file));
339
+ fs.writeFileSync(`${bundle}.rvf-audit.json`, `${JSON.stringify(rvfAudit, null, 2)}\n`);
340
+ const indexFailures = rvfAudit.stores.filter(({ state }) => !isPassingState(state));
341
+ if (indexFailures.length || rvfAudit.privateExclusion.leaks.length) {
342
+ fail(`RVF index audit failed: ${indexFailures
343
+ .map((row) => `${path.basename(row.path)} (vectors=${row.totalVectors ?? '?'}, recall@${row.recall?.kEffective ?? '?'}=${row.recall?.recallAtK ?? '?'}: ${[...new Set(row.failures.map((failure) => failure.kind))].join('+')})`)
344
+ .concat(rvfAudit.privateExclusion.leaks.map((name) => `${name} (private store present)`))
345
+ .join(', ')}`);
196
346
  }
197
- stores.push({
198
- name: store,
199
- sourceCommit: generation.sourceCommit,
200
- builtUtc: generation.builtUtc,
201
- model: generation.model,
202
- dimensions: generation.dimensions,
203
- files,
347
+
348
+ const finalByteFiles = stores.flatMap((store) => store.files.map((file) => ({ store: store.name, ...file })))
349
+ .sort((a, b) => a.file.localeCompare(b.file) || a.store.localeCompare(b.store));
350
+ const finalBytePartitionSha256 = digest({
351
+ archiveManifestSha256: digest(manifest),
352
+ privateFenceSha256: sha256File(privateFenceFile),
353
+ sourceManifestSha256: sha256File(sourceFile),
354
+ gistAggregate,
355
+ derivedStores,
356
+ files: finalByteFiles,
204
357
  });
358
+
359
+ return {
360
+ schemaVersion: 3,
361
+ kind: 'ruvnet-brain-corpus-candidate',
362
+ builderSourceSha: builderSourceSha.toLowerCase(),
363
+ createdAt,
364
+ archive,
365
+ archiveManifest: fileIdentity(manifestFile),
366
+ archiveManifestVersion: manifest.version,
367
+ archiveManifestReleaseTag: manifest.releaseTag,
368
+ fileCount: manifest.fileCount,
369
+ totalBytes: manifest.totalBytes,
370
+ privateFence: fileIdentity(privateFenceFile),
371
+ generationLedger: fileIdentity(ledgerFile),
372
+ sourceManifest: fileIdentity(sourceFile),
373
+ gistAggregate,
374
+ derivedStores,
375
+ storeCount: stores.length,
376
+ stores,
377
+ excludedPrivateStores: privateStores,
378
+ duplicateRvfDigests: [],
379
+ unreceiptedRvfFiles: [],
380
+ missingSidecars: [],
381
+ bootstrap,
382
+ finalBytePartitionSha256,
383
+ // A6, verbatim: "bump the corpus receipt to schemaVersion 3 with accuracyReport
384
+ // {file, sha256, bytes}". Exactly those three fields — the oracle and generator identities
385
+ // the publication gate needs are reached THROUGH this digest, by re-reading the bound report.
386
+ // The field keeps its name and shape so existing seed readers are unaffected; what changed is
387
+ // that this report is now a published DIAGNOSTIC, and `recallReport` is the blocking one.
388
+ accuracyReport: accuracyDiagnostic.identity,
389
+ accuracyDiagnostic: {
390
+ state: accuracyDiagnostic.state,
391
+ classification: accuracyDiagnostic.classification,
392
+ c3Eligible: accuracyDiagnostic.c3Eligible,
393
+ blocking: false,
394
+ note: 'ADR-086 C3 was NOT met and is NOT demonstrated by this release. Published for inspection.',
395
+ },
396
+ recallReport: recall.identity,
397
+ recallSummary: {
398
+ fixtureSha256: recall.report.fixture.sha256,
399
+ questions: recall.report.totals.questions,
400
+ repositoriesAnswering: recall.report.totals.repoCoverage,
401
+ exactFileTop1: recall.report.totals.hitTop1,
402
+ exactFileTop5: recall.report.totals.hitTop5,
403
+ floor: recall.report.floor.value,
404
+ blocking: true,
405
+ },
406
+ generator: { corpusCandidateSha256: sha256File(fileURLToPath(import.meta.url)) },
407
+ };
408
+ } finally {
409
+ fs.rmSync(tmp, { recursive: true, force: true });
205
410
  }
206
- if (missingSidecars.length) fail(`missing sidecars: ${missingSidecars.join(', ')}`);
207
- if (!stores.length) fail('zero public corpus stores remain after the private fence');
208
- const finalByteFiles = stores.flatMap((store) => store.files.map((file) => ({ store: store.name, ...file })))
209
- .sort((a, b) => a.file.localeCompare(b.file) || a.store.localeCompare(b.store));
210
- const finalBytePartitionSha256 = crypto.createHash('sha256').update(canonicalJson({
211
- publicInventoryPartitionSha256: publicInventory.partitionSha256,
212
- publicStores: publicInventory.publicStores,
213
- files: finalByteFiles,
214
- })).digest('hex');
215
- const archive = await verifyArchive({ bundleFile: path.resolve(bundleFile || ''), stores, privateStores });
216
- const policyIdentity = fileIdentity(path.resolve(policyFile));
217
- const fenceIdentity = fileIdentity(fenceFile);
218
- const ledgerIdentity = fileIdentity(ledgerFile);
219
- const coverageGeneration = String(policy.coverageGeneration || policy.generation || policy.generated || policyIdentity.sha256);
220
-
221
- return {
222
- schemaVersion: 1,
223
- kind: 'ruvnet-brain-corpus-candidate',
224
- builderSourceSha: builderSourceSha.toLowerCase(),
225
- createdAt,
226
- coverageGeneration,
227
- eligibleRepoCount: counts.eligibleRepoCount,
228
- gistCount: counts.gistCount,
229
- storeCount: stores.length,
230
- stores,
231
- privateFence: fenceIdentity,
232
- eligibilityPolicy: policyIdentity,
233
- generationLedger: ledgerIdentity,
234
- publicInventory,
235
- finalBytePartitionSha256,
236
- excludedPrivateStores: privateStores.filter((privateStore) =>
237
- [...rvfByStore.keys()].some((store) => store.toLowerCase() === privateStore)),
238
- duplicateRvfDigests,
239
- unreceiptedRvfFiles: [],
240
- missingSidecars: [],
241
- archive,
242
- generator: { corpusCandidateSha256: sha256File(fileURLToPath(import.meta.url)) },
243
- };
244
411
  }
245
412
 
246
413
  function currentGitSha() {
@@ -249,48 +416,94 @@ function currentGitSha() {
249
416
  return result.stdout.trim();
250
417
  }
251
418
 
252
- export async function createCorpusReceipt(options) {
253
- const receiptFile = path.resolve(options.receiptFile || 'dist/corpus-receipt.json');
254
- const receipt = await buildReceipt({
255
- ...options,
256
- builderSourceSha: options.builderSourceSha || currentGitSha(),
257
- createdAt: options.createdAt || new Date().toISOString(),
419
+ export async function createCorpusReceipt({
420
+ bundleFile, builderSourceSha, bootstrapIdentity = null, receiptFile, createdAt, accuracyReportFile = null, recallReportFile = null,
421
+ } = {}) {
422
+ const resolvedReceiptFile = path.resolve(receiptFile || 'dist/corpus-receipt.json');
423
+ const receipt = await deriveCorpusCandidate({
424
+ bundleFile,
425
+ builderSourceSha: builderSourceSha || currentGitSha(),
426
+ bootstrapIdentity,
427
+ createdAt: createdAt || new Date().toISOString(),
428
+ accuracyReportFile,
429
+ recallReportFile,
258
430
  });
259
- fs.mkdirSync(path.dirname(receiptFile), { recursive: true });
260
- fs.writeFileSync(receiptFile, `${JSON.stringify(receipt, null, 2)}\n`, { flag: options.overwrite === false ? 'wx' : 'w' });
431
+ fs.mkdirSync(path.dirname(resolvedReceiptFile), { recursive: true });
432
+ fs.writeFileSync(resolvedReceiptFile, `${JSON.stringify(receipt, null, 2)}\n`);
261
433
  return receipt;
262
434
  }
263
435
 
264
- export async function verifyCorpusReceipt(options) {
265
- const receipt = readJson(path.resolve(options.receiptFile || ''), 'corpus receipt');
266
- if (receipt.kind !== 'ruvnet-brain-corpus-candidate' || receipt.schemaVersion !== 1) fail('unsupported corpus receipt');
267
- const exactFiles = [
268
- ['archive', path.resolve(options.bundleFile || '')],
269
- ['eligibility policy', path.resolve(options.policyFile || '')],
270
- ['private fence', path.join(path.resolve(options.assetsDir || ''), 'PRIVATE-STORES.json')],
271
- ['generation ledger', path.join(path.resolve(options.assetsDir || ''), 'RVF-GENERATIONS.json')],
272
- ];
273
- for (const [label, file] of exactFiles) {
274
- if (!fs.existsSync(file)) fail(`${label} missing (${file})`);
275
- const expected = label === 'archive'
276
- ? receipt.archive
277
- : label === 'eligibility policy'
278
- ? receipt.eligibilityPolicy
279
- : label === 'private fence'
280
- ? receipt.privateFence
281
- : receipt.generationLedger;
282
- if (sha256File(file) !== expected?.sha256) fail(`${label} sha256 differs from receipt`);
283
- if (fs.statSync(file).size !== expected?.bytes) fail(`${label} byte length differs from receipt`);
436
+ export async function verifyCorpusReceipt({
437
+ bundleFile, receiptFile, expectedBuilderSha, expectedArchiveSha256, expectedReceiptSha256, accuracyReportFile = null, recallReportFile = null,
438
+ } = {}) {
439
+ const resolvedReceiptFile = path.resolve(receiptFile || '');
440
+ const resolvedBundleFile = path.resolve(bundleFile || '');
441
+ const receipt = readJson(resolvedReceiptFile, 'corpus receipt');
442
+ // Schema 3 only. A schema-2 receipt has no accuracyReport binding, so accepting one here would
443
+ // silently publish a corpus that was never measured — the exact hole A6 closes.
444
+ if (receipt.schemaVersion !== 3 || receipt.kind !== 'ruvnet-brain-corpus-candidate') {
445
+ fail('unsupported corpus receipt (schema downgrade or wrong kind)');
446
+ }
447
+ if (expectedReceiptSha256 != null && sha256File(resolvedReceiptFile) !== expectedReceiptSha256) {
448
+ fail('receipt file bytes differ from the expected receipt sha256');
284
449
  }
285
- const actual = await buildReceipt({
286
- ...options,
450
+ if (expectedBuilderSha != null && receipt.builderSourceSha !== String(expectedBuilderSha).toLowerCase()) {
451
+ fail('receipt builderSourceSha differs from the expected builder SHA');
452
+ }
453
+ if (!fs.existsSync(resolvedBundleFile)) fail(`bundle missing (${resolvedBundleFile})`);
454
+ const actualArchiveSha256 = sha256File(resolvedBundleFile);
455
+ if (actualArchiveSha256 !== receipt.archive?.sha256 || fs.statSync(resolvedBundleFile).size !== receipt.archive?.bytes) {
456
+ fail('archive sha256 or byte length differs from the corpus receipt');
457
+ }
458
+ if (expectedArchiveSha256 != null && actualArchiveSha256 !== expectedArchiveSha256) {
459
+ fail('archive sha256 differs from the expected archive sha256');
460
+ }
461
+ const derived = await deriveCorpusCandidate({
462
+ bundleFile: resolvedBundleFile,
287
463
  builderSourceSha: receipt.builderSourceSha,
464
+ bootstrapIdentity: receipt.bootstrap,
288
465
  createdAt: receipt.createdAt,
466
+ accuracyReportFile,
467
+ recallReportFile,
289
468
  });
290
- if (canonicalJson(actual) !== canonicalJson(receipt)) fail('receipt does not match the exact corpus inputs');
469
+ if (canonicalJson(derived) !== canonicalJson(receipt)) fail('receipt does not match the exact corpus archive contents');
291
470
  return receipt;
292
471
  }
293
472
 
473
+ // Verify a downloaded seed archive against its EXTERNAL, content-addressed descriptor
474
+ // (corpus-sha256-<digest>) and its accompanying schema-2 candidate receipt. This never compares
475
+ // the external tag against the archive's own internal ARCHIVE-MANIFEST releaseTag/version — those
476
+ // are two independent identity domains and conflating them was the historical bug this fixes.
477
+ export async function verifySeedBaseline({ seedDescriptor, bundleFile, receiptFile, accuracyReportFile = null, recallReportFile = null } = {}) {
478
+ if (!seedDescriptor || typeof seedDescriptor !== 'object') fail('seed descriptor is required');
479
+ const { tag, sha256, bytes, sourceCommit = null, allowPinnedTag = false } = seedDescriptor;
480
+ const expectedSha256 = String(sha256 || '').toLowerCase();
481
+ if (!HEX64.test(expectedSha256)) fail('seed descriptor sha256 is missing or malformed');
482
+ if (!tag || tag === 'latest') fail('seed descriptor tag is missing or forbidden');
483
+ const contentAddressed = tag === `corpus-sha256-${expectedSha256}`;
484
+ if (!contentAddressed && !allowPinnedTag) {
485
+ fail('seed descriptor tag must be the exact digest-derived tag corpus-sha256-<sha256> unless explicitly pinned');
486
+ }
487
+ const resolvedBundleFile = path.resolve(bundleFile || '');
488
+ if (!fs.existsSync(resolvedBundleFile)) fail(`seed bundle missing (${resolvedBundleFile})`);
489
+ const actualSha256 = sha256File(resolvedBundleFile);
490
+ if (actualSha256 !== expectedSha256) fail(`seed bundle sha256 ${actualSha256} differs from configured ${expectedSha256}`);
491
+ if (Number.isSafeInteger(bytes) && fs.statSync(resolvedBundleFile).size !== bytes) {
492
+ fail('seed bundle byte length differs from the configured seed descriptor');
493
+ }
494
+ // "Reverify the downloaded final artifact against the measured identity" — a downloaded seed is
495
+ // re-measured against its own detached accuracy report, which must have travelled with it.
496
+ const receipt = await verifyCorpusReceipt({
497
+ bundleFile: resolvedBundleFile,
498
+ receiptFile,
499
+ accuracyReportFile,
500
+ recallReportFile,
501
+ expectedArchiveSha256: expectedSha256,
502
+ ...(sourceCommit ? { expectedBuilderSha: sourceCommit } : {}),
503
+ });
504
+ return { tag, sha256: expectedSha256, bytes: fs.statSync(resolvedBundleFile).size, receipt };
505
+ }
506
+
294
507
  function arg(name, fallback) {
295
508
  const index = process.argv.indexOf(name);
296
509
  return index >= 0 ? process.argv[index + 1] : fallback;
@@ -298,17 +511,57 @@ function arg(name, fallback) {
298
511
 
299
512
  async function main() {
300
513
  const mode = process.argv.includes('--verify') ? 'verify' : 'create';
301
- const options = {
302
- assetsDir: arg('--assets', 'kb'),
303
- bundleFile: arg('--bundle', 'dist/ruvnet-brain.zip'),
304
- policyFile: arg('--policy'),
305
- receiptFile: arg('--receipt', arg('--out', 'dist/corpus-receipt.json')),
306
- builderSourceSha: arg('--builder-source-sha'),
307
- };
308
- const receipt = mode === 'verify' ? await verifyCorpusReceipt(options) : await createCorpusReceipt(options);
309
- console.log(JSON.stringify({ ok: true, mode, archive: receipt.archive, stores: receipt.storeCount }, null, 2));
514
+ const bundleFile = arg('--bundle', 'dist/ruvnet-brain.zip');
515
+ const receiptFile = arg('--receipt', arg('--out', 'dist/corpus-receipt.json'));
516
+ const accuracyReportFile = arg('--accuracy-report');
517
+ const recallReportFile = arg('--recall-report');
518
+ if (mode === 'create') {
519
+ const bootstrapTag = arg('--bootstrap-tag');
520
+ const bootstrapSha256 = arg('--bootstrap-sha256');
521
+ const bootstrapIdentity = bootstrapTag && bootstrapSha256 ? { tag: bootstrapTag, sha256: bootstrapSha256 } : null;
522
+ const receipt = await createCorpusReceipt({
523
+ bundleFile,
524
+ builderSourceSha: arg('--builder-source-sha'),
525
+ bootstrapIdentity,
526
+ receiptFile,
527
+ accuracyReportFile,
528
+ recallReportFile,
529
+ });
530
+ console.log(JSON.stringify({
531
+ ok: true, mode, archive: receipt.archive, stores: receipt.storeCount, accuracyReport: receipt.accuracyReport, recall: receipt.recallSummary,
532
+ }, null, 2));
533
+ } else {
534
+ const receipt = await verifyCorpusReceipt({
535
+ bundleFile,
536
+ receiptFile,
537
+ accuracyReportFile,
538
+ recallReportFile,
539
+ expectedBuilderSha: arg('--expected-builder-sha'),
540
+ expectedArchiveSha256: arg('--expected-archive-sha256'),
541
+ expectedReceiptSha256: arg('--expected-receipt-sha256'),
542
+ });
543
+ console.log(JSON.stringify({
544
+ ok: true, mode, archive: receipt.archive, stores: receipt.storeCount, accuracyReport: receipt.accuracyReport, recall: receipt.recallSummary,
545
+ }, null, 2));
546
+ }
547
+ }
548
+
549
+ // REALPATH BOTH SIDES, or this CLI silently no-ops. argv[1] is whatever the caller typed, symlinks
550
+ // and all, while node resolves a module URL THROUGH symlinks before it reaches import.meta.url — so a
551
+ // symlinked invocation compares a link path against a real path, decides it is not the entry point,
552
+ // runs nothing, and EXITS 0. On macOS every os.tmpdir() path is symlinked (/var/folders -> /private/
553
+ // var/folders), so any caller staging work in a temp directory hits this. Measured 2026-09-14:
554
+ // build-bundle.mjs and corpus-candidate.mjs both no-opped and prepareCorpusCandidate reported SUCCESS
555
+ // with no archive and no receipt on disk. Same defect, same fix as plugin/scripts/hook-input.mjs:518.
556
+ function isMain() {
557
+ try {
558
+ if (!process.argv[1]) return false;
559
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
560
+ } catch {
561
+ return false;
562
+ }
310
563
  }
311
564
 
312
- if (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) {
565
+ if (isMain()) {
313
566
  main().catch((error) => { console.error(error.message); process.exitCode = 1; });
314
567
  }