@tachikomagundam/abathur 0.1.0

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 (119) hide show
  1. package/.github/workflows/ci.yml +29 -0
  2. package/.github/workflows/publish.yml +74 -0
  3. package/LICENSE +21 -0
  4. package/README.md +461 -0
  5. package/config/abathur.jsonc +17 -0
  6. package/config/genomes/historian.example.jsonc +124 -0
  7. package/dist/bench/adapter.js +201 -0
  8. package/dist/bench/fixture-probe.js +92 -0
  9. package/dist/bench/fixture-support.js +173 -0
  10. package/dist/bench/fixture.js +236 -0
  11. package/dist/bench/toy.js +152 -0
  12. package/dist/cli.js +110 -0
  13. package/dist/commands/bundle.js +79 -0
  14. package/dist/commands/genome.js +94 -0
  15. package/dist/commands/graft.js +71 -0
  16. package/dist/commands/kernel.js +47 -0
  17. package/dist/commands/promote.js +25 -0
  18. package/dist/commands/run.js +145 -0
  19. package/dist/commands/self-eval.js +240 -0
  20. package/dist/commands/status.js +186 -0
  21. package/dist/commands/tombstone.js +72 -0
  22. package/dist/config.js +161 -0
  23. package/dist/core/bundle-common.js +119 -0
  24. package/dist/core/bundle-export.js +212 -0
  25. package/dist/core/bundle-inspect.js +143 -0
  26. package/dist/core/bundle-manifest.js +105 -0
  27. package/dist/core/bundle-mask.js +75 -0
  28. package/dist/core/bundle-tar.js +240 -0
  29. package/dist/core/bundle.js +9 -0
  30. package/dist/core/evolve/brief.js +45 -0
  31. package/dist/core/evolve/candidate.js +140 -0
  32. package/dist/core/evolve/child-track.js +197 -0
  33. package/dist/core/evolve/friction.js +150 -0
  34. package/dist/core/evolve/reflect.js +191 -0
  35. package/dist/core/evolve/run-bench.js +170 -0
  36. package/dist/core/evolve/run-friction.js +63 -0
  37. package/dist/core/evolve/run-loop.js +282 -0
  38. package/dist/core/evolve/run-plan.js +39 -0
  39. package/dist/core/evolve/run-rows.js +145 -0
  40. package/dist/core/evolve/self-overlay.js +213 -0
  41. package/dist/core/evolve/self-snapshot.js +170 -0
  42. package/dist/core/evolve/stub-mutators.mjs +105 -0
  43. package/dist/core/evolve/udiff.js +189 -0
  44. package/dist/core/genome-paths.js +76 -0
  45. package/dist/core/genome.js +176 -0
  46. package/dist/core/glob.js +106 -0
  47. package/dist/core/graft-gates.js +184 -0
  48. package/dist/core/graft-rebench.js +187 -0
  49. package/dist/core/graft-support.js +181 -0
  50. package/dist/core/graft.js +218 -0
  51. package/dist/core/ids.js +154 -0
  52. package/dist/core/incumbent.js +46 -0
  53. package/dist/core/kernel.js +112 -0
  54. package/dist/core/ledger.js +198 -0
  55. package/dist/core/locks.js +172 -0
  56. package/dist/core/promote.js +119 -0
  57. package/dist/core/snapshot.js +61 -0
  58. package/dist/core/spec.js +178 -0
  59. package/dist/core/stats-math.js +102 -0
  60. package/dist/core/stats-pareto.js +57 -0
  61. package/dist/core/stats.js +184 -0
  62. package/dist/core/worktree.js +190 -0
  63. package/dist/exit.js +32 -0
  64. package/dist/genomes/toy-smoke/genome.jsonc +30 -0
  65. package/dist/genomes/toy-smoke/grader.mjs +61 -0
  66. package/dist/genomes/toy-smoke/init.mjs +63 -0
  67. package/dist/genomes/toy-smoke/units/add.mjs +17 -0
  68. package/dist/genomes/toy-smoke/units/explode.mjs +4 -0
  69. package/dist/genomes/toy-smoke/units/hang.mjs +16 -0
  70. package/dist/genomes/toy-smoke/units/mul.mjs +16 -0
  71. package/dist/genomes/toy-smoke/units/mutate.mjs +18 -0
  72. package/dist/genomes/toy-smoke/units/sub.mjs +16 -0
  73. package/dist/jsonc.js +77 -0
  74. package/dist/out.js +5 -0
  75. package/dist/test/bench-adapter.test.js +33 -0
  76. package/dist/test/bench-fixture.test.js +407 -0
  77. package/dist/test/bench-toy.test.js +251 -0
  78. package/dist/test/bundle.test.js +659 -0
  79. package/dist/test/config.test.js +185 -0
  80. package/dist/test/d7-gate.test.js +56 -0
  81. package/dist/test/fixture-loop.test.js +267 -0
  82. package/dist/test/fixtures/friction-writer.js +16 -0
  83. package/dist/test/fixtures-historian.js +82 -0
  84. package/dist/test/fixtures-self.js +143 -0
  85. package/dist/test/fixtures-wt.js +64 -0
  86. package/dist/test/friction.test.js +398 -0
  87. package/dist/test/genome.test.js +453 -0
  88. package/dist/test/git.test.js +69 -0
  89. package/dist/test/graft.test.js +567 -0
  90. package/dist/test/historian-genome.test.js +134 -0
  91. package/dist/test/historian-grader-io.test.js +148 -0
  92. package/dist/test/historian-grader.test.js +209 -0
  93. package/dist/test/ids.test.js +116 -0
  94. package/dist/test/include-val.test.js +120 -0
  95. package/dist/test/ledger-lock.test.js +99 -0
  96. package/dist/test/ledger.test.js +102 -0
  97. package/dist/test/promote.test.js +394 -0
  98. package/dist/test/reflect.test.js +410 -0
  99. package/dist/test/run-loop.test.js +433 -0
  100. package/dist/test/self-snapshot.test.js +328 -0
  101. package/dist/test/snapshot.test.js +86 -0
  102. package/dist/test/stats.test.js +423 -0
  103. package/dist/test/stub-mutators.test.js +17 -0
  104. package/dist/test/testutil.js +30 -0
  105. package/dist/test/worktree.test.js +198 -0
  106. package/dist/util/freeze.js +30 -0
  107. package/dist/util/git.js +85 -0
  108. package/docs/federation.md +184 -0
  109. package/docs/immutable-kernel.md +87 -0
  110. package/graders/historian/grader-core.d.mts +53 -0
  111. package/graders/historian/grader-core.mjs +276 -0
  112. package/graders/historian/grader-support.d.mts +57 -0
  113. package/graders/historian/grader-support.mjs +137 -0
  114. package/graders/historian/grader.mjs +113 -0
  115. package/graders/historian/mutate.sh +114 -0
  116. package/graders/historian/reset-sandbox.sh +60 -0
  117. package/graders/historian/run-scenario.sh +49 -0
  118. package/graders/historian/seed-wrapped.sh +32 -0
  119. package/package.json +42 -0
