ruvnet-brain 4.3.20 → 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 (143) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +383 -78
  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/host-shell-boundary.mjs +43 -0
  30. package/plugin/scripts/nightly-scheduler.mjs +37 -4
  31. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  32. package/plugin/scripts/project-progression-contract.mjs +16 -0
  33. package/plugin/scripts/project-progression-hook.mjs +3 -0
  34. package/plugin/scripts/project-progression-producer.mjs +252 -0
  35. package/plugin/scripts/project-progression-reader.mjs +271 -0
  36. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  37. package/plugin/scripts/project-progression-sources.mjs +220 -0
  38. package/plugin/scripts/project-progression-store.mjs +106 -13
  39. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  40. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  41. package/plugin/scripts/session-start-budget.mjs +59 -0
  42. package/plugin/scripts/session-start-core.mjs +234 -457
  43. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  44. package/plugin/scripts/session-start-health.mjs +64 -0
  45. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  46. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  47. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  48. package/plugin/scripts/session-start-signals.mjs +73 -0
  49. package/plugin/scripts/session-start-trace.mjs +86 -0
  50. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  51. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  52. package/plugin/scripts/update-apply.mjs +2 -32
  53. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  54. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  55. package/scripts/adr-072-completion.mjs +1 -1
  56. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  57. package/scripts/approved-runtime.mjs +197 -0
  58. package/scripts/brain-novice-50.mjs +16 -1
  59. package/scripts/brain-score.mjs +23 -5
  60. package/scripts/build-bundle.mjs +971 -530
  61. package/scripts/build-concepts.mjs +36 -116
  62. package/scripts/console-engine.test.mjs +8 -7
  63. package/scripts/console-runtime-identity.mjs +4 -0
  64. package/scripts/corpus-aggregates.mjs +94 -77
  65. package/scripts/corpus-candidate.mjs +475 -222
  66. package/scripts/corpus-next-seed.mjs +225 -0
  67. package/scripts/corpus-promotion.mjs +58 -0
  68. package/scripts/corpus-reconcile.mjs +411 -105
  69. package/scripts/doc-currency.mjs +16 -1
  70. package/scripts/dual-host-deliberation.mjs +25 -2
  71. package/scripts/dual-host-suggest.mjs +17 -1
  72. package/scripts/falsify.mjs +13 -3
  73. package/scripts/gist-receipts.mjs +482 -87
  74. package/scripts/github-health-watch.mjs +12 -2
  75. package/scripts/handoff-asset.mjs +34 -0
  76. package/scripts/hook-retirement-check.mjs +8 -1
  77. package/scripts/host-registry.mjs +1 -1
  78. package/scripts/ingest-gists.mjs +74 -101
  79. package/scripts/job-heartbeat.sh +77 -14
  80. package/scripts/learning-replay-execution.mjs +10 -4
  81. package/scripts/nightly-gists.sh +27 -13
  82. package/scripts/nightly-two-run-proof.mjs +1 -1
  83. package/scripts/nightly-watchdog.mjs +61 -4
  84. package/scripts/onboarding-console.mjs +319 -27
  85. package/scripts/oracle/produce-questions.mjs +293 -0
  86. package/scripts/oracle/producer-hosts.mjs +235 -0
  87. package/scripts/oracle/repo-recall.mjs +448 -0
  88. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  89. package/scripts/oracle/source-tree.mjs +165 -0
  90. package/scripts/oracle/source-units.mjs +391 -0
  91. package/scripts/oracle/spike-run.mjs +98 -0
  92. package/scripts/oracle/unit-inventory.mjs +141 -0
  93. package/scripts/oracle/unit-sampling.mjs +128 -0
  94. package/scripts/oracle/validate-labels.mjs +250 -0
  95. package/scripts/private-overlay.mjs +248 -0
  96. package/scripts/product-integrity-contract.mjs +1 -1
  97. package/scripts/proxy/claude-proxied.sh +6 -0
  98. package/scripts/proxy/proxy-revert.sh +5 -0
  99. package/scripts/proxy/proxy-up.sh +6 -0
  100. package/scripts/proxy/proxy-verify.mjs +4 -0
  101. package/scripts/public-inputs.mjs +409 -0
  102. package/scripts/public-verification-inputs.mjs +112 -26
  103. package/scripts/public-verification-lane.mjs +1 -1
  104. package/scripts/published-surface-probe.mjs +34 -4
  105. package/scripts/qe/card-lane-gate.mjs +16 -1
  106. package/scripts/qe/session-start-gate.mjs +16 -1
  107. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  108. package/scripts/record-lesson.mjs +4 -1
  109. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  110. package/scripts/release-abort-stale.mjs +5 -1
  111. package/scripts/release-authority.mjs +104 -12
  112. package/scripts/release-channel-kind.mjs +86 -0
  113. package/scripts/release-convergence-watchdog.mjs +7 -2
  114. package/scripts/release-projection.mjs +177 -72
  115. package/scripts/release-transaction-provider.mjs +23 -6
  116. package/scripts/release.mjs +252 -17
  117. package/scripts/retrieval-canary.mjs +87 -0
  118. package/scripts/rvf-index-audit.mjs +573 -13
  119. package/scripts/rvf-wire.mjs +269 -0
  120. package/scripts/seal-gist-receipt.mjs +65 -0
  121. package/scripts/selfcheck.mjs +42 -21
  122. package/scripts/source-coverage.mjs +253 -24
  123. package/scripts/status-honesty.mjs +25 -0
  124. package/scripts/sync-census.mjs +0 -0
  125. package/scripts/sync-version.mjs +2 -0
  126. package/scripts/trismart.mjs +42 -0
  127. package/scripts/updater-manifest.mjs +162 -0
  128. package/scripts/verify-channels.mjs +17 -5
  129. package/scripts/wired-check.mjs +48 -10
  130. package/tri-smart-skill/QUICKSTART.md +37 -0
  131. package/tri-smart-skill/README.md +92 -0
  132. package/tri-smart-skill/install.cmd +14 -0
  133. package/tri-smart-skill/install.command +13 -0
  134. package/tri-smart-skill/install.mjs +51 -0
  135. package/tri-smart-skill/install.sh +9 -0
  136. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  137. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  138. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  139. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  140. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  141. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  142. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  143. package/scripts/corpus-seed-publish.mjs +0 -110
