@junghanacs/entwurf 0.25.0 → 0.30.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 (88) hide show
  1. package/AGENTS.md +1 -1
  2. package/BASELINE.md +1 -1
  3. package/CHANGELOG.md +182 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +1 -1
  6. package/README.md +178 -674
  7. package/VERIFY.md +17 -16
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +6 -2
  10. package/docs/external-mcp-host.md +1 -1
  11. package/docs/setup-clean-host.md +346 -10
  12. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +27 -4
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +12 -1
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +1 -1
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +2 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +2 -2
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +2 -2
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +3 -5
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +3 -3
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +13 -6
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +1 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +3 -3
  23. package/mcp/entwurf-bridge/src/index.ts +30 -3
  24. package/package.json +11 -11
  25. package/pi-extensions/entwurf-control.ts +50 -637
  26. package/pi-extensions/lib/acp/backend-adapter.ts +13 -1
  27. package/pi-extensions/lib/entwurf-fact-provider.ts +2 -2
  28. package/pi-extensions/lib/entwurf-mcp-server.ts +74 -0
  29. package/pi-extensions/lib/entwurf-peers-render.ts +1 -1
  30. package/pi-extensions/lib/entwurf-v2-decider.ts +2 -2
  31. package/pi-extensions/lib/entwurf-v2-mailbox.ts +2 -2
  32. package/pi-extensions/lib/entwurf-v2-production.ts +3 -3
  33. package/pi-extensions/lib/entwurf-v2-surface.ts +3 -5
  34. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +3 -3
  35. package/pi-extensions/lib/fresh-call-composition.ts +13 -6
  36. package/pi-extensions/lib/meta-mailbox-body.ts +1 -1
  37. package/pi-extensions/lib/mux-fresh-call.ts +3 -3
  38. package/run.sh +168 -54
  39. package/scripts/check-entwurf-peers-surface.ts +8 -15
  40. package/scripts/check-entwurf-self-address.ts +7 -11
  41. package/scripts/check-entwurf-v2-surface.ts +20 -112
  42. package/scripts/check-fresh-call-dispatch.ts +12 -13
  43. package/scripts/check-gate-qualification.ts +2741 -157
  44. package/scripts/check-install-surface.ts +3 -2
  45. package/scripts/check-mux-resume-call.ts +24 -35
  46. package/scripts/check-pi-bridge-sender.ts +130 -0
  47. package/scripts/check-pi-mcp-bridge.ts +251 -0
  48. package/scripts/lib/codex-fresh-live-protocol.ts +17 -5
  49. package/scripts/lib/codex-fresh-source-receipts.ts +21 -2
  50. package/scripts/lib/mutation-qualify.ts +269 -44
  51. package/scripts/lib/pi-mcp-bridge-probe.mjs +170 -0
  52. package/scripts/lib/qualification-acceptance.ts +146 -0
  53. package/scripts/lib/qualification-artifact.ts +359 -0
  54. package/scripts/lib/qualification-collection.ts +384 -0
  55. package/scripts/lib/qualification-gh.ts +231 -0
  56. package/scripts/lib/qualification-ledger.ts +669 -0
  57. package/scripts/lib/qualification-oracle-core.ts +279 -0
  58. package/scripts/lib/qualification-receipt.ts +561 -0
  59. package/scripts/lib/qualification-release.ts +271 -0
  60. package/scripts/meta-bridge-doctor.sh +16 -5
  61. package/scripts/mutants/acp-foreground-only.json +17 -0
  62. package/scripts/mutants/codex-native.json +11 -11
  63. package/scripts/mutants/control-send-receipt.json +6 -4
  64. package/scripts/mutants/copilot-birth.json +2 -1
  65. package/scripts/mutants/fresh-call-dispatch.json +3 -14
  66. package/scripts/mutants/gate-qualification.json +59 -0
  67. package/scripts/mutants/mux-fresh-call.json +43 -45
  68. package/scripts/mutants/mux-resume-call.json +11 -35
  69. package/scripts/mutants/pack-install.json +15 -4
  70. package/scripts/mutants/pi-bridge-sender.json +54 -0
  71. package/scripts/mutants/pi-floor.json +6 -2
  72. package/scripts/mutants/pi-mcp-bridge.json +36 -0
  73. package/scripts/mutants/pi-mcp-register.json +51 -0
  74. package/scripts/mutants/qualification-ledger.json +489 -0
  75. package/scripts/mutants/v2-surface.json +6 -6
  76. package/scripts/qualification-oracle.ts +124 -0
  77. package/scripts/resident-rpc-drive.ts +5 -1
  78. package/scripts/smoke-acp-bundled-mcp-live.ts +3 -1
  79. package/scripts/smoke-acp-cortex-live.ts +3 -1
  80. package/scripts/smoke-acp-session-reuse-live.ts +29 -12
  81. package/scripts/smoke-acp-socket-citizen-live.ts +3 -1
  82. package/scripts/smoke-acp-v2-send-live.ts +3 -1
  83. package/scripts/smoke-codex-fresh-live.ts +20 -1
  84. package/scripts/smoke-entwurf-chain-live.ts +6 -2
  85. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -1
  86. package/scripts/smoke-herdr-raw-install-live.sh +8 -3
  87. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  88. package/scripts/smoke-resident-garden-guard.sh +3 -1
