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.
- package/README.md +5 -5
- package/bin/install.mjs +275 -60
- package/console/app.js +141 -9
- package/console/index.html +51 -24
- package/console/scope.css +137 -0
- package/console/scope.html +144 -0
- package/console/scope.js +209 -0
- package/console/tips.html +1 -0
- package/kb/corpus-release-identity.mjs +239 -0
- package/kb/update-storage-transaction.mjs +20 -3
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +61 -0
- package/plugin/hooks/codex-hooks.json +64 -1
- package/plugin/hooks/hook-contracts.json +299 -6
- package/plugin/hooks/hooks.json +81 -1
- package/plugin/mcp/server.mjs +23 -0
- package/plugin/scripts/advocacy-catalog.mjs +245 -0
- package/plugin/scripts/advocacy-route.mjs +460 -0
- package/plugin/scripts/continuation-gate.mjs +25 -2
- package/plugin/scripts/continuation-objective.mjs +7 -1
- package/plugin/scripts/continuity-hook-policy.mjs +190 -15
- package/plugin/scripts/coverage-integrity.mjs +7 -0
- package/plugin/scripts/gates.mjs +113 -10
- package/plugin/scripts/grounding-turn-gate.mjs +167 -0
- package/plugin/scripts/grounding-turn-mark.mjs +91 -0
- package/plugin/scripts/hook-shim.mjs +14 -0
- package/plugin/scripts/nightly-scheduler.mjs +37 -4
- package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
- package/plugin/scripts/project-progression-contract.mjs +16 -0
- package/plugin/scripts/project-progression-hook.mjs +3 -0
- package/plugin/scripts/project-progression-producer.mjs +252 -0
- package/plugin/scripts/project-progression-reader.mjs +271 -0
- package/plugin/scripts/project-progression-session-start.mjs +93 -16
- package/plugin/scripts/project-progression-sources.mjs +220 -0
- package/plugin/scripts/project-progression-store.mjs +106 -13
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
- package/plugin/scripts/session-snapshot-hook.mjs +115 -7
- package/plugin/scripts/session-start-budget.mjs +59 -0
- package/plugin/scripts/session-start-core.mjs +234 -457
- package/plugin/scripts/session-start-fsutil.mjs +61 -0
- package/plugin/scripts/session-start-health.mjs +64 -0
- package/plugin/scripts/session-start-hook-description.mjs +45 -0
- package/plugin/scripts/session-start-issue-alert.mjs +77 -0
- package/plugin/scripts/session-start-repo-identity.mjs +54 -0
- package/plugin/scripts/session-start-signals.mjs +73 -0
- package/plugin/scripts/session-start-trace.mjs +86 -0
- package/plugin/scripts/session-start-update-plane.mjs +104 -0
- package/plugin/scripts/unprompted-runtime.mjs +32 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
- package/scripts/adr-072-completion.mjs +1 -1
- package/scripts/agentdb-fleet-doctor.mjs +5 -1
- package/scripts/approved-runtime.mjs +197 -0
- package/scripts/brain-novice-50.mjs +16 -1
- package/scripts/brain-score.mjs +23 -5
- package/scripts/build-bundle.mjs +971 -530
- package/scripts/build-concepts.mjs +36 -116
- package/scripts/console-engine.test.mjs +8 -7
- package/scripts/console-runtime-identity.mjs +4 -0
- package/scripts/corpus-aggregates.mjs +94 -77
- package/scripts/corpus-candidate.mjs +475 -222
- package/scripts/corpus-next-seed.mjs +225 -0
- package/scripts/corpus-promotion.mjs +58 -0
- package/scripts/corpus-reconcile.mjs +411 -105
- package/scripts/doc-currency.mjs +16 -1
- package/scripts/dual-host-deliberation.mjs +25 -2
- package/scripts/dual-host-suggest.mjs +17 -1
- package/scripts/falsify.mjs +13 -3
- package/scripts/gist-receipts.mjs +482 -87
- package/scripts/github-health-watch.mjs +12 -2
- package/scripts/handoff-asset.mjs +34 -0
- package/scripts/hook-retirement-check.mjs +8 -1
- package/scripts/host-registry.mjs +1 -1
- package/scripts/ingest-gists.mjs +74 -101
- package/scripts/job-heartbeat.sh +77 -14
- package/scripts/learning-replay-execution.mjs +10 -4
- package/scripts/nightly-gists.sh +27 -13
- package/scripts/nightly-two-run-proof.mjs +1 -1
- package/scripts/nightly-watchdog.mjs +61 -4
- package/scripts/onboarding-console.mjs +319 -27
- package/scripts/oracle/produce-questions.mjs +293 -0
- package/scripts/oracle/producer-hosts.mjs +235 -0
- package/scripts/oracle/repo-recall.mjs +448 -0
- package/scripts/oracle/retrieval-accuracy.mjs +818 -0
- package/scripts/oracle/source-tree.mjs +165 -0
- package/scripts/oracle/source-units.mjs +391 -0
- package/scripts/oracle/spike-run.mjs +98 -0
- package/scripts/oracle/unit-inventory.mjs +141 -0
- package/scripts/oracle/unit-sampling.mjs +128 -0
- package/scripts/oracle/validate-labels.mjs +250 -0
- package/scripts/private-overlay.mjs +248 -0
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/proxy/claude-proxied.sh +6 -0
- package/scripts/proxy/proxy-revert.sh +5 -0
- package/scripts/proxy/proxy-up.sh +6 -0
- package/scripts/proxy/proxy-verify.mjs +4 -0
- package/scripts/public-inputs.mjs +409 -0
- package/scripts/public-verification-inputs.mjs +112 -26
- package/scripts/public-verification-lane.mjs +1 -1
- package/scripts/published-surface-probe.mjs +34 -4
- package/scripts/qe/card-lane-gate.mjs +16 -1
- package/scripts/qe/session-start-gate.mjs +16 -1
- package/scripts/rebuild-gists-from-receipts.mjs +58 -78
- package/scripts/record-lesson.mjs +4 -1
- package/scripts/rehearse-corpus-pipeline.mjs +994 -0
- package/scripts/release-abort-stale.mjs +5 -1
- package/scripts/release-authority.mjs +104 -12
- package/scripts/release-channel-kind.mjs +86 -0
- package/scripts/release-convergence-watchdog.mjs +7 -2
- package/scripts/release-projection.mjs +177 -72
- package/scripts/release-transaction-provider.mjs +23 -6
- package/scripts/release.mjs +252 -17
- package/scripts/retrieval-canary.mjs +87 -0
- package/scripts/rvf-index-audit.mjs +573 -13
- package/scripts/rvf-wire.mjs +269 -0
- package/scripts/seal-gist-receipt.mjs +65 -0
- package/scripts/selfcheck.mjs +42 -21
- package/scripts/source-coverage.mjs +253 -24
- package/scripts/status-honesty.mjs +25 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-version.mjs +2 -0
- package/scripts/trismart.mjs +42 -0
- package/scripts/updater-manifest.mjs +162 -0
- package/scripts/verify-channels.mjs +17 -5
- package/scripts/wired-check.mjs +48 -10
- package/tri-smart-skill/QUICKSTART.md +37 -0
- package/tri-smart-skill/README.md +92 -0
- package/tri-smart-skill/install.cmd +14 -0
- package/tri-smart-skill/install.command +13 -0
- package/tri-smart-skill/install.mjs +51 -0
- package/tri-smart-skill/install.sh +9 -0
- package/tri-smart-skill/tri-smart/SKILL.md +90 -0
- package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
- package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
- package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
- package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
- package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
- package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
- 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
|
+
}
|