@@ -0,0 +1,994 @@
1
+ #!/usr/bin/env node
2
+ // scripts/rehearse-corpus-pipeline.mjs — ADR-086 Step 8.
3
+ //
4
+ // Run the complete corpus preparation and future product-consumption path in a DISPOSABLE checkout,
5
+ // end to end, with REAL seed import, REAL source acquisition, REAL local embeddings and REAL RVF
6
+ // indexes — on an explicitly bounded subset so the loop takes minutes instead of the ~6 hours a
7
+ // GitHub Actions corpus-seed dispatch takes. It exists because a 6-hour CI round trip is not a
8
+ // debugging loop, and because Dual made a local rehearsal a hard gate before any real dispatch.
9
+ //
10
+ // THIS IS AN ORCHESTRATOR. It calls the existing entry points; it reimplements none of them:
11
+ // scripts/corpus-reconcile.mjs assertBootstrapIdentity, normalizeExtractedCorpus, syncCorpusInputs,
12
+ // seedPrivateFenceEvidence, acquireCorpusGeneration,
13
+ // reconcileAndPrepareCorpusCandidate, prepareCorpusCandidate
14
+ // scripts/source-coverage.mjs observeSourceUniverse, canonicalSourceObservation, buildCoverage
15
+ // scripts/build-bundle.mjs assembleBundle (invoked exactly once per candidate, via its CLI,
16
+ // by prepareCorpusCandidate — counted, never reimplemented)
17
+ // scripts/corpus-candidate.mjs --verify (CLI) and verifySeedBaseline
18
+ // scripts/release.mjs --corpus-seed (CLI, the real future product-consumption path)
19
+ //
20
+ // WHAT IS BOUNDED, AND SAID OUT LOUD: `--repos N` (default 2) and `--gists M` (default 3). A
21
+ // rehearsal receipt ALWAYS carries `bounds` and the banner below so nobody can mistake a 2-store
22
+ // rehearsal for a 194-store corpus build. Everything else is the production code path.
23
+ //
24
+ // SAFETY — publication mutations are RECORDED, NEVER EXECUTED, and the interception is PROVEN:
25
+ // 1. The explicit seam the code offers (`RUVNET_GH_COMMAND` / `RUVNET_GH_SCRIPT`, read by
26
+ // scripts/release.mjs) is used FIRST, because it cannot be defeated by a PATH reassignment.
27
+ // 2. A PATH shim is installed as well (belt and braces), for any caller that has no such seam.
28
+ // 3. GH_TOKEN / GITHUB_TOKEN are poisoned for the publication phase, so even a leaked real `gh`
29
+ // cannot authenticate against the live repository.
30
+ // 4. The harness PROVES its own interception with a probe before trusting any result, and FAILS
31
+ // CLOSED when the probe shows it was bypassed. `--tamper bypass-interception` demonstrates
32
+ // that detection by deliberately reassigning PATH the way scripts/nightly-gists.sh does.
33
+ // A recorder that cannot detect its own bypass is exactly the silent failure this repo already
34
+ // documented once (tests/integration/nightly-gists-error-paths.test.mjs).
35
+ //
36
+ // Usage:
37
+ // node scripts/rehearse-corpus-pipeline.mjs [--repos 2] [--gists 3] [--generations 2]
38
+ // [--receipt <file>] [--seed <local ruvnet-brain.zip>] [--keep] [--tamper <mode>]
39
+ //
40
+ // Done is an exit code, not an opinion: FAIL exits non-zero. A phase that could not run is a SKIP
41
+ // with a stated reason, never a silent pass.
42
+
43
+ import crypto from 'node:crypto';
44
+ import fs from 'node:fs';
45
+ import os from 'node:os';
46
+ import path from 'node:path';
47
+ import { spawnSync } from 'node:child_process';
48
+ import { fileURLToPath, pathToFileURL } from 'node:url';
49
+
50
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
51
+ export const REAL_ROOT = path.resolve(HERE, '..');
52
+ const BANNER = 'BOUNDED REHEARSAL — a small explicitly-bounded subset, NOT a full corpus build';
53
+ const HEX64 = /^[a-f0-9]{64}$/;
54
+ const TAMPER_MODES = new Set(['none', 'extracted-byte', 'archive-byte', 'bypass-interception']);
55
+ const PROBE_ARG = '__rehearsal_probe__';
56
+
57
+ // Every `gh`/`npm` invocation that would MUTATE anything outside this process. Matched against the
58
+ // argument vector; a match is recorded and answered from the recorder, never executed. Expressed as
59
+ // data (not functions) so the generated recorder needs no eval to reconstruct it.
60
+ export const MUTATION_RULES = {
61
+ gh: {
62
+ verbsBySubject: {
63
+ release: ['create', 'edit', 'delete', 'upload', 'delete-asset'],
64
+ workflow: ['run', 'enable', 'disable'],
65
+ pr: ['create', 'merge', 'close', 'edit', 'ready', 'review'],
66
+ issue: ['create', 'close', 'edit', 'delete', 'transfer'],
67
+ gist: ['create', 'edit', 'delete', 'rename'],
68
+ secret: ['set', 'delete'],
69
+ variable: ['set', 'delete'],
70
+ ruleset: ['create', 'edit', 'delete'],
71
+ repo: ['create', 'delete', 'edit', 'fork', 'rename', 'sync', 'archive'],
72
+ cache: ['delete'],
73
+ label: ['create', 'delete', 'edit', 'clone'],
74
+ },
75
+ // `gh api` against a REST path is a read only while it stays a GET: an explicit non-GET
76
+ // --method, or any field flag (which makes gh send POST), marks it as a write.
77
+ restWriteFlags: ['-f', '-F', '--input'],
78
+ methodFlags: ['-X', '--method'],
79
+ // `gh api graphql` ALWAYS sends POST and ALWAYS carries `-f query=...`, so the REST rule above
80
+ // would classify every ordinary repository/gist enumeration as a mutation. MEASURED: it did —
81
+ // the first rehearsal with whole-process interception failed with "GitHub repository
82
+ // enumeration returned no repository connection" because the observation's own GraphQL read had
83
+ // been stubbed out. For GraphQL the operation keyword in the document is the real signal.
84
+ graphqlMutationSource: '(^|[\\s{}])mutation[\\s({]',
85
+ },
86
+ npm: { verbs: ['publish', 'unpublish', 'deprecate', 'dist-tag', 'access', 'owner', 'version', 'token'] },
87
+ };
88
+
89
+ function fail(message) {
90
+ throw new Error(`[rehearse-corpus-pipeline] ${message}`);
91
+ }
92
+
93
+ function sha256File(file) {
94
+ const hash = crypto.createHash('sha256');
95
+ const fd = fs.openSync(file, 'r');
96
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
97
+ try {
98
+ let bytes;
99
+ while ((bytes = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, bytes));
100
+ } finally {
101
+ fs.closeSync(fd);
102
+ }
103
+ return hash.digest('hex');
104
+ }
105
+
106
+ const digestOf = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
107
+
108
+ function run(command, args, options = {}) {
109
+ return spawnSync(command, args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, ...options });
110
+ }
111
+
112
+ function runOrFail(label, command, args, options = {}) {
113
+ const result = run(command, args, options);
114
+ if (result.error || result.status !== 0) {
115
+ const detail = String(result.error?.message || result.stderr || result.stdout || `exit ${result.status}`).trim();
116
+ fail(`${label} failed (${detail.slice(0, 800)})`);
117
+ }
118
+ return result;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------------------------
122
+ // Isolation snapshots. These are ASSERTIONS the run makes about itself, not comments: the real
123
+ // working tree and the installed brain at ~/.cache/ruvnet-brain/kb must be byte-unchanged after a
124
+ // rehearsal, and the receipt says so with a digest taken before and after.
125
+ // ---------------------------------------------------------------------------------------------
126
+
127
+ /** name/size/mtime/mode inventory of a tree — a cheap, complete change detector (any write to any
128
+ * file changes size or mtime; any create/delete changes the entry set). Never opens file contents:
129
+ * the installed brain is 1.4 GB and is READ-ONLY to this script. */
130
+ export function inventoryTree(dir, { maxEntries = 200_000 } = {}) {
131
+ const root = path.resolve(dir);
132
+ if (!fs.existsSync(root)) return { dir: root, present: false, entries: 0, digest: null };
133
+ const rows = [];
134
+ const visit = (current, prefix) => {
135
+ let entries;
136
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch (error) {
137
+ rows.push({ p: prefix, unreadable: String(error.code || error.message) });
138
+ return;
139
+ }
140
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
141
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
142
+ const absolute = path.join(current, entry.name);
143
+ if (rows.length > maxEntries) fail(`tree inventory exceeded ${maxEntries} entries (${root})`);
144
+ let stat;
145
+ try { stat = fs.lstatSync(absolute); } catch { rows.push({ p: relative, gone: true }); continue; }
146
+ if (stat.isDirectory()) { rows.push({ p: `${relative}/`, m: stat.mode }); visit(absolute, relative); }
147
+ else rows.push({ p: relative, s: stat.size, t: Math.trunc(stat.mtimeMs), m: stat.mode });
148
+ }
149
+ };
150
+ visit(root, '');
151
+ return { dir: root, present: true, entries: rows.length, digest: digestOf(rows) };
152
+ }
153
+
154
+ export function snapshotRepoState(root) {
155
+ const head = run('git', ['rev-parse', 'HEAD'], { cwd: root });
156
+ const status = run('git', ['status', '--porcelain'], { cwd: root });
157
+ return {
158
+ root: path.resolve(root),
159
+ head: String(head.stdout || '').trim() || null,
160
+ porcelain: String(status.stdout || ''),
161
+ digest: digestOf([String(head.stdout || '').trim(), String(status.stdout || '')]),
162
+ };
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------------------------
166
+ // The command recorder. Publication mutations are recorded and never executed; reads pass through.
167
+ // ---------------------------------------------------------------------------------------------
168
+
169
+ function recorderSource({ tool, recordFile, token, stubDir }) {
170
+ return `#!/usr/bin/env node
171
+ // GENERATED by scripts/rehearse-corpus-pipeline.mjs — a command recorder, not a tool.
172
+ import fs from 'node:fs';
173
+ import path from 'node:path';
174
+ import { spawnSync } from 'node:child_process';
175
+
176
+ const TOOL = ${JSON.stringify(tool)};
177
+ const RECORD = ${JSON.stringify(recordFile)};
178
+ const TOKEN = ${JSON.stringify(token)};
179
+ const STUB_DIR = ${JSON.stringify(stubDir)};
180
+ const RULES = ${JSON.stringify(MUTATION_RULES)};
181
+ const args = process.argv.slice(2);
182
+
183
+ function isMutation(argv) {
184
+ if (TOOL === 'npm') return RULES.npm.verbs.includes(argv[0]);
185
+ if (argv[0] === 'api') {
186
+ const methodIndex = argv.findIndex((token) => RULES.gh.methodFlags.includes(token));
187
+ const method = methodIndex >= 0 ? String(argv[methodIndex + 1] || '').toUpperCase() : null;
188
+ if (method && method !== 'GET') return true;
189
+ if (argv[1] === 'graphql') {
190
+ const document = argv.filter((token) => /^query=/.test(token)).map((token) => token.slice('query='.length)).join('\\n');
191
+ return new RegExp(RULES.gh.graphqlMutationSource).test(document);
192
+ }
193
+ return RULES.gh.restWriteFlags.some((flag) => argv.includes(flag));
194
+ }
195
+ const verbs = RULES.gh.verbsBySubject[argv[0]];
196
+ return Array.isArray(verbs) && verbs.includes(argv[1]);
197
+ }
198
+
199
+ function record(row) {
200
+ fs.appendFileSync(RECORD, \`\${JSON.stringify({ tool: TOOL, args, at: new Date().toISOString(), token: TOKEN, ...row })}\\n\`);
201
+ }
202
+
203
+ if (args[0] === ${JSON.stringify(PROBE_ARG)}) {
204
+ record({ classification: 'probe', executed: false });
205
+ process.stdout.write(\`RUVNET-REHEARSAL-STUB-OK \${TOKEN}\\n\`);
206
+ process.exit(0);
207
+ }
208
+
209
+ if (isMutation(args)) {
210
+ record({ classification: 'publication-mutation', executed: false });
211
+ process.stderr.write(\`[rehearsal-recorder] RECORDED, NOT EXECUTED: \${TOOL} \${args.join(' ')}\\n\`);
212
+ process.stdout.write('{"ok":true,"recordedByRehearsalRecorder":true}\\n');
213
+ process.exit(0);
214
+ }
215
+
216
+ // \`gh release view <tag>\` on a not-yet-published content-addressed tag: answered here so the
217
+ // absence proof never depends on the network or on an authenticated token. Recorded as a stubbed
218
+ // read, never as a passthrough — the receipt must not claim a network round trip that never happened.
219
+ if (TOOL === 'gh' && args[0] === 'release' && args[1] === 'view') {
220
+ record({ classification: 'stubbed-read', executed: false });
221
+ process.stderr.write('release not found\\n');
222
+ process.exit(1);
223
+ }
224
+
225
+ const PATH_DIRS = String(process.env.PATH || '').split(path.delimiter)
226
+ .concat(['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'])
227
+ .filter((dir) => dir && path.resolve(dir) !== path.resolve(STUB_DIR));
228
+ const real = PATH_DIRS.map((dir) => path.join(dir, TOOL)).find((file) => {
229
+ try { return fs.statSync(file).isFile(); } catch { return false; }
230
+ });
231
+ if (!real) {
232
+ record({ classification: 'passthrough-read', executed: false, error: \`no real \${TOOL} on PATH\` });
233
+ process.stderr.write(\`[rehearsal-recorder] no real \${TOOL} found for passthrough\\n\`);
234
+ process.exit(127);
235
+ }
236
+ const result = spawnSync(real, args, { stdio: 'inherit', env: process.env });
237
+ record({ classification: 'passthrough-read', executed: true, exit: result.status ?? null });
238
+ process.exit(result.status ?? 1);
239
+ `;
240
+ }
241
+
242
+ /**
243
+ * Install the recorder. Returns the env additions plus the two things a caller must use before
244
+ * trusting anything: `proveIntercepting()` and `recorded()`.
245
+ */
246
+ export function installCommandRecorder({ dir, tools = ['gh', 'npm'] }) {
247
+ const stubDir = path.resolve(dir);
248
+ fs.mkdirSync(stubDir, { recursive: true });
249
+ const recordFile = path.join(stubDir, 'recorded-commands.jsonl');
250
+ fs.writeFileSync(recordFile, '');
251
+ const token = crypto.randomBytes(12).toString('hex');
252
+ const scripts = {};
253
+ for (const tool of tools) {
254
+ const script = path.join(stubDir, `${tool}-recorder.mjs`);
255
+ fs.writeFileSync(script, recorderSource({ tool, recordFile, token, stubDir }));
256
+ const shim = path.join(stubDir, tool);
257
+ fs.writeFileSync(shim, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} "$@"\n`);
258
+ fs.chmodSync(shim, 0o755);
259
+ scripts[tool] = script;
260
+ }
261
+ const env = {
262
+ PATH: `${stubDir}${path.delimiter}${process.env.PATH || ''}`,
263
+ // The explicit seam scripts/release.mjs already offers. Preferred over PATH games precisely
264
+ // because a script that reassigns PATH cannot defeat it.
265
+ RUVNET_GH_COMMAND: process.execPath,
266
+ RUVNET_GH_SCRIPT: scripts.gh,
267
+ RUVNET_REHEARSAL_RECORD: recordFile,
268
+ };
269
+
270
+ const readProbe = (result) => String(result.stdout || '').includes(`RUVNET-REHEARSAL-STUB-OK ${token}`);
271
+
272
+ return {
273
+ stubDir,
274
+ recordFile,
275
+ token,
276
+ env,
277
+ scripts,
278
+ recorded: () => fs.readFileSync(recordFile, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line)),
279
+ /**
280
+ * Prove BOTH interception channels actually intercept, before any result is trusted. Returns
281
+ * `{ ok, channels }`; a caller that gets `ok: false` must FAIL CLOSED, never proceed — a
282
+ * bypassed recorder means the next `gh release create` would be real.
283
+ */
284
+ proveIntercepting: (extraEnv = {}) => {
285
+ const merged = { ...process.env, ...env, ...extraEnv };
286
+ const viaPath = run('gh', [PROBE_ARG], { env: merged });
287
+ const viaSeam = run(merged.RUVNET_GH_COMMAND, [merged.RUVNET_GH_SCRIPT, PROBE_ARG], { env: merged });
288
+ const channels = {
289
+ pathShim: { intercepted: readProbe(viaPath), exit: viaPath.status ?? null },
290
+ explicitSeam: { intercepted: readProbe(viaSeam), exit: viaSeam.status ?? null },
291
+ resolvedGh: String(run('sh', ['-c', 'command -v gh'], { env: merged }).stdout || '').trim() || null,
292
+ };
293
+ channels.pathShimResolvesInsideStubDir = channels.resolvedGh
294
+ ? path.resolve(path.dirname(channels.resolvedGh)) === stubDir : false;
295
+ return { ok: channels.pathShim.intercepted && channels.explicitSeam.intercepted
296
+ && channels.pathShimResolvesInsideStubDir, channels };
297
+ },
298
+ /**
299
+ * The bypass DEMONSTRATION: reassign PATH exactly the way scripts/nightly-gists.sh:1 does
300
+ * (`export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"` — a REASSIGNMENT, not an
301
+ * append) and drop the explicit seam. A correct harness must observe that its interception is
302
+ * gone. If this returns `intercepted: true`, the detector itself is broken.
303
+ */
304
+ demonstrateBypass: () => {
305
+ const hostile = { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin' };
306
+ delete hostile.RUVNET_GH_COMMAND;
307
+ delete hostile.RUVNET_GH_SCRIPT;
308
+ const probe = run('gh', [PROBE_ARG], { env: hostile });
309
+ return {
310
+ technique: 'PATH reassignment (the scripts/nightly-gists.sh pattern) plus removal of RUVNET_GH_COMMAND/RUVNET_GH_SCRIPT',
311
+ intercepted: readProbe(probe),
312
+ resolvedGh: String(run('sh', ['-c', 'command -v gh'], { env: hostile }).stdout || '').trim() || null,
313
+ exit: probe.status ?? null,
314
+ };
315
+ },
316
+ };
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------------------------
320
+ // Disposable checkout
321
+ // ---------------------------------------------------------------------------------------------
322
+
323
+ /**
324
+ * A real, self-contained git checkout of the current HEAD in a throwaway directory, with the two
325
+ * installed dependency trees symlinked in (never copied, never mutated). It is a real repository so
326
+ * every `git rev-parse HEAD` in the pipeline resolves to THIS checkout, and so the rehearsal's
327
+ * builderSha is genuinely its own source identity rather than a borrowed one.
328
+ */
329
+ export function createDisposableCheckout({ sourceRoot, targetRoot }) {
330
+ const source = path.resolve(sourceRoot);
331
+ const target = path.resolve(targetRoot);
332
+ fs.mkdirSync(target, { recursive: true });
333
+ const archive = path.join(path.dirname(target), 'checkout.tar');
334
+ runOrFail('git archive of the source checkout', 'git', ['archive', '--format=tar', '-o', archive, 'HEAD'], { cwd: source });
335
+ runOrFail('tar extract into the disposable checkout', 'tar', ['-xf', archive, '-C', target]);
336
+ fs.rmSync(archive, { force: true });
337
+ for (const relative of ['node_modules', path.join('kb', 'node_modules')]) {
338
+ const from = path.join(source, relative);
339
+ if (!fs.existsSync(from)) fail(`dependency tree missing in the source checkout (${from}); run npm ci there first`);
340
+ fs.mkdirSync(path.dirname(path.join(target, relative)), { recursive: true });
341
+ fs.symlinkSync(from, path.join(target, relative));
342
+ }
343
+ runOrFail('git init in the disposable checkout', 'git', ['init', '-q'], { cwd: target });
344
+ runOrFail('git add in the disposable checkout', 'git', ['add', '-A'], { cwd: target });
345
+ runOrFail('git commit in the disposable checkout', 'git', [
346
+ '-c', 'user.email=rehearsal@localhost', '-c', 'user.name=corpus rehearsal',
347
+ 'commit', '-q', '-m', 'disposable rehearsal checkout',
348
+ ], { cwd: target });
349
+ const head = String(runOrFail('git rev-parse in the disposable checkout', 'git', ['rev-parse', 'HEAD'], { cwd: target }).stdout).trim();
350
+ if (!/^[a-f0-9]{40}$/.test(head)) fail('disposable checkout produced no usable HEAD');
351
+ return { root: target, head, sourceHead: snapshotRepoState(source).head };
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------------------------
355
+ // Seed acquisition — real, or an explicit SKIP. Never a silent substitute.
356
+ // ---------------------------------------------------------------------------------------------
357
+
358
+ /**
359
+ * Acquire the exact bytes `data/corpus-seed.json` pins. Network first (the way corpus-seed.yml
360
+ * does it, `gh release download`); an offline fall-back is accepted ONLY when the local file's
361
+ * sha256 AND byte length equal the pinned descriptor exactly — that is the same identity contract,
362
+ * not a substitute. Anything else is reported as UNAVAILABLE and the caller SKIPs, loudly.
363
+ */
364
+ export function acquireSeed({ descriptor, repo, downloadDir, env, localCandidates = [] }) {
365
+ const attempts = [];
366
+ fs.mkdirSync(downloadDir, { recursive: true });
367
+ const target = path.join(downloadDir, descriptor.asset || 'ruvnet-brain.zip');
368
+
369
+ const network = run('gh', ['release', 'download', descriptor.tag, '--repo', repo,
370
+ '--pattern', descriptor.asset || 'ruvnet-brain.zip', '--dir', downloadDir], { env });
371
+ if (!network.error && network.status === 0 && fs.existsSync(target)) {
372
+ attempts.push({ channel: 'network', outcome: 'downloaded' });
373
+ const sha256 = sha256File(target);
374
+ if (sha256 === descriptor.sha256 && fs.statSync(target).size === descriptor.bytes) {
375
+ return { ok: true, channel: 'network', file: target, sha256, bytes: fs.statSync(target).size, attempts };
376
+ }
377
+ attempts.push({ channel: 'network', outcome: 'digest-mismatch', sha256 });
378
+ fs.rmSync(target, { force: true });
379
+ } else {
380
+ attempts.push({ channel: 'network', outcome: 'unavailable',
381
+ detail: String(network.error?.message || network.stderr || `exit ${network.status}`).trim().slice(0, 300) });
382
+ }
383
+
384
+ for (const candidate of localCandidates.filter(Boolean)) {
385
+ const file = path.resolve(candidate);
386
+ if (!fs.existsSync(file)) { attempts.push({ channel: 'local', file, outcome: 'absent' }); continue; }
387
+ const bytes = fs.statSync(file).size;
388
+ if (bytes !== descriptor.bytes) { attempts.push({ channel: 'local', file, outcome: 'byte-length-mismatch', bytes }); continue; }
389
+ const sha256 = sha256File(file);
390
+ if (sha256 !== descriptor.sha256) { attempts.push({ channel: 'local', file, outcome: 'digest-mismatch', sha256 }); continue; }
391
+ fs.copyFileSync(file, target);
392
+ attempts.push({ channel: 'local', file, outcome: 'digest-verified' });
393
+ return { ok: true, channel: 'local-cache-digest-verified', file: target, sha256, bytes, attempts };
394
+ }
395
+ return { ok: false, attempts,
396
+ reason: 'the pinned bootstrap seed asset could not be downloaded and no local copy matched the pinned sha256/bytes exactly' };
397
+ }
398
+
399
+ // ---------------------------------------------------------------------------------------------
400
+ // Bounded observation
401
+ // ---------------------------------------------------------------------------------------------
402
+
403
+ /**
404
+ * The ONE deliberate reduction in this rehearsal: the live source universe is observed for real
405
+ * (`observeSourceUniverse`, real `gh`) and then RESTRICTED to `repos` repositories and `gists`
406
+ * gists before being re-sealed through the production `canonicalSourceObservation`. Everything
407
+ * downstream — planning, cloning, embedding, indexing, pruning, aggregation, assembly, sealing —
408
+ * is the production code path over that smaller universe.
409
+ */
410
+ export function boundObservation({ observation, api, repoStores, gistCount }) {
411
+ const chosen = repoStores.length
412
+ ? observation.repositories.rows.filter((row) => repoStores.includes(String(row.storeName || row.name).toLowerCase()))
413
+ : [];
414
+ const gistRows = observation.gists.rows.slice(0, gistCount);
415
+ return api.canonicalSourceObservation({
416
+ schemaVersion: observation.schemaVersion,
417
+ kind: observation.kind,
418
+ owner: observation.owner,
419
+ observedAt: observation.observedAt,
420
+ repositories: { rows: chosen, expected: chosen.length },
421
+ gists: { rows: gistRows, expected: gistRows.length },
422
+ });
423
+ }
424
+
425
+ /** Deterministically pick the `count` smallest ELIGIBLE repositories, measured by the observation's
426
+ * own diskUsage, so a rehearsal clones and embeds real upstream source in minutes. Eligibility is
427
+ * decided by the production classifier (`buildCoverage`), never by a guess here. */
428
+ function selectBoundedRepoStores({ api, observation, assetsDir, count }) {
429
+ const ordered = [...observation.repositories.rows]
430
+ .sort((a, b) => (a.diskUsage ?? Number.MAX_SAFE_INTEGER) - (b.diskUsage ?? Number.MAX_SAFE_INTEGER)
431
+ || String(a.name).localeCompare(String(b.name)));
432
+ const eligible = [];
433
+ for (let window = Math.max(count, 4); window <= ordered.length && eligible.length < count; window *= 2) {
434
+ const slice = ordered.slice(0, window);
435
+ const probe = api.canonicalSourceObservation({
436
+ schemaVersion: observation.schemaVersion, kind: observation.kind, owner: observation.owner,
437
+ observedAt: observation.observedAt,
438
+ repositories: { rows: slice, expected: slice.length },
439
+ gists: { rows: [], expected: 0 },
440
+ });
441
+ const coverage = api.buildCoverage({ owner: observation.owner, kbDir: assetsDir, policyDir: assetsDir, observation: probe });
442
+ eligible.length = 0;
443
+ for (const row of coverage.rows) {
444
+ if (row.kind === 'repository' && row.disposition === 'eligible') eligible.push(String(row.artifact.store).toLowerCase());
445
+ if (eligible.length >= count) break;
446
+ }
447
+ }
448
+ if (eligible.length < count) {
449
+ fail(`fewer than ${count} eligible repositories in the observed universe (found ${eligible.length})`);
450
+ }
451
+ return eligible.slice(0, count);
452
+ }
453
+
454
+ // ---------------------------------------------------------------------------------------------
455
+ // Extracted-byte verification with the originals REVOKED
456
+ // ---------------------------------------------------------------------------------------------
457
+
458
+ /**
459
+ * Make the staging assets unreachable, then prove the produced archive stands entirely on its own:
460
+ * the extracted bytes must reproduce ARCHIVE-MANIFEST.json exactly, the real
461
+ * `corpus-candidate.mjs --verify` must pass against them, and nothing inside the archive may quote
462
+ * the staging path. This is the clause that catches an assembly which silently depends on the
463
+ * checkout it was built in.
464
+ */
465
+ export async function verifyExtractedBytesWithoutOriginals({
466
+ checkoutRoot, assetsDir, bundleFile, receiptFile, verifyDir, tamper = 'none', extractZip,
467
+ }) {
468
+ const revoked = `${assetsDir}.revoked`;
469
+ fs.renameSync(assetsDir, revoked);
470
+ fs.chmodSync(revoked, 0o000);
471
+ const findings = { revokedDir: revoked, originalsReachable: null, tamper, files: 0, totalBytes: 0 };
472
+ try {
473
+ try { fs.readdirSync(revoked); findings.originalsReachable = true; }
474
+ catch { findings.originalsReachable = false; }
475
+ if (findings.originalsReachable) fail('staging assets are still reachable after revocation — the isolation this phase depends on did not hold');
476
+
477
+ fs.mkdirSync(verifyDir, { recursive: true });
478
+ await extractZip(bundleFile, verifyDir);
479
+
480
+ const manifestFile = (function locate(dir) {
481
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
482
+ const file = path.join(dir, entry.name);
483
+ if (entry.isDirectory()) { const hit = locate(file); if (hit) return hit; }
484
+ else if (entry.name === 'ARCHIVE-MANIFEST.json') return file;
485
+ }
486
+ return null;
487
+ })(verifyDir);
488
+ if (!manifestFile) fail('extracted archive carries no ARCHIVE-MANIFEST.json');
489
+ const root = path.dirname(manifestFile);
490
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
491
+
492
+ if (tamper === 'extracted-byte') {
493
+ const victim = fs.readdirSync(root).filter((name) => name.endsWith('.big.rvf')).sort()[0];
494
+ if (!victim) fail('deliberate corruption requested but the extracted archive ships no .big.rvf');
495
+ const file = path.join(root, victim);
496
+ const fd = fs.openSync(file, 'r+');
497
+ try {
498
+ const byte = Buffer.alloc(1);
499
+ fs.readSync(fd, byte, 0, 1, 64);
500
+ byte[0] ^= 0xff;
501
+ fs.writeSync(fd, byte, 0, 1, 64);
502
+ } finally { fs.closeSync(fd); }
503
+ findings.tamperedFile = victim;
504
+ }
505
+
506
+ // Independent recomputation from the extracted bytes — the manifest is a claim until it is
507
+ // recomputed, and it is recomputed here from files the staging directory can no longer supply.
508
+ const mismatches = [];
509
+ for (const row of manifest.files || []) {
510
+ const file = path.join(root, row.path);
511
+ if (!fs.existsSync(file)) { mismatches.push(`${row.path}: absent from the extracted tree`); continue; }
512
+ const sha256 = sha256File(file);
513
+ const bytes = fs.statSync(file).size;
514
+ if (sha256 !== row.sha256 || bytes !== row.bytes) {
515
+ mismatches.push(`${row.path}: extracted bytes sha256=${sha256} bytes=${bytes} differ from ARCHIVE-MANIFEST sha256=${row.sha256} bytes=${row.bytes}`);
516
+ }
517
+ findings.files += 1;
518
+ findings.totalBytes += bytes;
519
+ }
520
+ if (mismatches.length) fail(`extracted bytes do not reproduce ARCHIVE-MANIFEST.json:\n ${mismatches.slice(0, 5).join('\n ')}`);
521
+
522
+ // No file inside the archive may quote the staging path: that would be a build that leaked its
523
+ // own scratch directory into shipped bytes.
524
+ const leaks = [];
525
+ for (const row of manifest.files || []) {
526
+ if (!/\.(json|jsonl|md|txt|mjs|js)$/.test(row.path)) continue;
527
+ const text = fs.readFileSync(path.join(root, row.path), 'utf8');
528
+ if (text.includes(revoked) || text.includes(assetsDir)) leaks.push(row.path);
529
+ }
530
+ findings.stagingPathLeaks = leaks.length;
531
+ if (leaks.length) fail(`shipped files quote the revoked staging directory: ${leaks.slice(0, 5).join(', ')}`);
532
+
533
+ const verified = run(process.execPath, [path.join(checkoutRoot, 'scripts', 'corpus-candidate.mjs'),
534
+ '--verify', '--bundle', bundleFile, '--receipt', receiptFile], { cwd: checkoutRoot });
535
+ findings.corpusCandidateVerify = { exit: verified.status ?? null,
536
+ stderr: String(verified.stderr || '').trim().slice(0, 400) };
537
+ if (verified.error || verified.status !== 0) {
538
+ fail(`corpus-candidate.mjs --verify rejected the sealed archive with its originals revoked (${findings.corpusCandidateVerify.stderr || `exit ${verified.status}`})`);
539
+ }
540
+ return findings;
541
+ } finally {
542
+ try { fs.chmodSync(revoked, 0o700); } catch { /* best effort; the temp root is removed anyway */ }
543
+ }
544
+ }
545
+
546
+ // ---------------------------------------------------------------------------------------------
547
+ // One generation: import seed N, reconcile bounded, assemble once, verify extracted bytes,
548
+ // rehearse publication with the recorder, and emit candidate N as the seed for generation N+1.
549
+ // ---------------------------------------------------------------------------------------------
550
+
551
+ async function runGeneration({ index, api, checkoutRoot, workRoot, seed, bounds, recorder, tamper, log }) {
552
+ const generation = { index, startedAt: new Date().toISOString(), seed: { tag: seed.tag, sha256: seed.sha256, bytes: seed.bytes, channel: seed.channel } };
553
+ const assetsDir = path.join(workRoot, `assets-gen${index}`);
554
+ const workspaceDir = path.join(workRoot, `clones-gen${index}`);
555
+ const candidateDir = path.join(workRoot, `candidate-gen${index}`, 'ruvnet-brain');
556
+ const receiptFile = path.join(workRoot, `candidate-gen${index}`, 'corpus-receipt.json');
557
+ const verifyDir = path.join(workRoot, `extract-gen${index}`);
558
+
559
+ // --- real seed import, exactly as scripts/corpus-reconcile.mjs main() does it ---------------
560
+ log(`[gen ${index}] importing seed ${seed.tag} (${seed.bytes} bytes, ${seed.channel})`);
561
+ const bootstrap = api.assertBootstrapIdentity({
562
+ archiveFile: seed.file, tag: seed.tag, sha256: seed.sha256, allowPinnedTag: seed.allowPinnedTag === true,
563
+ });
564
+ const extractParent = fs.mkdtempSync(path.join(workRoot, `.seed-extract-gen${index}-`));
565
+ await api.extractZip(seed.file, extractParent);
566
+ api.normalizeExtractedCorpus({ extractedDir: extractParent, assetsDir });
567
+ const fence = path.join(checkoutRoot, 'kb', 'PRIVATE-STORES.json');
568
+ if (!fs.existsSync(fence)) fail(`canonical private-store fence missing (${fence})`);
569
+ fs.copyFileSync(fence, path.join(assetsDir, 'PRIVATE-STORES.json'), fs.constants.COPYFILE_EXCL);
570
+ fs.rmSync(extractParent, { recursive: true, force: true });
571
+ api.syncCorpusInputs({ root: checkoutRoot, assetsDir });
572
+ const bootstrapIdentity = { tag: bootstrap.tag, sha256: bootstrap.sha256,
573
+ privateFenceEvidence: api.seedPrivateFenceEvidence(assetsDir) };
574
+ generation.seedImport = { assetsDir, storesInSeed: Object.keys(
575
+ JSON.parse(fs.readFileSync(path.join(assetsDir, 'RVF-GENERATIONS.json'), 'utf8')).stores || {}).length };
576
+ log(`[gen ${index}] seed imported: ${generation.seedImport.storesInSeed} stores in the ledger`);
577
+
578
+ // --- bounded observation over the REAL live source universe ---------------------------------
579
+ const externalFile = path.join(assetsDir, 'external-sources.json');
580
+ const externalSources = fs.existsSync(externalFile)
581
+ ? JSON.parse(fs.readFileSync(externalFile, 'utf8')).sources || [] : [];
582
+ const observeFull = () => api.observeSourceUniverse({ owner: bounds.owner, externalSources });
583
+ log(`[gen ${index}] observing the live source universe (real gh)`);
584
+ const full = observeFull();
585
+ const repoStores = selectBoundedRepoStores({ api, observation: full, assetsDir, count: bounds.repos });
586
+ generation.bounded = { repositories: repoStores, gists: bounds.gists,
587
+ observedUniverse: { repositories: full.repositories.rows.length, gists: full.gists.rows.length } };
588
+ log(`[gen ${index}] bounded to repositories [${repoStores.join(', ')}] + ${bounds.gists} gist(s) out of ${full.repositories.rows.length} repos / ${full.gists.rows.length} gists`);
589
+
590
+ // Force genuine source acquisition + embedding + indexing for the bounded stores: drop their
591
+ // seed RVF bytes so planReconciliation must rebuild them from upstream. Without this a seed whose
592
+ // stores are already CURRENT would exercise no acquisition at all, and the rehearsal would prove
593
+ // nothing about the expensive half of the pipeline.
594
+ if (bounds.forceRebuild) {
595
+ for (const store of repoStores) fs.rmSync(path.join(assetsDir, `${store}.big.rvf`), { force: true });
596
+ generation.forcedRebuild = repoStores;
597
+ }
598
+
599
+ const boundedObserve = () => boundObservation({ observation: observeFull(), api, repoStores, gistCount: bounds.gists });
600
+
601
+ // --- reconcile + assemble ONCE --------------------------------------------------------------
602
+ const invocations = [];
603
+ const recordingRun = (command, args, options = {}) => {
604
+ invocations.push({ command, script: path.basename(String(args?.[0] || '')), args: args.map(String) });
605
+ return spawnSync(command, args, { encoding: 'utf8', ...options });
606
+ };
607
+ const reconcileStart = Date.now();
608
+ const { reconciliation, candidate } = await api.reconcileAndPrepareCorpusCandidate({
609
+ assetsDir, workspaceDir, root: checkoutRoot, owner: bounds.owner, builderSha: bounds.builderSha,
610
+ candidateDir, receiptFile, coverageFile: path.join(checkoutRoot, 'data', 'source-coverage.json'),
611
+ bootstrapIdentity, maxRounds: bounds.maxRounds,
612
+ reconcile: (options) => api.acquireCorpusGeneration({ ...options, observe: boundedObserve }),
613
+ prepare: (options) => api.prepareCorpusCandidate({ ...options, run: recordingRun }),
614
+ });
615
+ generation.reconciliation = {
616
+ rounds: reconciliation.rounds.length,
617
+ observationSha256: reconciliation.observation.observationSha256,
618
+ refreshed: reconciliation.rounds.flatMap((round) => round.refreshed || []),
619
+ pruned: reconciliation.rounds.flatMap((round) => round.pruned || []).length,
620
+ rebuilt: reconciliation.rounds.flatMap((round) => round.rebuilt || []),
621
+ durationMs: Date.now() - reconcileStart,
622
+ };
623
+ const assemblyInvocations = invocations.filter((row) => row.script === 'build-bundle.mjs');
624
+ generation.assembly = {
625
+ assembleBundleInvocations: assemblyInvocations.length,
626
+ receiptInvocations: invocations.filter((row) => row.script === 'corpus-candidate.mjs').length,
627
+ bundleFile: candidate.bundleFile,
628
+ };
629
+ if (assemblyInvocations.length !== 1) {
630
+ fail(`assembly must happen exactly once per candidate; build-bundle.mjs ran ${assemblyInvocations.length} time(s)`);
631
+ }
632
+ const bundleFile = candidate.bundleFile;
633
+ // An exit code of 0 is not evidence that a file was written. prepareCorpusCandidate's `checked()`
634
+ // inspects only the child's status, so a CLI that silently no-ops (see the realpath note on
635
+ // workRoot) passes every gate above while producing nothing. Name that possibility here rather
636
+ // than surfacing it as a bare ENOENT three lines later.
637
+ for (const [label, file] of [['candidate archive', bundleFile], ['candidate receipt', candidate.receiptFile]]) {
638
+ if (!fs.existsSync(file)) {
639
+ fail(`assembly reported success but produced no ${label} (${file}). The child processes exited 0 without writing — `
640
+ + 'check that scripts/build-bundle.mjs and scripts/corpus-candidate.mjs actually entered their CLI blocks '
641
+ + '(their `path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)` guard is false under a symlinked path).');
642
+ }
643
+ }
644
+ const archiveSha256 = sha256File(bundleFile);
645
+ const archiveBytes = fs.statSync(bundleFile).size;
646
+ generation.candidate = { bundleFile, sha256: archiveSha256, bytes: archiveBytes,
647
+ receiptFile: candidate.receiptFile, receiptSha256: sha256File(candidate.receiptFile) };
648
+ const receipt = JSON.parse(fs.readFileSync(candidate.receiptFile, 'utf8'));
649
+ generation.candidate.storeCount = receipt.storeCount;
650
+ generation.candidate.stores = receipt.stores.map(({ name, kind }) => ({ name, kind }));
651
+ log(`[gen ${index}] sealed candidate ${archiveSha256.slice(0, 16)} — ${receipt.storeCount} stores, ${archiveBytes} bytes`);
652
+
653
+ // --- remove access to the originals, then verify the extracted bytes ------------------------
654
+ generation.extractedByteVerification = await verifyExtractedBytesWithoutOriginals({
655
+ checkoutRoot, assetsDir, bundleFile, receiptFile: candidate.receiptFile, verifyDir,
656
+ tamper: index === bounds.tamperGeneration ? tamper : 'none', extractZip: api.extractZip,
657
+ });
658
+ log(`[gen ${index}] extracted-byte verification passed with staging revoked (${generation.extractedByteVerification.files} files)`);
659
+
660
+ // --- rehearse the real product-consumption path, publication RECORDED not executed -----------
661
+ const corpusTag = `corpus-sha256-${archiveSha256}`;
662
+ const proof = recorder.proveIntercepting();
663
+ if (!proof.ok) {
664
+ generation.publication = { status: 'FAIL', reason: 'command interception could not be proven; refusing to run a publication path that might reach the network', proof };
665
+ fail('command interception could not be proven before the publication rehearsal — failing closed');
666
+ }
667
+ const publishEnv = {
668
+ ...process.env,
669
+ ...recorder.env,
670
+ GITHUB_ACTIONS: 'true',
671
+ GITHUB_WORKFLOW: 'protected-release',
672
+ GITHUB_REPOSITORY: 'stuinfla/ruvnet-brain',
673
+ GITHUB_EVENT_NAME: 'workflow_dispatch',
674
+ GITHUB_REF_PROTECTED: 'true',
675
+ GITHUB_SHA: bounds.builderSha,
676
+ // Poisoned on purpose: even a leaked real `gh` cannot authenticate against the live repository.
677
+ GH_TOKEN: 'rehearsal-invalid-token-do-not-use',
678
+ GITHUB_TOKEN: 'rehearsal-invalid-token-do-not-use',
679
+ };
680
+ const publish = run(process.execPath, [path.join(checkoutRoot, 'scripts', 'release.mjs'), '--corpus-seed',
681
+ '--corpus-tag', corpusTag, '--corpus-bundle', bundleFile, '--corpus-receipt', candidate.receiptFile,
682
+ '--target', bounds.builderSha, '--repo', 'stuinfla/ruvnet-brain'], { cwd: checkoutRoot, env: publishEnv });
683
+ const recordedNow = recorder.recorded().filter((row) => row.classification === 'publication-mutation');
684
+ generation.publication = {
685
+ status: publish.status === 0 ? 'PASS' : 'FAIL',
686
+ entrypoint: 'scripts/release.mjs --corpus-seed',
687
+ corpusTag,
688
+ exit: publish.status ?? null,
689
+ stdout: String(publish.stdout || '').trim().slice(0, 800),
690
+ stderr: String(publish.stderr || '').trim().slice(0, 800),
691
+ interceptionProof: proof.channels,
692
+ recordedMutations: recordedNow.map((row) => `${row.tool} ${row.args.join(' ')}`.slice(0, 400)),
693
+ executedMutations: recordedNow.filter((row) => row.executed).length,
694
+ };
695
+ if (publish.status !== 0) fail(`the product-consumption path (release.mjs --corpus-seed) failed: ${generation.publication.stderr || `exit ${publish.status}`}`);
696
+ if (generation.publication.executedMutations !== 0) fail('a publication mutation was EXECUTED rather than recorded — the hard fence was crossed');
697
+ if (!recordedNow.some((row) => row.args[0] === 'release' && row.args[1] === 'create')) {
698
+ fail('the publication rehearsal recorded no `gh release create` — the recorder captured nothing, so nothing is proven');
699
+ }
700
+ log(`[gen ${index}] publication path rehearsed: ${recordedNow.length} mutation(s) RECORDED, 0 executed`);
701
+
702
+ // --- candidate N becomes seed N+1 -----------------------------------------------------------
703
+ // The file NAME is part of the sealed identity (`archive.file` inside the schema-2 receipt), so
704
+ // candidate N must be handed to generation N+1 under its own basename, in its own directory.
705
+ const nextSeedFile = path.join(workRoot, `seed-gen${index + 1}`, path.basename(bundleFile));
706
+ fs.mkdirSync(path.dirname(nextSeedFile), { recursive: true });
707
+ fs.copyFileSync(bundleFile, nextSeedFile);
708
+ generation.nextSeed = { tag: corpusTag, sha256: archiveSha256, bytes: archiveBytes, file: nextSeedFile,
709
+ contentAddressed: true };
710
+ generation.finishedAt = new Date().toISOString();
711
+ return {
712
+ generation,
713
+ nextSeed: { file: nextSeedFile, tag: corpusTag, sha256: archiveSha256, bytes: archiveBytes,
714
+ channel: `candidate-generation-${index}`, allowPinnedTag: false, receiptFile: candidate.receiptFile },
715
+ };
716
+ }
717
+
718
+ // ---------------------------------------------------------------------------------------------
719
+ // The rehearsal
720
+ // ---------------------------------------------------------------------------------------------
721
+
722
+ export async function rehearseCorpusPipeline({
723
+ sourceRoot = REAL_ROOT,
724
+ repos = 2,
725
+ gists = 3,
726
+ generations = 2,
727
+ maxRounds = 3,
728
+ owner = 'ruvnet',
729
+ tamper = 'none',
730
+ tamperGeneration = 1,
731
+ forceRebuild = true,
732
+ seedOverride = null,
733
+ keep = false,
734
+ workRootParent = null,
735
+ installedBrainDir = path.join(os.homedir(), '.cache', 'ruvnet-brain', 'kb'),
736
+ log = (line) => process.stderr.write(`${line}\n`),
737
+ } = {}) {
738
+ if (!TAMPER_MODES.has(tamper)) fail(`unknown --tamper mode ${tamper} (expected: ${[...TAMPER_MODES].join(', ')})`);
739
+ const startedAt = new Date().toISOString();
740
+ const started = Date.now();
741
+ const receipt = {
742
+ schemaVersion: 1,
743
+ kind: 'ruvnet-brain-corpus-rehearsal-receipt',
744
+ banner: BANNER,
745
+ startedAt,
746
+ host: { node: process.version, platform: `${process.platform}/${process.arch}` },
747
+ bounds: { repos, gists, generations, maxRounds, owner, forceRebuild, tamper, tamperGeneration },
748
+ phases: [],
749
+ generations: [],
750
+ verdict: 'FAIL',
751
+ };
752
+ const phase = (name, status, detail = {}) => {
753
+ receipt.phases.push({ phase: name, status, ...detail });
754
+ log(`[${status}] ${name}${detail.reason ? ` — ${detail.reason}` : ''}`);
755
+ return status;
756
+ };
757
+
758
+ // REALPATH, deliberately. On macOS `os.tmpdir()` is `/var/folders/...`, a symlink to
759
+ // `/private/var/folders/...`. Node's module resolver realpaths `import.meta.url` but leaves
760
+ // `process.argv[1]` as typed, so the repo's standard CLI guard
761
+ // (`path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)`) is FALSE for every script
762
+ // invoked through a symlinked path — and those CLIs then exit 0 having done nothing at all.
763
+ // MEASURED here on 2026-09-14: build-bundle.mjs and corpus-candidate.mjs both silently no-opped
764
+ // under `/var/folders/...` and prepareCorpusCandidate reported success with no archive and no
765
+ // receipt on disk. Rehearsing under the real path removes that variable from the rehearsal; the
766
+ // defect itself is reported, not patched here (those files belong to other work).
767
+ const workRoot = fs.mkdtempSync(path.join(
768
+ fs.realpathSync(path.resolve(workRootParent || os.tmpdir())), 'corpus-rehearsal-'));
769
+ const checkoutRoot = path.join(workRoot, 'checkout');
770
+ let recorder = null;
771
+ let restoreProcessEnv = null;
772
+ try {
773
+ // ---- phase 1: isolation baseline ---------------------------------------------------------
774
+ const beforeRepo = snapshotRepoState(sourceRoot);
775
+ const beforeBrain = inventoryTree(installedBrainDir);
776
+ receipt.isolation = { sourceRoot: path.resolve(sourceRoot), installedBrainDir, before: {
777
+ repo: beforeRepo.digest, installedBrain: beforeBrain.digest, installedBrainEntries: beforeBrain.entries } };
778
+ phase('isolation-baseline', 'PASS', { repoHead: beforeRepo.head, installedBrainEntries: beforeBrain.entries });
779
+
780
+ // ---- phase 2: disposable checkout --------------------------------------------------------
781
+ const checkout = createDisposableCheckout({ sourceRoot, targetRoot: checkoutRoot });
782
+ receipt.disposableCheckout = { root: checkout.root, head: checkout.head, sourceHead: checkout.sourceHead,
783
+ materializedFrom: 'git archive HEAD — the COMMITTED tree only; uncommitted working-tree changes are NOT rehearsed' };
784
+ phase('disposable-checkout', 'PASS', { root: checkout.root, head: checkout.head, sourceHead: checkout.sourceHead });
785
+
786
+ // ---- phase 3: command recorder, proven before anything trusts it -------------------------
787
+ recorder = installCommandRecorder({ dir: path.join(workRoot, 'stub-bin') });
788
+ // Cover THIS process too, not only the children it spawns. observeSourceUniverse and
789
+ // gist-receipts shell out to `gh` from inside the rehearsal's own process; leaving those on the
790
+ // real binary would mean the fence covered the publication path alone. With the recorder on
791
+ // this process's PATH and explicit seam, EVERY `gh` — in-process or spawned, at any depth —
792
+ // resolves to it: reads pass through, mutations are recorded and refused. MEASURED before this
793
+ // change: a passing rehearsal's ledger showed a single passthrough-read, because every
794
+ // observation call had gone straight to /opt/homebrew/bin/gh.
795
+ restoreProcessEnv = Object.fromEntries(Object.keys(recorder.env).map((key) => [key, process.env[key]]));
796
+ Object.assign(process.env, recorder.env);
797
+ const proof = recorder.proveIntercepting();
798
+ receipt.commandRecorder = { stubDir: recorder.stubDir, recordFile: recorder.recordFile, proof: proof.channels,
799
+ scope: 'this process and every descendant — the PATH shim and the RUVNET_GH_COMMAND/RUVNET_GH_SCRIPT seam are both set on process.env' };
800
+ if (!proof.ok) {
801
+ phase('command-recorder-interception', 'FAIL', { reason: 'interception could not be proven', proof: proof.channels });
802
+ fail('command interception could not be proven — refusing to run a pipeline that can publish');
803
+ }
804
+ phase('command-recorder-interception', 'PASS', { channels: proof.channels });
805
+
806
+ if (tamper === 'bypass-interception') {
807
+ const bypass = recorder.demonstrateBypass();
808
+ receipt.commandRecorder.bypassDemonstration = bypass;
809
+ if (bypass.intercepted) {
810
+ phase('deliberate-bypass-detection', 'FAIL', { reason: 'the harness did NOT notice its interception was bypassed', bypass });
811
+ fail('deliberate bypass was not detected — the recorder cannot prove its own integrity');
812
+ }
813
+ phase('deliberate-bypass-detection', 'PASS', {
814
+ reason: `interception correctly detected as ABSENT under ${bypass.technique}`, bypass });
815
+ fail(`interception bypass demonstrated (--tamper bypass-interception): PATH reassignment defeated the shim and the harness failed closed rather than proceeding; real gh would have been ${bypass.resolvedGh}`);
816
+ }
817
+
818
+ // ---- phase 4: load the disposable checkout's own modules ---------------------------------
819
+ const load = (relative) => import(pathToFileURL(path.join(checkoutRoot, relative)).href);
820
+ const [reconcileMod, coverageMod, candidateMod, zipMod] = await Promise.all([
821
+ load('scripts/corpus-reconcile.mjs'), load('scripts/source-coverage.mjs'),
822
+ load('scripts/corpus-candidate.mjs'), load('kb/zip-extract.mjs'),
823
+ ]);
824
+ const api = {
825
+ assertBootstrapIdentity: reconcileMod.assertBootstrapIdentity,
826
+ normalizeExtractedCorpus: reconcileMod.normalizeExtractedCorpus,
827
+ seedPrivateFenceEvidence: reconcileMod.seedPrivateFenceEvidence,
828
+ syncCorpusInputs: reconcileMod.syncCorpusInputs,
829
+ acquireCorpusGeneration: reconcileMod.acquireCorpusGeneration,
830
+ reconcileAndPrepareCorpusCandidate: reconcileMod.reconcileAndPrepareCorpusCandidate,
831
+ prepareCorpusCandidate: reconcileMod.prepareCorpusCandidate,
832
+ observeSourceUniverse: coverageMod.observeSourceUniverse,
833
+ canonicalSourceObservation: coverageMod.canonicalSourceObservation,
834
+ buildCoverage: coverageMod.buildCoverage,
835
+ verifySeedBaseline: candidateMod.verifySeedBaseline,
836
+ extractZip: zipMod.extractZip,
837
+ };
838
+ for (const [name, value] of Object.entries(api)) {
839
+ if (typeof value !== 'function') fail(`the disposable checkout does not export ${name}`);
840
+ }
841
+ phase('load-pipeline-entrypoints', 'PASS', { entrypoints: Object.keys(api).length });
842
+
843
+ // ---- phase 5: real seed acquisition (or an explicit SKIP) --------------------------------
844
+ const descriptor = JSON.parse(fs.readFileSync(path.join(checkoutRoot, 'data', 'corpus-seed.json'), 'utf8'));
845
+ if (!HEX64.test(String(descriptor.sha256 || ''))) fail('data/corpus-seed.json does not pin a usable sha256');
846
+ const repoSlug = (String(run('git', ['remote', 'get-url', 'origin'], { cwd: sourceRoot }).stdout || '')
847
+ .trim().match(/github\.com[:/]+([^/]+\/[^/.]+)/) || [])[1] || 'stuinfla/ruvnet-brain';
848
+ const acquisition = acquireSeed({
849
+ descriptor, repo: repoSlug, downloadDir: path.join(workRoot, 'seed-download'),
850
+ env: { ...process.env, ...recorder.env },
851
+ localCandidates: [seedOverride, process.env.RUVNET_REHEARSAL_SEED,
852
+ path.join(os.homedir(), 'RVF-KnowledgeBases', 'corpus-20260912', 'seed', 'ruvnet-brain.zip')],
853
+ });
854
+ receipt.seedAcquisition = { descriptor, repository: repoSlug, ...acquisition };
855
+ if (!acquisition.ok) {
856
+ phase('real-seed-import', 'SKIP', { reason: acquisition.reason, attempts: acquisition.attempts });
857
+ receipt.verdict = 'FAIL';
858
+ receipt.skipped = 'real seed import unavailable; every downstream phase depends on it';
859
+ return { receipt, workRoot };
860
+ }
861
+ phase('real-seed-import', 'PASS', { channel: acquisition.channel, sha256: acquisition.sha256, bytes: acquisition.bytes,
862
+ reason: acquisition.channel === 'network' ? undefined
863
+ : 'the pinned release asset was unreachable; a local copy whose sha256 AND byte length equal the pinned descriptor exactly was used — same identity contract, not a substitute' });
864
+
865
+ // ---- phase 6..N: the generations ---------------------------------------------------------
866
+ let seed = { file: acquisition.file, tag: descriptor.tag, sha256: descriptor.sha256, bytes: descriptor.bytes,
867
+ channel: acquisition.channel, allowPinnedTag: true };
868
+ for (let index = 1; index <= generations; index += 1) {
869
+ if (index > 1) {
870
+ // Import candidate N as seed N+1 through the SAME baseline verifier the future consumer
871
+ // uses — the content-addressed tag, the archive bytes, and the schema-2 receipt together.
872
+ const baseline = await api.verifySeedBaseline({
873
+ seedDescriptor: { tag: seed.tag, sha256: seed.sha256, bytes: seed.bytes },
874
+ bundleFile: seed.file, receiptFile: seed.receiptFile,
875
+ });
876
+ phase(`generation-${index}-seed-baseline`, 'PASS', {
877
+ tag: baseline.tag, sha256: baseline.sha256, stores: baseline.receipt.storeCount,
878
+ reason: `candidate from generation ${index - 1} accepted as the generation ${index} seed — the chain closes` });
879
+ }
880
+ const result = await runGeneration({
881
+ index, api, checkoutRoot, workRoot, seed, recorder, tamper, log,
882
+ bounds: { repos, gists, owner, maxRounds, builderSha: checkout.head, forceRebuild, tamperGeneration },
883
+ });
884
+ receipt.generations.push(result.generation);
885
+ phase(`generation-${index}`, 'PASS', {
886
+ candidateSha256: result.generation.candidate.sha256,
887
+ stores: result.generation.candidate.storeCount,
888
+ durationMs: result.generation.reconciliation.durationMs });
889
+ seed = result.nextSeed;
890
+ }
891
+
892
+ // ---- final phase: isolation re-assertion --------------------------------------------------
893
+ const afterRepo = snapshotRepoState(sourceRoot);
894
+ const afterBrain = inventoryTree(installedBrainDir);
895
+ receipt.isolation.after = { repo: afterRepo.digest, installedBrain: afterBrain.digest, installedBrainEntries: afterBrain.entries };
896
+ receipt.isolation.workingTreeUnchanged = afterRepo.digest === beforeRepo.digest;
897
+ receipt.isolation.installedBrainUnchanged = afterBrain.digest === beforeBrain.digest;
898
+ if (!receipt.isolation.workingTreeUnchanged || !receipt.isolation.installedBrainUnchanged) {
899
+ // Strict by design — any difference is reported, including one a human made in another
900
+ // window while the rehearsal ran. So name the exact lines that moved: a bare boolean would
901
+ // make an unrelated concurrent edit indistinguishable from a pipeline that wrote outside its
902
+ // sandbox, and the whole point of this phase is to tell those two apart.
903
+ const before = new Set(beforeRepo.porcelain.split('\n'));
904
+ const after = new Set(afterRepo.porcelain.split('\n'));
905
+ receipt.isolation.workingTreeDelta = {
906
+ headBefore: beforeRepo.head, headAfter: afterRepo.head,
907
+ appeared: [...after].filter((line) => line && !before.has(line)),
908
+ disappeared: [...before].filter((line) => line && !after.has(line)),
909
+ };
910
+ phase('isolation-unchanged', 'FAIL', { reason: 'the real working tree or the installed brain changed during the rehearsal',
911
+ workingTreeUnchanged: receipt.isolation.workingTreeUnchanged,
912
+ installedBrainUnchanged: receipt.isolation.installedBrainUnchanged,
913
+ workingTreeDelta: receipt.isolation.workingTreeDelta });
914
+ fail('the real working tree or the installed brain changed during the rehearsal');
915
+ }
916
+ phase('isolation-unchanged', 'PASS', { workingTree: 'byte-unchanged', installedBrain: 'byte-unchanged' });
917
+
918
+ receipt.recordedPublicationCommands = recorder.recorded()
919
+ .filter((row) => row.classification === 'publication-mutation')
920
+ .map((row) => ({ tool: row.tool, command: row.args.join(' '), executed: row.executed, at: row.at }));
921
+ receipt.commandLedger = recorder.recorded().reduce((totals, row) => {
922
+ totals[row.classification] = (totals[row.classification] || 0) + 1;
923
+ return totals;
924
+ }, {});
925
+ receipt.verdict = receipt.phases.some(({ status }) => status === 'FAIL') ? 'FAIL' : 'PASS';
926
+ return { receipt, workRoot };
927
+ } catch (error) {
928
+ receipt.error = String(error?.message || error);
929
+ receipt.verdict = 'FAIL';
930
+ if (recorder) {
931
+ receipt.recordedPublicationCommands = recorder.recorded()
932
+ .filter((row) => row.classification === 'publication-mutation')
933
+ .map((row) => ({ tool: row.tool, command: row.args.join(' '), executed: row.executed, at: row.at }));
934
+ }
935
+ if (!receipt.phases.some(({ status }) => status === 'FAIL')) {
936
+ receipt.phases.push({ phase: 'rehearsal', status: 'FAIL', reason: receipt.error });
937
+ }
938
+ return { receipt, workRoot };
939
+ } finally {
940
+ if (restoreProcessEnv) {
941
+ for (const [key, value] of Object.entries(restoreProcessEnv)) {
942
+ if (value === undefined) delete process.env[key];
943
+ else process.env[key] = value;
944
+ }
945
+ }
946
+ receipt.finishedAt = new Date().toISOString();
947
+ receipt.durationMs = Date.now() - started;
948
+ receipt.workRoot = workRoot;
949
+ receipt.workRootRetained = keep;
950
+ if (!keep) {
951
+ try { fs.rmSync(workRoot, { recursive: true, force: true }); } catch { /* temp dir */ }
952
+ }
953
+ }
954
+ }
955
+
956
+ // ---------------------------------------------------------------------------------------------
957
+ // CLI
958
+ // ---------------------------------------------------------------------------------------------
959
+
960
+ function arg(argv, name, fallback = null) {
961
+ const index = argv.indexOf(name);
962
+ return index >= 0 && argv[index + 1] !== undefined ? argv[index + 1] : fallback;
963
+ }
964
+
965
+ export async function main(argv = process.argv.slice(2)) {
966
+ const receiptOut = arg(argv, '--receipt', null);
967
+ const { receipt } = await rehearseCorpusPipeline({
968
+ repos: Number(arg(argv, '--repos', 2)),
969
+ gists: Number(arg(argv, '--gists', 3)),
970
+ generations: Number(arg(argv, '--generations', 2)),
971
+ maxRounds: Number(arg(argv, '--max-rounds', 3)),
972
+ owner: arg(argv, '--owner', 'ruvnet'),
973
+ tamper: arg(argv, '--tamper', 'none'),
974
+ tamperGeneration: Number(arg(argv, '--tamper-generation', 1)),
975
+ forceRebuild: !argv.includes('--no-force-rebuild'),
976
+ seedOverride: arg(argv, '--seed', null),
977
+ keep: argv.includes('--keep'),
978
+ workRootParent: arg(argv, '--work-root', null),
979
+ });
980
+ const text = `${JSON.stringify(receipt, null, 2)}\n`;
981
+ if (receiptOut) {
982
+ fs.mkdirSync(path.dirname(path.resolve(receiptOut)), { recursive: true });
983
+ fs.writeFileSync(path.resolve(receiptOut), text);
984
+ }
985
+ process.stdout.write(text);
986
+ return receipt.verdict === 'PASS' ? 0 : 1;
987
+ }
988
+
989
+ if (((() => { try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })())) {
990
+ main().then((code) => { process.exitCode = code; }).catch((error) => {
991
+ console.error(error?.message || error);
992
+ process.exitCode = 1;
993
+ });
994
+ }