@@ -0,0 +1,561 @@
1
+ /**
2
+ * qualification-receipt — the PARTIAL group receipt of #124 (P1).
3
+ *
4
+ * `check-gate-qualification.ts --group <exact argv JSON> --receipt <path>` runs ONE
5
+ * exact-argv group through the unchanged control→mutant→restore→control body and
6
+ * writes what it observed here. A receipt is a MEASUREMENT of one group on one
7
+ * candidate. It is never a qualification pass: the full body stays the only thing
8
+ * that prints `[gate-qualification] ok`, and this module owns no verdict about any
9
+ * group it did not run.
10
+ *
11
+ * What a receipt fingerprints, and what it does NOT prove:
12
+ * - candidate: origin HEAD + work-surface content sha, sealed before/after.
13
+ * - core (C0), conservative and whole: the runner files, all of run.sh, all of
14
+ * package.json (version included), the lock, the tsconfig/vitest config, the node
15
+ * version and binary, and the node_modules tree. Splitting any of these is later,
16
+ * separately proved work.
17
+ * - node_modules: a content+mode+symlink-target tree hash (links are NOT followed;
18
+ * measured 2026-09-29 on oracle: every link resolves inside the tree). The Vitest
19
+ * cache under `node_modules/.vite` is written by gates during a run, so it is
20
+ * excluded from the identity hash by name and its drift is reported separately.
21
+ * That cache can steer Vitest's test ordering — a NAMED environment risk, not a
22
+ * proof of equivalence.
23
+ * - declared inputs: every subject, signatureSource and argv file operand of the
24
+ * group. Declared, not audited: a gate that reads a file by path outside this set
25
+ * is invisible here, so no carry may be derived from these alone.
26
+ * A declared path that is absolute, normalizes outside the repo, or whose parent
27
+ * directory resolves (realpath) outside it is recorded as `outside-repo` and never
28
+ * read; a symlink is recorded by its target, never
29
+ * followed — so bytes reached THROUGH a declared symlink are not fingerprinted.
30
+ * - NOT covered at all: host tools the gates spawn (bash, git, python3, tmux, …),
31
+ * kernel/tmp state, and inter-group leaks the full serial body would expose.
32
+ *
33
+ * `receiptSha256` detects accidental corruption only. Anyone can recompute it, so it
34
+ * cannot tell a runner-written receipt from a hand-written one.
35
+ */
36
+
37
+ import { createHash } from "node:crypto";
38
+ import * as fs from "node:fs";
39
+ import * as path from "node:path";
40
+ import {
41
+ createRepoSnapshot,
42
+ type MutantSpec,
43
+ originHead,
44
+ originWorkSurfaceSha,
45
+ qualifyMutants,
46
+ reportPassed,
47
+ } from "./mutation-qualify.ts";
48
+
49
+ export const RECEIPT_SCHEMA = "entwurf.qualification-group-receipt/v0";
50
+ export const RECEIPT_LABEL = "PARTIAL/local — one exact-argv group, not a qualification pass";
51
+
52
+ /** C0 as files on the work surface; missing ones are recorded as missing, never skipped. */
53
+ export const CORE_FILES = [
54
+ "scripts/check-gate-qualification.ts",
55
+ "scripts/qualification-oracle.ts",
56
+ "scripts/lib/mutation-qualify.ts",
57
+ "scripts/lib/qualification-artifact.ts",
58
+ "scripts/lib/qualification-collection.ts",
59
+ "scripts/lib/qualification-gh.ts",
60
+ "scripts/lib/qualification-ledger.ts",
61
+ "scripts/lib/qualification-oracle-core.ts",
62
+ "scripts/lib/qualification-receipt.ts",
63
+ "scripts/lib/qualification-release.ts",
64
+ "scripts/lib/qualification-acceptance.ts",
65
+ "scripts/lib/reclaim-on-exit.ts",
66
+ "run.sh",
67
+ "package.json",
68
+ "pnpm-lock.yaml",
69
+ "pnpm-workspace.yaml",
70
+ "tsconfig.json",
71
+ "scripts/tsconfig.json",
72
+ "vitest.config.ts",
73
+ ];
74
+
75
+ /** Excluded from the node_modules identity hash by name; see the header. */
76
+ export const NODE_MODULES_EXCLUDED = [".vite"];
77
+
78
+ // ── argv ────────────────────────────────────────────────────────────────────
79
+
80
+ export type QualificationArgs =
81
+ | { mode: "full" }
82
+ | { mode: "manifests-only" }
83
+ | { mode: "attribution-self-test" }
84
+ | { mode: "group"; group: string[]; receiptPath: string }
85
+ | { mode: "compose"; receipts: string[]; expectPath?: string; ledgerOut?: string }
86
+ | { mode: "groups"; groups: string[][] | "all"; collectDir: string };
87
+
88
+ export class QualificationArgsError extends Error {}
89
+
90
+ /**
91
+ * Parse the qualification entry's own argv (process.argv.slice(2)). Refuses, before
92
+ * anything runs: an unknown flag, a repeated flag, a missing value, `--group` without
93
+ * `--receipt` or the reverse, and a group flag combined with a head-only mode.
94
+ */
95
+ export function parseQualificationArgs(argv: string[]): QualificationArgs {
96
+ const seen = new Set<string>();
97
+ let group: string[] | undefined;
98
+ let receiptPath: string | undefined;
99
+ let receipts: string[] | undefined;
100
+ let expectPath: string | undefined;
101
+ let ledgerOut: string | undefined;
102
+ let groups: string[][] | "all" | undefined;
103
+ let collectDir: string | undefined;
104
+ for (let i = 0; i < argv.length; i++) {
105
+ const flag = argv[i];
106
+ if (seen.has(flag)) throw new QualificationArgsError(`repeated flag ${flag}`);
107
+ seen.add(flag);
108
+ if (flag === "--manifests-only" || flag === "--attribution-self-test") continue;
109
+ if (flag === "--group" || flag === "--receipt") {
110
+ const value = argv[++i];
111
+ if (value === undefined || value.startsWith("--")) throw new QualificationArgsError(`${flag} needs a value`);
112
+ if (flag === "--group") {
113
+ let parsed: unknown;
114
+ try {
115
+ parsed = JSON.parse(value);
116
+ } catch {
117
+ throw new QualificationArgsError(`--group must be the exact gate argv as a JSON array, got: ${value}`);
118
+ }
119
+ if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every((a) => typeof a === "string" && a !== "")) {
120
+ throw new QualificationArgsError(
121
+ `--group must be a non-empty JSON array of non-empty strings, got: ${value}`,
122
+ );
123
+ }
124
+ group = parsed;
125
+ } else {
126
+ receiptPath = value;
127
+ }
128
+ continue;
129
+ }
130
+ if (flag === "--compose") {
131
+ receipts = [];
132
+ while (i + 1 < argv.length && !argv[i + 1].startsWith("--")) receipts.push(argv[++i]);
133
+ if (receipts.length === 0) throw new QualificationArgsError("--compose needs at least one receipt file");
134
+ continue;
135
+ }
136
+ if (flag === "--groups" || flag === "--collect") {
137
+ const value = argv[++i];
138
+ if (value === undefined || value.startsWith("--")) throw new QualificationArgsError(`${flag} needs a value`);
139
+ if (flag === "--collect") {
140
+ collectDir = value;
141
+ continue;
142
+ }
143
+ if (value === "all") {
144
+ // Every exact argv of the committed manifests, in manifest order — resolved after
145
+ // the head has validated them (see check-gate-qualification.ts Phase 2m).
146
+ groups = "all";
147
+ continue;
148
+ }
149
+ let parsed: unknown;
150
+ try {
151
+ parsed = JSON.parse(value);
152
+ } catch {
153
+ throw new QualificationArgsError(
154
+ `--groups must be "all" or a JSON array of exact gate argv arrays, got: ${value}`,
155
+ );
156
+ }
157
+ const isArgv = (a: unknown): a is string[] =>
158
+ Array.isArray(a) && a.length > 0 && a.every((x) => typeof x === "string" && x !== "");
159
+ if (!Array.isArray(parsed) || !parsed.every(isArgv)) {
160
+ throw new QualificationArgsError(`--groups must be a JSON array of non-empty argv arrays, got: ${value}`);
161
+ }
162
+ if (parsed.length === 0) throw new QualificationArgsError("--groups is empty — nothing to collect");
163
+ const keys = parsed.map((a) => JSON.stringify(a));
164
+ const dup = keys.find((k, j) => keys.indexOf(k) !== j);
165
+ if (dup) throw new QualificationArgsError(`--groups repeats ${dup}`);
166
+ groups = parsed;
167
+ continue;
168
+ }
169
+ if (flag === "--expect" || flag === "--ledger-out") {
170
+ const value = argv[++i];
171
+ if (value === undefined || value.startsWith("--")) throw new QualificationArgsError(`${flag} needs a value`);
172
+ if (flag === "--expect") expectPath = value;
173
+ else ledgerOut = value;
174
+ continue;
175
+ }
176
+ throw new QualificationArgsError(`unknown argument ${flag}`);
177
+ }
178
+ if (groups !== undefined || collectDir !== undefined) {
179
+ const other = [...seen].filter((f) => !["--groups", "--collect"].includes(f));
180
+ if (other.length > 0) throw new QualificationArgsError(`--groups cannot combine with ${other.join(" ")}`);
181
+ if (groups === undefined) throw new QualificationArgsError("--collect needs --groups");
182
+ if (collectDir === undefined) throw new QualificationArgsError("--groups needs --collect");
183
+ return { mode: "groups", groups, collectDir };
184
+ }
185
+ if ((expectPath !== undefined || ledgerOut !== undefined) && receipts === undefined) {
186
+ throw new QualificationArgsError("--expect/--ledger-out need --compose");
187
+ }
188
+ if (receipts !== undefined) {
189
+ const other = [...seen].filter((f) => !["--compose", "--expect", "--ledger-out"].includes(f));
190
+ if (other.length > 0) throw new QualificationArgsError(`--compose cannot combine with ${other.join(" ")}`);
191
+ return { mode: "compose", receipts, expectPath, ledgerOut };
192
+ }
193
+ const heads = ["--manifests-only", "--attribution-self-test"].filter((f) => seen.has(f));
194
+ if (heads.length > 1) throw new QualificationArgsError(`${heads.join(" and ")} are exclusive`);
195
+ if (group !== undefined || receiptPath !== undefined) {
196
+ if (heads.length > 0) throw new QualificationArgsError(`--group/--receipt cannot combine with ${heads[0]}`);
197
+ if (group === undefined) throw new QualificationArgsError("--receipt needs --group");
198
+ if (receiptPath === undefined) throw new QualificationArgsError("--group needs --receipt");
199
+ return { mode: "group", group, receiptPath };
200
+ }
201
+ if (heads[0] === "--manifests-only") return { mode: "manifests-only" };
202
+ if (heads[0] === "--attribution-self-test") return { mode: "attribution-self-test" };
203
+ return { mode: "full" };
204
+ }
205
+
206
+ /**
207
+ * A receipt path must be absolute, must not exist yet, and its parent must be an
208
+ * existing directory OUTSIDE the repo (a receipt inside the checkout would move the
209
+ * very work-surface seal it records).
210
+ */
211
+ export function checkReceiptPath(receiptPath: string, repoDir: string): void {
212
+ if (!path.isAbsolute(receiptPath)) throw new QualificationArgsError(`--receipt must be absolute: ${receiptPath}`);
213
+ if (fs.existsSync(receiptPath) || isDanglingLink(receiptPath)) {
214
+ throw new QualificationArgsError(`--receipt already exists, refusing to overwrite: ${receiptPath}`);
215
+ }
216
+ const parent = path.dirname(receiptPath);
217
+ let parentReal: string;
218
+ try {
219
+ parentReal = fs.realpathSync(parent);
220
+ } catch {
221
+ throw new QualificationArgsError(`--receipt parent directory does not exist: ${parent}`);
222
+ }
223
+ if (!fs.statSync(parentReal).isDirectory())
224
+ throw new QualificationArgsError(`--receipt parent is not a directory: ${parent}`);
225
+ const repoReal = fs.realpathSync(repoDir);
226
+ if (parentReal === repoReal || parentReal.startsWith(repoReal + path.sep)) {
227
+ throw new QualificationArgsError(`--receipt must live outside the repo: ${receiptPath}`);
228
+ }
229
+ }
230
+
231
+ function isDanglingLink(p: string): boolean {
232
+ try {
233
+ return fs.lstatSync(p).isSymbolicLink();
234
+ } catch {
235
+ return false;
236
+ }
237
+ }
238
+
239
+ // ── fingerprints ────────────────────────────────────────────────────────────
240
+
241
+ export function sha256(data: string | Buffer): string {
242
+ return createHash("sha256").update(data).digest("hex");
243
+ }
244
+
245
+ /** path → sha256 of a regular file, `link:<target>` for a symlink, `missing` when absent. */
246
+ export function fileFingerprints(repoDir: string, rels: string[]): Record<string, string> {
247
+ const out: Record<string, string> = {};
248
+ const repoReal = fs.realpathSync(repoDir);
249
+ for (const rel of [...new Set(rels)].sort()) {
250
+ const norm = path.normalize(rel);
251
+ if (path.isAbsolute(rel) || norm === ".." || norm.startsWith(`..${path.sep}`)) {
252
+ out[rel] = "outside-repo";
253
+ continue;
254
+ }
255
+ const abs = path.join(repoDir, rel);
256
+ // Lexical containment is not enough: a symlinked DIRECTORY on the way (`linkdir ->
257
+ // /elsewhere`) would let lstat/readFileSync of `linkdir/x` read bytes outside the
258
+ // repo. The parent's realpath must stay inside the repo's realpath; only the final
259
+ // component may be a link, and that one is recorded by target, never followed.
260
+ let parentReal: string;
261
+ try {
262
+ parentReal = fs.realpathSync(path.dirname(abs));
263
+ } catch {
264
+ out[rel] = "missing";
265
+ continue;
266
+ }
267
+ if (parentReal !== repoReal && !parentReal.startsWith(repoReal + path.sep)) {
268
+ out[rel] = "outside-repo";
269
+ continue;
270
+ }
271
+ let st: fs.Stats;
272
+ try {
273
+ st = fs.lstatSync(abs);
274
+ } catch {
275
+ out[rel] = "missing";
276
+ continue;
277
+ }
278
+ if (st.isSymbolicLink()) out[rel] = `link:${fs.readlinkSync(abs)}`;
279
+ else if (st.isFile()) out[rel] = sha256(fs.readFileSync(abs));
280
+ else out[rel] = "special";
281
+ }
282
+ return out;
283
+ }
284
+
285
+ export interface TreeFingerprint {
286
+ sha256: string;
287
+ files: number;
288
+ links: number;
289
+ /** Entry lines, kept only to diff two fingerprints of the same tree. */
290
+ lines: string[];
291
+ }
292
+
293
+ /** Content+mode+link-target hash of a tree; symlinks are recorded, never followed. */
294
+ export function treeFingerprint(root: string, excludeTop: string[] = []): TreeFingerprint {
295
+ const lines: string[] = [];
296
+ let files = 0;
297
+ let links = 0;
298
+ const walk = (rel: string): void => {
299
+ const entries = fs.readdirSync(path.join(root, rel)).sort();
300
+ for (const name of entries) {
301
+ if (rel === "" && excludeTop.includes(name)) continue;
302
+ const r = rel === "" ? name : `${rel}/${name}`;
303
+ const abs = path.join(root, r);
304
+ const st = fs.lstatSync(abs);
305
+ if (st.isSymbolicLink()) {
306
+ lines.push(`${r}\0link\0${fs.readlinkSync(abs)}`);
307
+ links++;
308
+ } else if (st.isDirectory()) {
309
+ lines.push(`${r}\0dir`);
310
+ walk(r);
311
+ } else if (st.isFile()) {
312
+ lines.push(`${r}\0${(st.mode & 0o777).toString(8)}\0${sha256(fs.readFileSync(abs))}`);
313
+ files++;
314
+ } else {
315
+ lines.push(`${r}\0special`);
316
+ }
317
+ }
318
+ };
319
+ walk("");
320
+ return { sha256: sha256(lines.join("\n")), files, links, lines };
321
+ }
322
+
323
+ /** Paths whose entry differs between two fingerprints of the same tree (added, removed or changed). */
324
+ export function treeDrift(before: TreeFingerprint, after: TreeFingerprint): string[] {
325
+ const key = (line: string): string => line.split("\0")[0];
326
+ const a = new Map(before.lines.map((l) => [key(l), l]));
327
+ const b = new Map(after.lines.map((l) => [key(l), l]));
328
+ const out = new Set<string>();
329
+ for (const [k, l] of a) if (b.get(k) !== l) out.add(k);
330
+ for (const k of b.keys()) if (!a.has(k)) out.add(k);
331
+ return [...out].sort();
332
+ }
333
+
334
+ export interface NodeModulesFingerprint {
335
+ present: boolean;
336
+ sha256: string | null;
337
+ excluded: string[];
338
+ files: number;
339
+ links: number;
340
+ }
341
+
342
+ export function nodeModulesFingerprint(repoDir: string): {
343
+ summary: NodeModulesFingerprint;
344
+ full: TreeFingerprint | null;
345
+ } {
346
+ const root = path.join(repoDir, "node_modules");
347
+ if (!fs.existsSync(root)) {
348
+ return {
349
+ summary: { present: false, sha256: null, excluded: NODE_MODULES_EXCLUDED, files: 0, links: 0 },
350
+ full: null,
351
+ };
352
+ }
353
+ const idFp = treeFingerprint(root, NODE_MODULES_EXCLUDED);
354
+ return {
355
+ summary: {
356
+ present: true,
357
+ sha256: idFp.sha256,
358
+ excluded: NODE_MODULES_EXCLUDED,
359
+ files: idFp.files,
360
+ links: idFp.links,
361
+ },
362
+ full: treeFingerprint(root),
363
+ };
364
+ }
365
+
366
+ let nodeRuntimeMemo: { version: string; execPath: string; execSha256: string } | undefined;
367
+
368
+ /** The running node — version, resolved binary path and its sha (hashed once per process). */
369
+ export function nodeRuntime(): { version: string; execPath: string; execSha256: string } {
370
+ if (!nodeRuntimeMemo) {
371
+ const execPath = fs.realpathSync(process.execPath);
372
+ nodeRuntimeMemo = { version: process.version, execPath, execSha256: sha256(fs.readFileSync(execPath)) };
373
+ }
374
+ return nodeRuntimeMemo;
375
+ }
376
+
377
+ /** Canonical (sorted-key) JSON, so a hash does not depend on property insertion order. */
378
+ export function canonicalJson(value: unknown): string {
379
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
380
+ if (value !== null && typeof value === "object") {
381
+ const obj = value as Record<string, unknown>;
382
+ return `{${Object.keys(obj)
383
+ .sort()
384
+ .filter((k) => obj[k] !== undefined)
385
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`)
386
+ .join(",")}}`;
387
+ }
388
+ return JSON.stringify(value);
389
+ }
390
+
391
+ // ── write ───────────────────────────────────────────────────────────────────
392
+
393
+ /** Attach `receiptSha256` (over the canonical body without it) — corruption detection only. */
394
+ export function sealReceipt<T extends object>(body: T): T & { receiptSha256: string } {
395
+ return { ...body, receiptSha256: sha256(canonicalJson(body)) };
396
+ }
397
+
398
+ /**
399
+ * Write once, atomically, never over an existing file: the bytes go to a sibling temp
400
+ * file (O_EXCL), are fully written (writeFileSync on the fd loops over short writes)
401
+ * and fsynced, then hard-linked to the final name — `link` fails with EEXIST instead
402
+ * of replacing whatever appeared there meanwhile. The temp file is removed on every
403
+ * path, including a failed write.
404
+ */
405
+ export function writeReceiptExclusive(receiptPath: string, receipt: object): void {
406
+ const tmp = `${receiptPath}.tmp-${process.pid}`;
407
+ const fd = fs.openSync(tmp, "wx", 0o600);
408
+ try {
409
+ try {
410
+ fs.writeFileSync(fd, `${JSON.stringify(receipt, null, "\t")}\n`);
411
+ fs.fsyncSync(fd);
412
+ } finally {
413
+ fs.closeSync(fd);
414
+ }
415
+ fs.linkSync(tmp, receiptPath);
416
+ } finally {
417
+ fs.rmSync(tmp, { force: true });
418
+ }
419
+ }
420
+
421
+ /** A group's DECLARED inputs: every subject, signatureSource and argv file operand. */
422
+ export function declaredInputPaths(repoDir: string, groupSpecs: MutantSpec[], gate: string[]): string[] {
423
+ return [
424
+ ...groupSpecs.flatMap((m) => [m.subject, m.signatureSource]),
425
+ ...gate.filter((a) => !a.startsWith("-") && fs.existsSync(path.join(repoDir, a))),
426
+ ];
427
+ }
428
+
429
+ /** The one hash a receipt and a current candidate compare for C0. */
430
+ export function coreSha256(core: { files: Record<string, string>; node: unknown; nodeModules: unknown }): string {
431
+ return sha256(canonicalJson(core));
432
+ }
433
+
434
+ // ── one group, measured ─────────────────────────────────────────────────────
435
+
436
+ export interface GroupReceiptOptions {
437
+ repoDir: string;
438
+ /** The validated committed mutant set, in manifest order. */
439
+ selected: MutantSpec[];
440
+ /** Exact gate argv; must equal at least one spec's gate. */
441
+ gate: string[];
442
+ receiptPath: string;
443
+ log: (line: string) => void;
444
+ logError: (line: string) => void;
445
+ /** Snapshot tmp root (tests); defaults to the OS tmp dir. */
446
+ tmpRoot?: string;
447
+ }
448
+
449
+ /**
450
+ * Run the mutants whose gate argv equals `gate` exactly, in manifest order, through
451
+ * the unchanged qualifyMutants state machine on a fresh snapshot, and write the
452
+ * receipt. A red is written like a green; `passed` is reportPassed AND a stable seal.
453
+ * An exception inside the body propagates and writes nothing — it is not an
454
+ * observation. Refuses before any snapshot when the argv names no committed group.
455
+ */
456
+ export async function runGroupReceipt(
457
+ opts: GroupReceiptOptions,
458
+ ): Promise<{ passed: boolean; receiptSha256: string; sealStable: boolean }> {
459
+ const { repoDir, selected, gate } = opts;
460
+ const groupKey = JSON.stringify(gate);
461
+ const groupSpecs = selected.filter((m) => JSON.stringify(m.gate) === groupKey);
462
+ if (groupSpecs.length === 0) {
463
+ throw new Error(`--group ${groupKey} names no exact gate argv in the committed manifests — nothing was run`);
464
+ }
465
+ const startedAt = new Date();
466
+ const headBefore = originHead(repoDir);
467
+ const workSurfaceBefore = originWorkSurfaceSha(repoDir);
468
+ const coreFilesBefore = fileFingerprints(repoDir, CORE_FILES);
469
+ const nmBefore = nodeModulesFingerprint(repoDir);
470
+ const node = nodeRuntime();
471
+ const declaredInputs = declaredInputPaths(repoDir, groupSpecs, gate);
472
+ const inputsBefore = fileFingerprints(repoDir, declaredInputs);
473
+
474
+ const snap = createRepoSnapshot(repoDir, opts.tmpRoot);
475
+ opts.log(
476
+ `[gate-qualification --group] PARTIAL: ${groupSpecs.length}/${selected.length} mutants of ${groupKey}; snapshot ${snap.repoDir} (origin HEAD ${headBefore.slice(0, 12)})`,
477
+ );
478
+ let report: Awaited<ReturnType<typeof qualifyMutants>>;
479
+ try {
480
+ report = await qualifyMutants(snap, groupSpecs, opts.log);
481
+ } finally {
482
+ fs.rmSync(snap.baseDir, { recursive: true, force: true });
483
+ }
484
+
485
+ const headAfter = originHead(repoDir);
486
+ const workSurfaceAfter = originWorkSurfaceSha(repoDir);
487
+ const coreFilesAfter = fileFingerprints(repoDir, CORE_FILES);
488
+ const nmAfter = nodeModulesFingerprint(repoDir);
489
+ const inputsAfter = fileFingerprints(repoDir, declaredInputs);
490
+ const sealStable =
491
+ headAfter === headBefore &&
492
+ workSurfaceAfter === workSurfaceBefore &&
493
+ canonicalJson(coreFilesAfter) === canonicalJson(coreFilesBefore) &&
494
+ nmAfter.summary.sha256 === nmBefore.summary.sha256 &&
495
+ canonicalJson(inputsAfter) === canonicalJson(inputsBefore);
496
+ const groupResult = report.groups[0];
497
+ const passed = reportPassed(report) && sealStable;
498
+ const core = { files: coreFilesBefore, node, nodeModules: nmBefore.summary };
499
+ const receipt = sealReceipt({
500
+ schema: RECEIPT_SCHEMA,
501
+ label: RECEIPT_LABEL,
502
+ startedAt: startedAt.toISOString(),
503
+ elapsedSeconds: (Date.now() - startedAt.getTime()) / 1000,
504
+ candidate: { head: headBefore, workSurfaceSha256: workSurfaceBefore },
505
+ core: { ...core, sha256: coreSha256(core) },
506
+ group: {
507
+ gate,
508
+ // Run order, not sorted: mutants run sequentially and may leak state into each other.
509
+ specSetSha256: sha256(canonicalJson(groupSpecs)),
510
+ claims: groupSpecs.map((m) => m.claim),
511
+ declaredInputs: inputsBefore,
512
+ },
513
+ manifestSet: {
514
+ claimCount: selected.length,
515
+ claims: selected.map((m) => m.claim).sort(),
516
+ },
517
+ result: {
518
+ passed,
519
+ control: groupResult.control,
520
+ mutants: groupResult.mutants,
521
+ treeClean: report.treeClean,
522
+ porcelainClean: report.porcelainClean,
523
+ },
524
+ seal: {
525
+ stable: sealStable,
526
+ headAfter,
527
+ workSurfaceAfterSha256: workSurfaceAfter,
528
+ coreFilesChanged: Object.keys(coreFilesBefore).filter((k) => coreFilesBefore[k] !== coreFilesAfter[k]),
529
+ nodeModulesIdentityAfter: nmAfter.summary.sha256,
530
+ nodeModulesExcludedDrift:
531
+ nmBefore.full && nmAfter.full
532
+ ? treeDrift(nmBefore.full, nmAfter.full).filter((p) =>
533
+ nmBefore.summary.excluded.some((x) => p === x || p.startsWith(`${x}/`)),
534
+ )
535
+ : [],
536
+ declaredInputsChanged: Object.keys(inputsBefore).filter((k) => inputsBefore[k] !== inputsAfter[k]),
537
+ },
538
+ notCovered: [
539
+ "undeclared inputs a gate reads by path or spawns (declared inputs are subjects + signatureSources + argv files only)",
540
+ "bytes reached through a declared symlink (recorded by target, not followed)",
541
+ "host tools spawned by gates (bash, git, python3, tmux, …) and kernel/tmp state",
542
+ "state leaks between this group and groups that did not run with it",
543
+ `node_modules paths excluded from identity by name (${nmBefore.summary.excluded.join(", ")}); drift listed in seal`,
544
+ "receiptSha256 detects corruption only; it cannot distinguish a runner-written receipt from a hand-written one",
545
+ ],
546
+ });
547
+ writeReceiptExclusive(opts.receiptPath, receipt);
548
+ opts.log(
549
+ `[gate-qualification --group] receipt: ${opts.receiptPath} (receiptSha256 ${receipt.receiptSha256.slice(0, 12)}…)`,
550
+ );
551
+ for (const m of groupResult.mutants) {
552
+ if (m.verdict !== "KILLED")
553
+ opts.logError(`[gate-qualification --group] NOT KILLED: ${m.claim} → ${m.verdict} (${m.detail})`);
554
+ }
555
+ if (!sealStable) opts.logError("[gate-qualification --group] SEAL MOVED during the run — see receipt.seal");
556
+ const killed = groupResult.mutants.filter((m) => m.verdict === "KILLED").length;
557
+ opts.log(
558
+ `[gate-qualification --group] PARTIAL ${passed ? "group-green" : "group-RED"}: ${killed}/${groupSpecs.length} killed, control ${groupResult.control}; ${selected.length - groupSpecs.length} committed mutants NOT run`,
559
+ );
560
+ return { passed, receiptSha256: receipt.receiptSha256, sealStable };
561
+ }