@@ -0,0 +1,659 @@
1
+ // Todo 12 AC pins (plan lines 165-172): lineage bundles — export + inspect with
2
+ // provenance + redacted train-only evidence.
3
+ // AC happy: export a toy promoted lineage → inspect passes byte-for-byte,
4
+ // re-export is deterministic;
5
+ // AC tamper: flip one contained file byte → inspect exit 1 naming path + expected/actual sha;
6
+ // AC mask-1: /opt/wiki-ops planted UNdeclared in rationale AND a transcript → export exits 1
7
+ // naming member+line PRE-WRITE; no .tgz ever exists in --out afterwards;
8
+ // AC mask-2: same literal DECLARED via spec bundle.maskLiterals → export/inspect pass,
9
+ // bundle members carry the <MASKED-1> placeholder instead;
10
+ // AC evidence: evidence/ carries train unit runIds only — a planted val transcript is
11
+ // structurally excluded (export walks only train runIds);
12
+ // AC lineage: manifest.genome.fingerprint != fingerprint(contained tree spec) (wrong-genome
13
+ // import) → exit 1; unknown digest_algo → exit 2; garbage/truncated/evil-traversal
14
+ // tar → exit 2, never a stack trace;
15
+ // AC digest: manifest.benchDigest != digest recomputed from contained tree → exit 1;
16
+ // dirty_worktree: uncommitted worktree edit → tree members + files[] follow the COMMIT,
17
+ // never the worktree; --last N = N newest candidate rows, primary = max genId.
18
+ import assert from "node:assert/strict";
19
+ import { createHash } from "node:crypto";
20
+ import { spawnSync } from "node:child_process";
21
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, chmodSync } from "node:fs";
22
+ import { mkdtemp } from "node:fs/promises";
23
+ import * as os from "node:os";
24
+ import path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import test, {} from "node:test";
27
+ import { Ledger } from "../core/ledger.js";
28
+ import { benchDigest, fingerprint, genId } from "../core/ids.js";
29
+ import { newGeneration, openGenome, sealGeneration } from "../core/worktree.js";
30
+ import { registerGenome, requireGenomesByLabel, fingerprint16 } from "../core/genome.js";
31
+ import { decodeGenerationRecord } from "../core/evolve/run-bench.js";
32
+ import { runEvolution } from "../core/evolve/run-loop.js";
33
+ import { prepareToyGenome } from "../bench/toy.js";
34
+ import { loadGenomeSpecFile } from "../core/spec.js";
35
+ import { readTar, writeTar, TarError } from "../core/bundle-tar.js";
36
+ import { buildMaskPlan, scanMemberLeaks } from "../core/bundle-mask.js";
37
+ const CLI = fileURLToPath(new URL("../../dist/cli.js", import.meta.url));
38
+ const WIKI_LITERAL = "/opt/wiki-ops";
39
+ const ADD_ANCHOR = "return a - b; // seeded bug: must be `return a + b;`";
40
+ const STUB_SOURCE = `#!/usr/bin/env node
41
+ import { readFileSync } from "node:fs";
42
+ const args = process.argv.slice(2);
43
+ const opt = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
44
+ const mode = opt("--mode");
45
+ const dir = opt("--dir");
46
+ const read = (f) => readFileSync(dir + "/" + f, "utf8");
47
+ function lineDiff(file, anchor, replacement) {
48
+ const lines = read(file).split("\\n");
49
+ const i = lines.findIndex((l) => l.includes(anchor));
50
+ if (i < 0) { process.stderr.write("stub: no anchor in " + file + "\\n"); process.exit(1); }
51
+ const changed = lines[i].replace(anchor, replacement);
52
+ return "--- a/" + file + "\\n+++ b/" + file + "\\n@@ -" + String(i + 1) + ",1 +" + String(i + 1) + ",1 @@\\n-" + lines[i] + "\\n+" + changed + "\\n";
53
+ }
54
+ const FIX = ${JSON.stringify(ADD_ANCHOR)};
55
+ const out = (candidates) => process.stdout.write(JSON.stringify({ candidates }) + "\\n");
56
+ if (mode === "three") {
57
+ out([
58
+ { id: "annotate-add", rationale: "annotate add()", diffs: [lineDiff("units/add.mjs", "export function add(", "export function add /* patched */(")] },
59
+ { id: "fix-add", rationale: "fix add(): subtraction to addition", diffs: [lineDiff("units/add.mjs", FIX, "return a + b;")] },
60
+ ]);
61
+ } else if (mode === "leaky") {
62
+ out([
63
+ { id: "fix-add", rationale: "fix add() per runbook " + ${JSON.stringify(WIKI_LITERAL)} + "/secrets", diffs: [lineDiff("units/add.mjs", FIX, "return a + b;")] },
64
+ ]);
65
+ } else {
66
+ process.stderr.write("stub: unknown mode\\n");
67
+ process.exit(2);
68
+ }
69
+ `;
70
+ async function bundleFixture(t, opts = {}) {
71
+ const root = await mkdtemp(path.join(os.tmpdir(), "abathur-bundle-"));
72
+ t.after(() => {
73
+ spawnSync("rm", ["-rf", root], { encoding: "utf8" });
74
+ });
75
+ const configDir = path.join(root, "config");
76
+ mkdirSync(configDir, { recursive: true });
77
+ writeFileSync(path.join(configDir, "config.jsonc"), '{ "opencodeBin": null }\n', "utf8");
78
+ const repo = await prepareToyGenome(path.join(root, "genome"));
79
+ if (opts.maskLiterals !== undefined) {
80
+ const spec = loadGenomeSpecFile(path.join(repo, "genome.jsonc"));
81
+ writeFileSync(path.join(repo, "genome.jsonc"), `${JSON.stringify({ ...spec, bundle: { maskLiterals: [...opts.maskLiterals] } }, null, 2)}\n`, "utf8");
82
+ // commit the edit: export self-describes from the gen COMMIT tree, so the
83
+ // registry spec and the committed spec must agree by construction.
84
+ gitC(repo, "-c", "user.name=abathur", "-c", "user.email=abathur@harness.local", "commit", "-am", "bundle: declare maskLiterals");
85
+ }
86
+ registerGenome(configDir, path.join(repo, "genome.jsonc"));
87
+ const entry = requireGenomesByLabel(configDir, "toy-smoke").entries[0];
88
+ if (entry === undefined)
89
+ throw new Error("fixture: toy-smoke registration vanished");
90
+ const stub = path.join(root, "stub.mjs");
91
+ writeFileSync(stub, STUB_SOURCE, "utf8");
92
+ chmodSync(stub, 0o755);
93
+ const env = { XDG_CACHE_HOME: path.join(root, "xdg-cache"), HOME: path.join(root, "home") };
94
+ return { root, configDir, repo, entry, env, wopts: { env } };
95
+ }
96
+ function cliEnv(f) {
97
+ return {
98
+ ...process.env,
99
+ ABATHUR_CONFIG: path.join(f.configDir, "config.jsonc"),
100
+ HOME: path.join(f.root, "home"),
101
+ XDG_CACHE_HOME: path.join(f.root, "xdg-cache"),
102
+ };
103
+ }
104
+ function cli(f, ...args) {
105
+ return spawnSync(process.execPath, [CLI, ...args], { env: cliEnv(f), encoding: "utf8", cwd: f.root });
106
+ }
107
+ function gitC(repo, ...args) {
108
+ const run = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
109
+ if (run.status !== 0)
110
+ throw new Error(`git ${args.join(" ")}: ${run.stderr}`);
111
+ return run.stdout;
112
+ }
113
+ async function evolve(f, mode = "three") {
114
+ const outcome = await runEvolution({
115
+ entry: f.entry,
116
+ configDir: f.configDir,
117
+ mutatorCommand: `node ${path.join(f.root, "stub.mjs")} --mode ${mode} --dir {worktree} --brief {brief}`,
118
+ env: f.env,
119
+ });
120
+ assert.equal(outcome.exitCode, 0, `fixture evolution must succeed: ${outcome.lines.join("\n")}`);
121
+ return genRows(f);
122
+ }
123
+ function genRows(f) {
124
+ return Ledger.open(f.repo)
125
+ .readAll()
126
+ .filter((r) => r.kind === "generation_complete" && r.genId !== undefined)
127
+ .map((r) => ({ genId: r.genId, data: decodeGenerationRecord(r) }));
128
+ }
129
+ function candidateRows(f) {
130
+ return genRows(f).filter((r) => r.data.source === "candidate");
131
+ }
132
+ /** Plant a bench transcript exactly where the fixture adapter would have left it. */
133
+ function plantTranscript(f, genIdStr, unitId, rep, lines) {
134
+ const dir = path.join(f.configDir, "bench-sandboxes", "a-20260101T000000Z-fixture", genIdStr, `${unitId}-${String(rep)}`, ".bench", "transcripts");
135
+ mkdirSync(dir, { recursive: true });
136
+ const file = path.join(dir, `${unitId}.jsonl`);
137
+ writeFileSync(file, `${lines.join("\n")}\n`, "utf8");
138
+ return file;
139
+ }
140
+ function bundleDir(f) {
141
+ const dir = path.join(f.root, "bundles");
142
+ mkdirSync(dir, { recursive: true });
143
+ return dir;
144
+ }
145
+ function listBundles(f) {
146
+ return readdirSync(bundleDir(f)).filter((n) => n.endsWith(".bundle.tgz")).sort();
147
+ }
148
+ function readBundle(f, name) {
149
+ return readFileSync(path.join(bundleDir(f), name));
150
+ }
151
+ function membersOf(bytes) {
152
+ return new Map(readTar(bytes).map((m) => [m.path, m.content]));
153
+ }
154
+ function repack(f, name, members) {
155
+ writeFileSync(path.join(bundleDir(f), name), gzip(writeTar(members)));
156
+ }
157
+ import { gunzipSync, gzipSync } from "node:zlib";
158
+ const gzip = (u) => new Uint8Array(gzipSync(u));
159
+ function ungzip(f, name) {
160
+ return new Uint8Array(gunzipSync(readBundle(f, name)));
161
+ }
162
+ function manifestOf(f, name) {
163
+ const m = membersOf(ungzip(f, name)).get("manifest.json");
164
+ if (m === undefined)
165
+ throw new Error("bundle has no manifest.json");
166
+ return JSON.parse(new TextDecoder().decode(m));
167
+ }
168
+ const sha256Hex = (b) => createHash("sha256").update(b).digest("hex");
169
+ /** Replace/insert one member; rePin=false leaves the manifest pins stale (byte-flip AC). */
170
+ function tamperMember(f, name, memberPath, content, rePin = true) {
171
+ const members = readTar(ungzip(f, name));
172
+ const next = members
173
+ .filter((m) => m.path !== memberPath)
174
+ .concat([{ path: memberPath, content }]);
175
+ if (memberPath !== "manifest.json" && rePin) {
176
+ const manifest = members.find((m) => m.path === "manifest.json");
177
+ if (manifest === undefined)
178
+ throw new Error("bundle has no manifest.json");
179
+ const doc = JSON.parse(new TextDecoder().decode(manifest.content));
180
+ const entry = doc.files.find((x) => x.path === memberPath);
181
+ if (entry !== undefined) {
182
+ entry.sha256 = sha256Hex(content);
183
+ entry.len = content.length;
184
+ const rewritten = new TextEncoder().encode(`${JSON.stringify(doc, null, 2)}\n`);
185
+ repack(f, name, next.map((m) => (m.path === "manifest.json" ? { path: m.path, content: rewritten } : m)));
186
+ return;
187
+ }
188
+ }
189
+ repack(f, name, next);
190
+ }
191
+ /** Hand-built tar: writer refuses traversal, so craft the evil header directly. */
192
+ function tarWithRawHeader(badName) {
193
+ const enc = new TextEncoder();
194
+ const header = new Uint8Array(512);
195
+ header.set(enc.encode(badName), 0);
196
+ header.set(enc.encode("0000644\x00"), 100);
197
+ header.set(enc.encode("0000000\x00"), 108);
198
+ header.set(enc.encode("0000000\x00"), 116);
199
+ header.set(enc.encode("00000000001\x00"), 124);
200
+ header.set(enc.encode("00000000000\x00"), 136);
201
+ header.set(new Uint8Array(8).fill(0x20), 148);
202
+ header[156] = 0x30;
203
+ header.set(enc.encode("ustar\x00"), 257);
204
+ header.set(enc.encode("00"), 263);
205
+ let sum = 0;
206
+ for (const b of header)
207
+ sum += b;
208
+ header.set(enc.encode((sum & 0o777777).toString(8).padStart(6, "0") + "\0 "), 148);
209
+ const blocks = [header, new Uint8Array(512).fill(0x78), new Uint8Array(512), new Uint8Array(512)];
210
+ const out = new Uint8Array(512 * 4);
211
+ let off = 0;
212
+ for (const b of blocks) {
213
+ out.set(b, off);
214
+ off += 512;
215
+ }
216
+ return out;
217
+ }
218
+ // ------------------------------------------------------------- pure units
219
+ test("ids.benchDigest: reorder-stable, content-sensitive, config-sensitive", () => {
220
+ const base = {
221
+ units: [
222
+ { unitId: "a", content: "AAA" },
223
+ { unitId: "b", content: "BBB" },
224
+ ],
225
+ scripts: [{ path: "grader.mjs", content: "grade" }],
226
+ graderCommand: "node grader.mjs {unit.path}",
227
+ runCommand: "node {unit.path}",
228
+ timeoutS: 10,
229
+ };
230
+ const d1 = benchDigest(base);
231
+ const d2 = benchDigest({ ...base, units: [...base.units].reverse() });
232
+ assert.equal(d1, d2, "unit order must not move the digest");
233
+ assert.match(d1, /^[0-9a-f]{64}$/);
234
+ assert.notEqual(d1, benchDigest({ ...base, scripts: [{ path: "grader.mjs", content: "grade v2" }] }), "grader content must move the digest");
235
+ assert.notEqual(d1, benchDigest({ ...base, scripts: [...base.scripts, { path: "seed.mjs", content: "s1" }] }), "seed script content must move the digest");
236
+ assert.notEqual(d1, benchDigest({ ...base, timeoutS: 11 }), "timeout must move the digest");
237
+ assert.notEqual(d1, benchDigest({ ...base, agentModel: "m1", judgeModel: "j1", judgeCommand: "node judge.mjs" }), "models/judge must move the digest");
238
+ });
239
+ test("bundle-tar: roundtrip, long names, traversal + garbage refused", () => {
240
+ const members = [
241
+ { path: "manifest.json", content: new TextEncoder().encode("{}\n") },
242
+ { path: "trees/g-x/units/add.mjs", content: new TextEncoder().encode("export function add(a: number, b: number) { return a + b; }\n") },
243
+ { path: `deep/dir/${"x".repeat(120)}/file.txt`, content: new TextEncoder().encode("ok\n") },
244
+ ];
245
+ const bytes = writeTar(members);
246
+ const back = readTar(bytes);
247
+ assert.deepEqual(back.map((m) => m.path), members.map((m) => m.path));
248
+ assert.deepEqual(Buffer.from(back[1]?.content ?? new Uint8Array()), Buffer.from(members[1]?.content ?? new Uint8Array()));
249
+ assert.throws(() => writeTar([{ path: "../evil", content: new Uint8Array() }]), TarError);
250
+ assert.throws(() => writeTar([{ path: "/etc/passwd", content: new Uint8Array() }]), TarError);
251
+ assert.throws(() => readTar(new Uint8Array([1, 2, 3, 4, 5])), TarError);
252
+ const good = writeTar(members);
253
+ assert.throws(() => readTar(good.slice(0, good.length - 17)), TarError, "truncated tar must be refused");
254
+ const corrupt = Uint8Array.from(good);
255
+ corrupt[148] = corrupt[148] === 0x31 ? 0x32 : 0x31; // flip a header byte → bad checksum
256
+ assert.throws(() => readTar(corrupt), TarError);
257
+ });
258
+ test("bundle-tar: non-ASCII member names round-trip byte-exact (PAX override, not USTAR mangling)", () => {
259
+ const members = [
260
+ { path: "manifest.json", content: new TextEncoder().encode("{}\n") },
261
+ { path: "ünï/данные.txt", content: new TextEncoder().encode("unicode member ✓\n") },
262
+ { path: `ünï/${"данные".repeat(12)}.txt`, content: new TextEncoder().encode("short non-ascii\n") },
263
+ { path: `${"ünï".repeat(30)}/leaf-ü.txt`, content: new TextEncoder().encode("split-prefixed non-ascii\n") },
264
+ ];
265
+ const back = readTar(writeTar(members));
266
+ assert.deepEqual(back.map((m) => m.path), members.map((m) => m.path));
267
+ for (const want of members) {
268
+ const got = back.find((m) => m.path === want.path);
269
+ assert.ok(got !== undefined, `member ${want.path} missing after roundtrip`);
270
+ assert.deepEqual(Buffer.from(got.content), Buffer.from(want.content), `${want.path} content must be byte-exact`);
271
+ }
272
+ });
273
+ test("bundle-mask: declared literals → placeholders; undeclared machine paths leak-flag with line", () => {
274
+ const plan = buildMaskPlan({ home: "/srv/home-alice", repoPath: "/work/genome", extra: [WIKI_LITERAL] });
275
+ const masked = plan.mask("at /srv/home-alice/x in /work/genome and see /opt/wiki-ops/runbook");
276
+ assert.equal(masked, "at <HOME>/x in <GENOME> and see <MASKED-1>/runbook");
277
+ // undeclared literal is a leak; declared one is clean
278
+ const leak = scanMemberLeaks("line one\nsecond /srv/home-alice line", plan, "/home/other", "/work/other");
279
+ assert.ok(leak !== null && leak !== undefined);
280
+ assert.equal(leak.line, 2);
281
+ assert.match(leak.snippet, /\/srv\/home-alice/);
282
+ // after masking, the same text is clean
283
+ assert.equal(scanMemberLeaks(plan.mask(`line one\nsecond ${WIKI_LITERAL} line`), plan, "/home/other", "/work/other"), null);
284
+ // a DECLARED literal surviving masking is still a leak (fail-closed double gate)
285
+ assert.ok(scanMemberLeaks(`second ${WIKI_LITERAL} line`, plan, "/home/other", "/work/other") !== null);
286
+ assert.equal(scanMemberLeaks("plain text", plan, "/home/other", "/work/other"), null);
287
+ // a generic machine path that is NOT a declared literal still leaks (fail-closed)
288
+ assert.ok(scanMemberLeaks("x /etc/shadow", plan, "/home/other", "/work/other") !== null);
289
+ });
290
+ // --------------------------------------------------------- AC: happy path
291
+ test("AC(happy): export promoted lineage → inspect OK, manifest exact, deterministic re-export", async (t) => {
292
+ const f = await bundleFixture(t);
293
+ const rows = await evolve(f);
294
+ const nominated = rows.find((r) => r.data.verdict === "nominated");
295
+ assert.ok(nominated !== undefined && nominated.data.commitSha !== undefined);
296
+ assert.equal(cli(f, "promote", "toy-smoke", nominated.genId).status, 0);
297
+ const out = bundleDir(f);
298
+ const run = cli(f, "bundle", "export", "toy-smoke", "--gen", nominated.genId, "--out", out);
299
+ assert.equal(run.status, 0, `export: ${run.stderr}`);
300
+ const expected = `abathur-${f.entry.fingerprint}-${nominated.genId}.bundle.tgz`;
301
+ assert.deepEqual(listBundles(f), [expected]);
302
+ assert.match(run.stdout, new RegExp(expected));
303
+ const insp = cli(f, "bundle", "inspect", path.join(out, expected));
304
+ assert.equal(insp.status, 0, `inspect: ${insp.stderr}${insp.stdout}`);
305
+ assert.match(insp.stdout, /inspect.*OK/i);
306
+ const m = manifestOf(f, expected);
307
+ assert.deepEqual(Object.keys(m), [
308
+ "schema_version", "digest_algo", "genome", "parent", "benchDigest", "benchProvenance",
309
+ "budgetCounters", "stats", "sealedGlobs", "files", "rationale", "frictionDigests",
310
+ ]);
311
+ assert.equal(m.schema_version, 1);
312
+ assert.equal(m.digest_algo, "sha256-canonical-v1");
313
+ assert.deepEqual(Object.keys(m.benchProvenance), [
314
+ "opencodeVersion", "agentModel", "adapterConfigDigest", "fixtureSeedId",
315
+ "judgeModel", "mutatorModel", "nRepeats", "statsConfigDigest",
316
+ ]);
317
+ assert.equal(m.genome.label, "toy-smoke");
318
+ assert.equal(m.genome.fingerprint, fingerprint(f.entry.spec));
319
+ assert.equal(m.parent, nominated.data.headCommit);
320
+ assert.equal(m.rationale, nominated.data.rationale);
321
+ assert.deepEqual(m.budgetCounters, nominated.data.counters);
322
+ assert.deepEqual(m.stats.matrix, nominated.data.units);
323
+ assert.deepEqual(m.sealedGlobs, ["grader.mjs"]);
324
+ assert.deepEqual(m.frictionDigests, []);
325
+ const members = membersOf(ungzip(f, expected));
326
+ // every non-manifest member is sha-pinned in files[] and hashes true
327
+ const files = m.files;
328
+ const pinned = new Set(files.map((x) => x.path));
329
+ for (const mpath of members.keys()) {
330
+ if (mpath === "manifest.json")
331
+ continue;
332
+ assert.ok(pinned.has(mpath), `member ${mpath} must be pinned`);
333
+ }
334
+ for (const x of files) {
335
+ const mem = members.get(x.path);
336
+ assert.ok(mem !== undefined, `pinned member ${x.path} missing from bundle`);
337
+ assert.equal(x.sha256, sha256Hex(mem));
338
+ assert.equal(x.len, mem.length);
339
+ }
340
+ assert.ok(members.has("README.md"));
341
+ assert.ok(members.has(`trees/${nominated.genId}/units/add.mjs`));
342
+ assert.ok(members.has("patch.diff"));
343
+ // deterministic re-export: byte-identical bundle
344
+ const first = readBundle(f, expected);
345
+ assert.equal(cli(f, "bundle", "export", "toy-smoke", "--gen", nominated.genId, "--out", out).status, 0);
346
+ assert.deepEqual(Buffer.from(readBundle(f, expected)), Buffer.from(first), "re-export must be byte-identical");
347
+ });
348
+ test("AC(tamper): one flipped byte inside patch.diff → inspect exit 1 naming path + expected/actual sha", async (t) => {
349
+ const f = await bundleFixture(t);
350
+ const rows = await evolve(f);
351
+ const nominated = rows.find((r) => r.data.verdict === "nominated");
352
+ assert.ok(nominated !== undefined);
353
+ assert.equal(cli(f, "bundle", "export", "toy-smoke", "--gen", nominated.genId, "--out", bundleDir(f)).status, 0);
354
+ const name = listBundles(f)[0];
355
+ const original = readTar(ungzip(f, name)).find((m) => m.path === "patch.diff");
356
+ assert.ok(original !== undefined);
357
+ const flipped = Uint8Array.from(original.content);
358
+ flipped[10] = flipped[10] === 0x61 ? 0x62 : 0x61; // flip a byte, keep the length identical
359
+ tamperMember(f, name, "patch.diff", flipped, false);
360
+ const insp = cli(f, "bundle", "inspect", path.join(bundleDir(f), name));
361
+ assert.equal(insp.status, 1);
362
+ const err = insp.stderr + insp.stdout;
363
+ assert.match(err, /patch\.diff/);
364
+ assert.match(err, new RegExp(`expected ${sha256Hex(original.content)}`));
365
+ assert.match(err, new RegExp(`actual ${sha256Hex(flipped)}`));
366
+ });
367
+ // ------------------------------------------------------------ AC: masking
368
+ test("AC(pre-write refusal): undeclared /opt/wiki-ops in rationale AND transcript → exit 1, nothing written", async (t) => {
369
+ const f = await bundleFixture(t);
370
+ const rows = await evolve(f, "leaky");
371
+ const gen = rows[rows.length - 1];
372
+ assert.ok(gen !== undefined);
373
+ plantTranscript(f, gen.genId, "add", 0, [
374
+ '{"role":"user","text":"hi"}',
375
+ `{"role":"assistant","text":"consult ${WIKI_LITERAL}/runbook"}`,
376
+ ]);
377
+ const run = cli(f, "bundle", "export", "toy-smoke", "--gen", gen.genId, "--out", bundleDir(f));
378
+ assert.equal(run.status, 1, `leaking export must be refused: ${run.stdout}`);
379
+ const err = run.stderr;
380
+ assert.match(err, /manifest\.json:\d+/, "names the manifest member + line");
381
+ assert.match(err, /evidence\/[^\s:]+:\d+/, "names the evidence member + line");
382
+ assert.deepEqual(listBundles(f), [], "a leaking bundle must never be written");
383
+ });
384
+ test("AC(masked success): literal declared via spec bundle.maskLiterals → placeholders in every member", async (t) => {
385
+ const f = await bundleFixture(t, { maskLiterals: [WIKI_LITERAL] });
386
+ const rows = await evolve(f, "leaky");
387
+ const gen = rows[rows.length - 1];
388
+ assert.ok(gen !== undefined);
389
+ const home = path.join(f.root, "home");
390
+ plantTranscript(f, gen.genId, "add", 0, [`{"cwd":"${home}/x","note":"${WIKI_LITERAL}/runbook"}`]);
391
+ assert.equal(cli(f, "bundle", "export", "toy-smoke", "--gen", gen.genId, "--out", bundleDir(f)).status, 0);
392
+ const name = listBundles(f)[0];
393
+ assert.equal(cli(f, "bundle", "inspect", path.join(bundleDir(f), name)).status, 0, "declared literal is masked, not leaked");
394
+ for (const mem of readTar(ungzip(f, name))) {
395
+ const text = new TextDecoder().decode(mem.content);
396
+ if (mem.path.startsWith("trees/"))
397
+ continue;
398
+ assert.ok(!text.includes(WIKI_LITERAL), `${mem.path} must not carry the declared literal`);
399
+ assert.ok(!text.includes(home), `${mem.path} must not carry HOME`);
400
+ }
401
+ const ev = readTar(ungzip(f, name)).find((x) => x.path.startsWith("evidence/"));
402
+ assert.ok(ev !== undefined);
403
+ assert.match(new TextDecoder().decode(ev.content), /<MASKED-1>/);
404
+ assert.match(new TextDecoder().decode(ev.content), /<HOME>/);
405
+ });
406
+ // ----------------------------------------------------------- AC: evidence
407
+ test("AC(evidence): only train runIds are walked — planted val transcript structurally excluded", async (t) => {
408
+ const f = await bundleFixture(t);
409
+ const rows = await evolve(f);
410
+ const nominated = rows.find((r) => r.data.verdict === "nominated");
411
+ assert.ok(nominated !== undefined);
412
+ const addRun = nominated.data.units.find((u) => u.unitId === "add");
413
+ const subRun = nominated.data.units.find((u) => u.unitId === "sub");
414
+ assert.ok(addRun !== undefined && subRun !== undefined, "toy row carries train add and val sub");
415
+ assert.ok(addRun.runIds.length > 0 && subRun.runIds.length > 0);
416
+ plantTranscript(f, nominated.genId, "add", 0, ['{"role":"user","text":"train scenario line"}']);
417
+ plantTranscript(f, nominated.genId, "sub", 0, ['{"role":"user","text":"VAL-SECRET scenario line"}']);
418
+ assert.equal(cli(f, "bundle", "export", "toy-smoke", "--gen", nominated.genId, "--out", bundleDir(f)).status, 0);
419
+ const name = listBundles(f)[0];
420
+ const members = readTar(ungzip(f, name));
421
+ const evidence = members.filter((m) => m.path.startsWith("evidence/"));
422
+ assert.ok(evidence.length >= 1, "train transcript made it into evidence/");
423
+ const trainRunId = addRun.runIds[0];
424
+ const valRunId = subRun.runIds[0];
425
+ assert.ok(evidence.some((m) => m.path.endsWith(`${trainRunId}.jsonl`)));
426
+ for (const m of evidence) {
427
+ assert.ok(!m.path.includes("-sub-"), `evidence member ${m.path} references a val unit`);
428
+ assert.ok(!new TextDecoder().decode(m.content).includes("VAL-SECRET"));
429
+ }
430
+ assert.ok(!members.some((m) => m.path.includes(valRunId)), "val runId never appears as a member path");
431
+ assert.equal(cli(f, "bundle", "inspect", path.join(bundleDir(f), name)).status, 0);
432
+ });
433
+ test("AC(evidence cap): >2MiB train transcript → export refuses naming the member, no truncation", async (t) => {
434
+ const f = await bundleFixture(t);
435
+ const rows = await evolve(f);
436
+ const nominated = rows.find((r) => r.data.verdict === "nominated");
437
+ assert.ok(nominated !== undefined);
438
+ plantTranscript(f, nominated.genId, "add", 0, ["x".repeat(2 * 1024 * 1024 + 10)]);
439
+ const run = cli(f, "bundle", "export", "toy-smoke", "--gen", nominated.genId, "--out", bundleDir(f));
440
+ assert.equal(run.status, 1);
441
+ assert.match(run.stderr, /evidence\/[^\s]*add-0[^\s]*|2 ?MiB|cap/i);
442
+ assert.deepEqual(listBundles(f), []);
443
+ });
444
+ // -------------------------------------------------------- AC: self-consistency
445
+ test("AC(wrong genome): swapped tree spec → fingerprint self-consistency fails exit 1", async (t) => {
446
+ const f1 = await bundleFixture(t);
447
+ const rows1 = await evolve(f1);
448
+ const n1 = rows1.find((r) => r.data.verdict === "nominated");
449
+ assert.ok(n1 !== undefined);
450
+ assert.equal(cli(f1, "bundle", "export", "toy-smoke", "--gen", n1.genId, "--out", bundleDir(f1)).status, 0);
451
+ const name1 = listBundles(f1)[0];
452
+ const f2 = await bundleFixture(t);
453
+ const rows2 = await evolve(f2);
454
+ const n2 = rows2[rows2.length - 1];
455
+ assert.ok(n2 !== undefined);
456
+ assert.equal(cli(f2, "bundle", "export", "toy-smoke", "--gen", n2.genId, "--out", bundleDir(f2)).status, 0);
457
+ const f2Members = membersOf(ungzip(f2, listBundles(f2)[0]));
458
+ const foreignSpec = f2Members.get(`trees/${String(n2?.genId)}/genome.jsonc`);
459
+ assert.ok(foreignSpec !== undefined);
460
+ assert.notEqual(fingerprint16(f1.entry.spec), fingerprint16(f2.entry.spec), "two toy instances differ by repoPath");
461
+ tamperMember(f1, name1, `trees/${n1.genId}/genome.jsonc`, foreignSpec);
462
+ const insp = cli(f1, "bundle", "inspect", path.join(bundleDir(f1), name1));
463
+ assert.equal(insp.status, 1);
464
+ assert.match(insp.stderr + insp.stdout, /fingerprint/i, "wrong-genome import is detected");
465
+ });
466
+ test("AC(digest recompute): manifest.benchDigest disagreeing with contained tree → exit 1", async (t) => {
467
+ const f = await bundleFixture(t);
468
+ const rows = await evolve(f);
469
+ const nominated = rows.find((r) => r.data.verdict === "nominated");
470
+ assert.ok(nominated !== undefined);
471
+ assert.equal(cli(f, "bundle", "export", "toy-smoke", "--gen", nominated.genId, "--out", bundleDir(f)).status, 0);
472
+ const name = listBundles(f)[0];
473
+ const members = readTar(ungzip(f, name));
474
+ const manifest = members.find((m) => m.path === "manifest.json");
475
+ assert.ok(manifest !== undefined);
476
+ const doc = JSON.parse(new TextDecoder().decode(manifest.content));
477
+ const real = doc.benchDigest;
478
+ doc.benchDigest = real.replace(/[0-9a-f]$/, real.endsWith("0") ? "1" : "0");
479
+ const rewritten = new TextEncoder().encode(`${JSON.stringify(doc, null, 2)}\n`);
480
+ repack(f, name, members.map((m) => (m.path === "manifest.json" ? { path: m.path, content: rewritten } : m)));
481
+ const insp = cli(f, "bundle", "inspect", path.join(bundleDir(f), name));
482
+ assert.equal(insp.status, 1);
483
+ assert.match(insp.stderr + insp.stdout, /benchDigest/i);
484
+ });
485
+ test("AC(unknown digest_algo): → exit 2 cannot-answer, not a failure claim", async (t) => {
486
+ const f = await bundleFixture(t);
487
+ const rows = await evolve(f);
488
+ const nominated = rows.find((r) => r.data.verdict === "nominated");
489
+ assert.ok(nominated !== undefined);
490
+ assert.equal(cli(f, "bundle", "export", "toy-smoke", "--gen", nominated.genId, "--out", bundleDir(f)).status, 0);
491
+ const name = listBundles(f)[0];
492
+ const members = readTar(ungzip(f, name));
493
+ const manifest = members.find((m) => m.path === "manifest.json");
494
+ assert.ok(manifest !== undefined);
495
+ const doc = JSON.parse(new TextDecoder().decode(manifest.content));
496
+ doc.digest_algo = "sha256-v0";
497
+ const rewritten = new TextEncoder().encode(`${JSON.stringify(doc, null, 2)}\n`);
498
+ repack(f, name, members.map((m) => (m.path === "manifest.json" ? { path: m.path, content: rewritten } : m)));
499
+ const insp = cli(f, "bundle", "inspect", path.join(bundleDir(f), name));
500
+ assert.equal(insp.status, 2);
501
+ assert.match(insp.stderr, /digest_algo|sha256-v0/);
502
+ });
503
+ // ---------------------------------------------------------- adversarial I/O
504
+ test("malformed_input: garbage file, truncated tgz, evil traversal tar → exit 2, never a stack trace", async (t) => {
505
+ const f = await bundleFixture(t);
506
+ const dir = bundleDir(f);
507
+ const junk = path.join(dir, "junk.bundle.tgz");
508
+ writeFileSync(junk, Buffer.from("this is not a gzip stream at all"));
509
+ const r1 = cli(f, "bundle", "inspect", junk);
510
+ assert.equal(r1.status, 2);
511
+ assert.ok(!r1.stderr.includes(" at "), "no stack trace");
512
+ const rows = await evolve(f);
513
+ const nominated = rows.find((r) => r.data.verdict === "nominated");
514
+ assert.ok(nominated !== undefined);
515
+ assert.equal(cli(f, "bundle", "export", "toy-smoke", "--gen", nominated.genId, "--out", dir).status, 0);
516
+ const good = readBundle(f, listBundles(f)[0]);
517
+ const trunc = path.join(dir, "trunc.bundle.tgz");
518
+ writeFileSync(trunc, Buffer.from(good.slice(0, Math.floor(good.length / 2))));
519
+ const r2 = cli(f, "bundle", "inspect", trunc);
520
+ assert.equal(r2.status, 2);
521
+ assert.ok(!r2.stderr.includes(" at "));
522
+ const evil = path.join(dir, "evil.bundle.tgz");
523
+ const evilBytes = tarWithRawHeader("../../../tmp/abathur-evil-probe");
524
+ writeFileSync(evil, gzip(evilBytes));
525
+ const r3 = cli(f, "bundle", "inspect", evil);
526
+ assert.equal(r3.status, 2);
527
+ assert.match(r3.stderr, /traversal|unsafe|member/i);
528
+ assert.ok(!existsSync(path.join(os.tmpdir(), "abathur-evil-probe")), "evil member must never be materialised");
529
+ const r4 = cli(f, "bundle", "inspect", path.join(dir, "does-not-exist.bundle.tgz"));
530
+ assert.equal(r4.status, 2);
531
+ });
532
+ test("dirty_worktree: bundle follows the commit tree, never the uncommitted worktree", async (t) => {
533
+ const f = await bundleFixture(t);
534
+ const rows = await evolve(f);
535
+ const nominated = rows.find((r) => r.data.verdict === "nominated");
536
+ assert.ok(nominated !== undefined && nominated.data.commitSha !== undefined);
537
+ writeFileSync(path.join(f.repo, "units", "mul.mjs"), "// DIRTY WORKTREE EDIT — must not reach the bundle\n", "utf8");
538
+ assert.equal(cli(f, "bundle", "export", "toy-smoke", "--gen", nominated.genId, "--out", bundleDir(f)).status, 0);
539
+ const name = listBundles(f)[0];
540
+ const members = membersOf(ungzip(f, name));
541
+ const mul = members.get(`trees/${nominated.genId}/units/mul.mjs`);
542
+ assert.ok(mul !== undefined);
543
+ const committed = new TextEncoder().encode(gitC(f.repo, "show", `${String(nominated.data.commitSha)}:units/mul.mjs`));
544
+ assert.deepEqual(Buffer.from(mul), Buffer.from(committed));
545
+ assert.ok(!new TextDecoder().decode(mul).includes("DIRTY WORKTREE"));
546
+ const m = manifestOf(f, name);
547
+ const pin = m.files.find((x) => x.path === `trees/${String(nominated.genId)}/units/mul.mjs`);
548
+ assert.ok(pin !== undefined);
549
+ assert.equal(pin.sha256, sha256Hex(committed));
550
+ assert.equal(cli(f, "bundle", "inspect", path.join(bundleDir(f), name)).status, 0);
551
+ });
552
+ // ------------------------------------------------------------ --last / lineage
553
+ test("stale_state: --last N picks the N newest candidate gens; primary = newest; --gen re-selects", async (t) => {
554
+ const f = await bundleFixture(t);
555
+ await evolve(f); // two candidate gens (annotate culled + fix-add nominated)
556
+ const cands = candidateRows(f);
557
+ assert.equal(cands.length, 2);
558
+ const r1 = cli(f, "bundle", "export", "toy-smoke", "--last", "2", "--out", bundleDir(f));
559
+ assert.equal(r1.status, 0, r1.stderr);
560
+ const name = listBundles(f)[0];
561
+ const ids = [...cands].sort((a, b) => (a.genId < b.genId ? -1 : 1)).map((r) => r.genId);
562
+ assert.equal(name, `abathur-${f.entry.fingerprint}-${ids.join("_")}.bundle.tgz`);
563
+ const m = manifestOf(f, name);
564
+ const primary = cands.find((r) => r.genId === ids[1]);
565
+ assert.ok(primary !== undefined);
566
+ assert.equal(m.parent, primary.data.headCommit, "manifest describes the newest gen");
567
+ assert.equal(m.rationale, primary.data.rationale);
568
+ const members = readTar(ungzip(f, name));
569
+ for (const id of ids)
570
+ assert.ok(members.some((x) => x.path.startsWith(`trees/${id}/`)), `tree for ${id} shipped`);
571
+ assert.ok(members.some((x) => x.path === "lineage.json"));
572
+ assert.equal(cli(f, "bundle", "inspect", path.join(bundleDir(f), name)).status, 0);
573
+ const r2 = cli(f, "bundle", "export", "toy-smoke", "--last", "5", "--out", bundleDir(f));
574
+ assert.equal(r2.status, 0, `--last beyond history clamps: ${r2.stderr}`);
575
+ assert.equal(listBundles(f).length, 1, "same selection ⇒ same file");
576
+ // a third generation lands ⇒ --last 1 follows the ledger, never a stale choice
577
+ const third = await handSeal(f, "bundle-third", { file: "units/mul.mjs", append: "\n// third\n" }, "culled");
578
+ const r3 = cli(f, "bundle", "export", "toy-smoke", "--last", "1", "--out", bundleDir(f));
579
+ assert.equal(r3.status, 0, r3.stderr);
580
+ assert.ok(listBundles(f).some((n) => n.endsWith(`-${third.genId}.bundle.tgz`)), "newest gen picked");
581
+ const bad = cli(f, "bundle", "export", "toy-smoke", "--gen", "g-20200101T000000Z-deadbeef", "--out", bundleDir(f));
582
+ assert.equal(bad.status, 2, "no-data gen miss is cannot-answer, not a recorded decision");
583
+ assert.match(bad.stderr + bad.stdout, /g-20200101T000000Z-deadbeef/, "unknown genId is NAMED");
584
+ });
585
+ test("AC(tamper-ledger): non-path-safe genId in a ledger row blocks export naming it, nothing written", async (t) => {
586
+ const f = await bundleFixture(t);
587
+ const rows = await evolve(f);
588
+ const nominated = rows.find((r) => r.data.verdict === "nominated");
589
+ assert.ok(nominated !== undefined && nominated.data.commitSha !== undefined);
590
+ const evil = "../../evil";
591
+ // hand-append a tampered candidate row through the real ledger API — the ledger
592
+ // schema only requires a non-empty string, so the export path is the gate.
593
+ // Real commit shas: the evil genId alone must decide the outcome.
594
+ Ledger.open(f.repo).append({
595
+ kind: "generation_complete",
596
+ genId: evil,
597
+ data: {
598
+ source: "candidate",
599
+ candidateId: "hand-evil",
600
+ rationale: "tampered row",
601
+ headCommit: nominated.data.headCommit,
602
+ commitSha: nominated.data.commitSha,
603
+ treeSha: nominated.data.treeSha ?? "2".repeat(40),
604
+ complete: true,
605
+ reps: 1,
606
+ units: [],
607
+ counters: { candidates: 1, modelCalls: 0, tokens: 0, wallS: 0 },
608
+ manifest: [],
609
+ verdict: "nominated",
610
+ benchProvenance: { benchType: "toy", versions: [{ bin: "node", version: process.version }] },
611
+ },
612
+ });
613
+ const run = cli(f, "bundle", "export", "toy-smoke", "--gen", evil, "--out", bundleDir(f));
614
+ assert.equal(run.status, 1, `path-unsafe genId must block: ${run.stdout}${run.stderr}`);
615
+ assert.match(run.stderr, /path-safe/, "refuses as the PATH_SAFE gate");
616
+ assert.match(run.stderr + run.stdout, /\.\.\/\.\.\/evil/, "names the offending genId");
617
+ assert.ok(!run.stderr.includes(" at "), `no stack trace: ${run.stderr}`);
618
+ assert.deepEqual(listBundles(f), [], "nothing written");
619
+ });
620
+ async function handSeal(f, tag, mutation, verdict) {
621
+ const opened = await openGenome(f.repo, [], f.wopts);
622
+ const id = genId(fingerprint({ handseal: tag, root: f.root }));
623
+ const genome = { repoPath: f.repo, genomeFp: f.entry.fingerprint };
624
+ const gen = await newGeneration(genome, opened.headCommit, id, f.wopts);
625
+ const target = path.join(gen.worktreePath, mutation.file);
626
+ writeFileSync(target, `${readFileSync(target, "utf8")}${mutation.append}`, "utf8");
627
+ const sealed = await sealGeneration(genome, id, `hand-seal ${id} (${tag})`, f.wopts);
628
+ const data = {
629
+ source: "candidate",
630
+ candidateId: `hand-${tag}`,
631
+ rationale: `hand-sealed ${tag}`,
632
+ headCommit: opened.headCommit,
633
+ commitSha: sealed.commitSha,
634
+ treeSha: sealed.treeSha,
635
+ complete: true,
636
+ reps: 2,
637
+ units: [],
638
+ counters: { candidates: 1, modelCalls: 0, tokens: 0, wallS: 0 },
639
+ manifest: [],
640
+ ...(verdict === undefined ? {} : { verdict }),
641
+ benchProvenance: { benchType: "toy", versions: [{ bin: "node", version: process.version }] },
642
+ };
643
+ Ledger.open(f.repo).append({ kind: "generation_complete", genId: id, data });
644
+ return { genId: id };
645
+ }
646
+ // -------------------------------------------------------------- CLI seam
647
+ test("CLI seam: usage errors are exit 2; router keeps bundle slot; help lists bundle", async (t) => {
648
+ const f = await bundleFixture(t);
649
+ assert.equal(cli(f, "bundle").status, 2);
650
+ assert.ok(!cli(f, "bundle").stderr.includes("not implemented yet"), "bundle group must be wired, not pending");
651
+ assert.equal(cli(f, "bundle", "frobnicate").status, 2);
652
+ assert.equal(cli(f, "bundle", "export", "toy-smoke", "--gen", "g-x").status, 2, "--out required");
653
+ assert.equal(cli(f, "bundle", "export", "toy-smoke", "--gen", "g-x", "--last", "1", "--out", bundleDir(f)).status, 2, "--gen XOR --last");
654
+ assert.equal(cli(f, "bundle", "export", "ghost-label", "--last", "1", "--out", bundleDir(f)).status, 2);
655
+ assert.equal(cli(f, "bundle", "inspect").status, 2, "path required");
656
+ const help = cli(f, "--help");
657
+ assert.equal(help.status, 0);
658
+ assert.match(help.stdout, /^ {2}bundle {2}/m);
659
+ });