@junghanacs/entwurf 0.12.9 → 0.13.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 (100) hide show
  1. package/AGENTS.md +135 -248
  2. package/BASELINE.md +46 -7
  3. package/CHANGELOG.md +35 -0
  4. package/CONTRIBUTING.md +4 -2
  5. package/DELIVERY.md +5 -4
  6. package/README.md +46 -18
  7. package/VERIFY.md +10 -6
  8. package/demo/demo.sh +7 -1
  9. package/docs/setup-clean-host.md +10 -4
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +159 -66
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +148 -5
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +15 -17
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +9 -11
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +3 -1
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +17 -20
  28. package/mcp/entwurf-bridge/src/index.ts +190 -82
  29. package/package.json +7 -3
  30. package/pi/settings.reference.json +1 -1
  31. package/pi-extensions/acp-provider.ts +20 -10
  32. package/pi-extensions/entwurf-control.ts +69 -27
  33. package/pi-extensions/lib/acp/augment.ts +60 -2
  34. package/pi-extensions/lib/acp/backend-adapter.ts +183 -8
  35. package/pi-extensions/lib/acp/backend.ts +5 -1
  36. package/pi-extensions/lib/acp/config.ts +19 -5
  37. package/pi-extensions/lib/acp/engraving.ts +3 -1
  38. package/pi-extensions/lib/acp/event-mapper.ts +10 -3
  39. package/pi-extensions/lib/acp/models.ts +69 -7
  40. package/pi-extensions/lib/acp/overlay.ts +234 -3
  41. package/pi-extensions/lib/acp/tool-surface.ts +12 -4
  42. package/pi-extensions/lib/entwurf-core.ts +26 -9
  43. package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
  44. package/pi-extensions/lib/entwurf-fact-provider.ts +15 -17
  45. package/pi-extensions/lib/entwurf-facts.ts +13 -13
  46. package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
  47. package/pi-extensions/lib/entwurf-self-address.ts +8 -9
  48. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
  49. package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
  50. package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
  51. package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
  52. package/pi-extensions/lib/entwurf-v2-production.ts +14 -11
  53. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +3 -1
  54. package/pi-extensions/lib/meta-session.ts +17 -20
  55. package/run.sh +200 -62
  56. package/scripts/agy-bridge-config.py +249 -49
  57. package/scripts/agy-bridge.sh +59 -14
  58. package/scripts/check-acp-carrier-augment.ts +34 -2
  59. package/scripts/check-acp-cortex.ts +668 -0
  60. package/scripts/check-acp-provider-surface.ts +50 -6
  61. package/scripts/check-acp-sdk-surface.ts +22 -11
  62. package/scripts/check-acp-session-reuse.ts +64 -1
  63. package/scripts/check-agy-permission-matrix.py +655 -0
  64. package/scripts/check-entwurf-deliverability.ts +9 -6
  65. package/scripts/check-entwurf-fact-provider.ts +7 -7
  66. package/scripts/check-entwurf-facts.ts +12 -12
  67. package/scripts/check-entwurf-resume-args.ts +22 -60
  68. package/scripts/check-entwurf-self-address.ts +187 -4
  69. package/scripts/check-entwurf-v2-contract.ts +2 -2
  70. package/scripts/check-entwurf-v2-production.ts +5 -5
  71. package/scripts/check-entwurf-v2-spawn-production.ts +6 -2
  72. package/scripts/check-entwurf-v2-surface.ts +327 -14
  73. package/scripts/check-gate-qualification.ts +787 -0
  74. package/scripts/check-meta-identity-consumers.ts +1 -1
  75. package/scripts/check-probe-cli-shim.ts +879 -0
  76. package/scripts/check-probe-ordering.ts +2450 -0
  77. package/scripts/check-shell-quote.ts +5 -4
  78. package/scripts/fixtures/probe-cli-shim +20 -0
  79. package/scripts/fixtures/probe-mcp-server.ts +168 -12
  80. package/scripts/lib/mutation-qualify.ts +794 -0
  81. package/scripts/lib/probe-acp-turn.ts +207 -0
  82. package/scripts/lib/probe-cli-shim.ts +464 -0
  83. package/scripts/lib/probe-cli-target.ts +165 -0
  84. package/scripts/lib/probe-event-log.ts +383 -0
  85. package/scripts/lib/probe-verdict.ts +1213 -0
  86. package/scripts/mutants/acp-augment.json +30 -0
  87. package/scripts/mutants/acp-cortex.json +196 -0
  88. package/scripts/mutants/agy-permission.json +144 -0
  89. package/scripts/mutants/meta-identity.json +17 -0
  90. package/scripts/mutants/probe-ordering.json +1032 -0
  91. package/scripts/mutants/self-address.json +59 -0
  92. package/scripts/mutants/v2-surface.json +87 -0
  93. package/scripts/smoke-acp-cortex-live.ts +392 -0
  94. package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
  95. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  96. package/scripts/smoke-acp-v2-send-live.ts +33 -18
  97. package/scripts/smoke-agy-install-state.sh +205 -20
  98. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
  99. package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
  100. package/scripts/check-entwurf-mailbox-guard.ts +0 -266
@@ -0,0 +1,787 @@
1
+ /**
2
+ * check-gate-qualification — kill-proof qualification of the shipped gates.
3
+ *
4
+ * Two phases, in a fixed order:
5
+ *
6
+ * 1. RUNNER SELF-TEST. The runner is itself a SUT: a qualification harness that
7
+ * rounds a wrong-reason red up to KILLED, or misses a zero-match, is a test bug
8
+ * of exactly the class it exists to close. So before any real manifest runs,
9
+ * the pure classifier is exhausted as a truth table and the whole pipeline is
10
+ * driven over a synthetic fixture repo through every negative: malformed
11
+ * manifest, duplicate claim, path escape, untracked subject, zero-match,
12
+ * multi-match, SURVIVED, WRONG-REASON, HANG (with a real grandchild kill on
13
+ * the process group), CONTROL-PRE red (the fake-KILLED hole), a state-poisoning
14
+ * gate (CONTROL-POST red), and a stray write (tree-manifest impurity).
15
+ *
16
+ * 2. REAL MANIFESTS. `scripts/mutants/*.json` are validated (exact schema, global
17
+ * claim uniqueness, subject tracked in the ORIGIN index, claim token exactly
18
+ * once in its gate source), then run in an isolated snapshot repo under the
19
+ * control-mutant-restore-control state machine. The real checkout is never
20
+ * written; its HEAD + work-surface content hash are asserted identical
21
+ * before/after (P1-5 — porcelain text alone misses byte changes in already-
22
+ * modified files).
23
+ *
24
+ * No tiers: the full mutant set measured ~80s on the reference host, under the agreed
25
+ * ~120s split threshold, so every run executes every mutant — one command, one truth,
26
+ * no advisory lane. If the set ever outgrows the budget, re-open the fast/full split
27
+ * from the design record instead of silently skipping mutants.
28
+ *
29
+ * Evidence: claim IDs + killed mutant IDs, never assertion counts.
30
+ */
31
+
32
+ import assert from "node:assert/strict";
33
+ import { spawnSync } from "node:child_process";
34
+ import * as fs from "node:fs";
35
+ import * as os from "node:os";
36
+ import * as path from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+ import {
39
+ classifyMutantRun,
40
+ createRepoSnapshot,
41
+ ManifestError,
42
+ type MutantManifest,
43
+ type MutantSpec,
44
+ makeOriginChecks,
45
+ originHead,
46
+ originWorkSurfaceSha,
47
+ qualifyMutants,
48
+ reportPassed,
49
+ runGateBounded,
50
+ signatureOnFailureLine,
51
+ sweepStaleSnapshots,
52
+ validateManifest,
53
+ validateManifestSet,
54
+ } from "./lib/mutation-qualify.ts";
55
+
56
+ const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
57
+ const MUTANTS_DIR = path.join(REPO_DIR, "scripts", "mutants");
58
+
59
+ let passed = 0;
60
+ function ok(label: string, cond: boolean): void {
61
+ assert.ok(cond, label);
62
+ console.log(` ok ${label}`);
63
+ passed++;
64
+ }
65
+
66
+ function throws(label: string, fn: () => void, naming: string): void {
67
+ try {
68
+ fn();
69
+ } catch (err) {
70
+ const msg = err instanceof Error ? err.message : String(err);
71
+ assert.ok(msg.includes(naming), `${label} — error should name \`${naming}\`, got: ${msg}`);
72
+ console.log(` ok ${label}`);
73
+ passed++;
74
+ return;
75
+ }
76
+ assert.fail(`${label} — expected a loud refusal, got silence`);
77
+ }
78
+
79
+ // ═══ Phase 1a — the pure classifier, exhausted as a truth table ═════════════
80
+
81
+ {
82
+ const base: import("./lib/mutation-qualify.ts").MutantRunFacts = {
83
+ controlPreOk: true,
84
+ matchCount: 1,
85
+ timedOut: false,
86
+ exitCode: 1,
87
+ signatureOnFailureLine: true,
88
+ restoredOk: true,
89
+ };
90
+ const rows: [string, Partial<typeof base>, string][] = [
91
+ ["nonzero + signature on a failure line", {}, "KILLED"],
92
+ ["control-pre red outranks everything", { controlPreOk: false, exitCode: 0 }, "CONTROL-RED"],
93
+ ["zero-match is stale production drift, never a kill", { matchCount: 0 }, "MUTANT-STALE"],
94
+ ["multi-match refuses before writing", { matchCount: 2 }, "MULTI-MATCH"],
95
+ ["failed restore outranks the run outcome (a dirty kill is not a kill)", { restoredOk: false }, "IMPURE"],
96
+ ["timeout is HANG, its own class", { timedOut: true, exitCode: null }, "HANG"],
97
+ ["exit 0 with the defect planted is SURVIVED", { exitCode: 0, signatureOnFailureLine: false }, "SURVIVED"],
98
+ [
99
+ "nonzero WITHOUT the claimed signature is WRONG-REASON, never rounded up",
100
+ { signatureOnFailureLine: false },
101
+ "WRONG-REASON",
102
+ ],
103
+ [
104
+ "a SIGNAL CRASH (exit null outside our timeout) is WRONG-REASON even with the token present — not a bounded kill",
105
+ { exitCode: null },
106
+ "WRONG-REASON",
107
+ ],
108
+ ];
109
+ for (const [why, patch, want] of rows) {
110
+ ok(`classifier: ${why} → ${want}`, classifyMutantRun({ ...base, ...patch }) === want);
111
+ }
112
+ ok(
113
+ "signature: a token on an `ok` line does NOT count (a later-assertion red must not self-certify)",
114
+ signatureOnFailureLine(" ok something [QK:X] fine\nFAIL: other reason", "[QK:X]") === false,
115
+ );
116
+ ok(
117
+ "signature: a token on a FAIL line counts",
118
+ signatureOnFailureLine(" ok unrelated\nFAIL: broken [QK:X] here", "[QK:X]") === true,
119
+ );
120
+ ok(
121
+ "signature: an assertion-error line counts (TS gates print the label via AssertionError)",
122
+ signatureOnFailureLine("AssertionError [ERR_ASSERTION]: label [QK:X]", "[QK:X]") === true,
123
+ );
124
+
125
+ // Exhaustive truth table (#57 runner qualification): EVERY fact combination — 2
126
+ // control × 3 match classes × 2 timeout × 3 exit classes × 2 signature × 2 restore
127
+ // = 144 rows. The named rows above document each verdict; this loop closes the rest
128
+ // of the space. Two independent assertions per row: the precedence oracle (restated
129
+ // here as full per-verdict conditions), and — standalone, because it is the verdict
130
+ // the whole gate exists to guard — KILLED iff its full 7-way conjunction, so no
131
+ // dropped guard or reordering can round any other row up to a kill.
132
+ {
133
+ let rows = 0;
134
+ for (const controlPreOk of [true, false]) {
135
+ for (const matchCount of [0, 1, 2]) {
136
+ for (const timedOut of [false, true]) {
137
+ for (const exitCode of [null, 0, 1]) {
138
+ for (const sig of [true, false]) {
139
+ for (const restoredOk of [true, false]) {
140
+ const facts = {
141
+ controlPreOk,
142
+ matchCount,
143
+ timedOut,
144
+ exitCode,
145
+ signatureOnFailureLine: sig,
146
+ restoredOk,
147
+ };
148
+ const got = classifyMutantRun(facts);
149
+ const killedIff =
150
+ controlPreOk &&
151
+ matchCount === 1 &&
152
+ restoredOk &&
153
+ !timedOut &&
154
+ exitCode !== null &&
155
+ exitCode !== 0 &&
156
+ sig;
157
+ assert.equal(
158
+ got === "KILLED",
159
+ killedIff,
160
+ `classifier truth table: KILLED must hold iff its full conjunction — row ${JSON.stringify(facts)} → ${got}`,
161
+ );
162
+ const expected = !controlPreOk
163
+ ? "CONTROL-RED"
164
+ : matchCount === 0
165
+ ? "MUTANT-STALE"
166
+ : matchCount > 1
167
+ ? "MULTI-MATCH"
168
+ : !restoredOk
169
+ ? "IMPURE"
170
+ : timedOut
171
+ ? "HANG"
172
+ : exitCode === null
173
+ ? "WRONG-REASON"
174
+ : exitCode === 0
175
+ ? "SURVIVED"
176
+ : sig
177
+ ? "KILLED"
178
+ : "WRONG-REASON";
179
+ assert.equal(
180
+ got,
181
+ expected,
182
+ `classifier truth table: row ${JSON.stringify(facts)} → ${got}, oracle says ${expected}`,
183
+ );
184
+ rows++;
185
+ }
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+ ok(
192
+ "classifier: exhaustive truth table — all 144 fact rows match the oracle, KILLED iff its conjunction",
193
+ rows === 144,
194
+ );
195
+ }
196
+ }
197
+
198
+ // ═══ Phase 1b — manifest validation negatives (before any snapshot work) ════
199
+
200
+ {
201
+ const good = {
202
+ schemaVersion: 1,
203
+ lane: "test",
204
+ mutants: [
205
+ {
206
+ claim: "SELFTEST-KILL",
207
+ title: "t",
208
+ subject: "subject.txt",
209
+ find: ["original-line"],
210
+ replace: ["defect-line"],
211
+ gate: ["bash", "gates/kill.sh"],
212
+ timeoutSeconds: 10,
213
+ signature: "[QK:SELFTEST-KILL]",
214
+ signatureSource: "gates/kill.sh",
215
+ },
216
+ ],
217
+ };
218
+ ok("manifest: a well-formed manifest validates", validateManifest(good, "good").mutants.length === 1);
219
+ throws(
220
+ "manifest: an unknown key is refused loudly",
221
+ () => validateManifest({ ...good, extra: 1 }, "m"),
222
+ "unknown key",
223
+ );
224
+ throws(
225
+ "manifest: a missing field is refused loudly",
226
+ () => validateManifest({ schemaVersion: 1, lane: "x" }, "m"),
227
+ "missing key",
228
+ );
229
+ throws(
230
+ "manifest: a wrong schemaVersion is refused",
231
+ () => validateManifest({ ...good, schemaVersion: 2 }, "m"),
232
+ "schemaVersion",
233
+ );
234
+ const mutate = (patch: Record<string, unknown>): unknown => ({
235
+ ...good,
236
+ mutants: [{ ...good.mutants[0], ...patch }],
237
+ });
238
+ throws(
239
+ "manifest: an absolute subject path is refused",
240
+ () => validateManifest(mutate({ subject: "/etc/passwd" }), "m"),
241
+ "repo-relative",
242
+ );
243
+ throws(
244
+ "manifest: a `..` subject escape is refused",
245
+ () => validateManifest(mutate({ subject: "../outside.txt" }), "m"),
246
+ "repo-relative",
247
+ );
248
+ throws(
249
+ "manifest: a node_modules subject is refused (shared dependency symlink)",
250
+ () => validateManifest(mutate({ subject: "node_modules/x.js" }), "m"),
251
+ "node_modules",
252
+ );
253
+ throws(
254
+ "manifest: replace identical to find is refused",
255
+ () => validateManifest(mutate({ replace: ["original-line"] }), "m"),
256
+ "differ",
257
+ );
258
+ throws(
259
+ "manifest: a signature that is not [QK:<claim>] is refused",
260
+ () => validateManifest(mutate({ signature: "[QK:OTHER]" }), "m"),
261
+ "signature must be exactly",
262
+ );
263
+ throws(
264
+ "manifest: a wrong-typed timeout is refused",
265
+ () => validateManifest(mutate({ timeoutSeconds: "10" }), "m"),
266
+ "timeoutSeconds",
267
+ );
268
+ throws(
269
+ "manifest: an out-of-range timeout is refused",
270
+ () => validateManifest(mutate({ timeoutSeconds: 0 }), "m"),
271
+ "timeoutSeconds",
272
+ );
273
+ throws(
274
+ "manifest: a shell-string gate is refused (argv array only)",
275
+ () => validateManifest(mutate({ gate: "bash gates/kill.sh" }), "m"),
276
+ "non-empty array",
277
+ );
278
+
279
+ const man = validateManifest(good, "a") as MutantManifest;
280
+ const dupSet = [man, validateManifest({ ...good, lane: "other" }, "b")];
281
+ const permissiveOrigin = {
282
+ subjectTracked: () => true,
283
+ regularContainedFile: () => true,
284
+ onWorkSurface: () => true,
285
+ tokenCount: () => 1,
286
+ };
287
+ throws(
288
+ "manifest set: a duplicate claim across manifests is refused",
289
+ () => validateManifestSet(dupSet, permissiveOrigin),
290
+ "duplicate claim",
291
+ );
292
+ throws(
293
+ "manifest set: a subject NOT tracked in the origin index is refused",
294
+ () => validateManifestSet([man], { ...permissiveOrigin, subjectTracked: () => false }),
295
+ "not tracked",
296
+ );
297
+ throws(
298
+ "manifest set: a claim token absent from its gate source is refused",
299
+ () => validateManifestSet([man], { ...permissiveOrigin, tokenCount: () => 0 }),
300
+ "occurs 0",
301
+ );
302
+ throws(
303
+ "manifest set: a claim token duplicated in its gate source is refused",
304
+ () => validateManifestSet([man], { ...permissiveOrigin, tokenCount: () => 2 }),
305
+ "occurs 2",
306
+ );
307
+ // P0-1: tracked is not enough — a tracked SYMLINK subject would let the snapshot's
308
+ // read/write follow it OUT of the sandbox. Both paths must be lstat-regular.
309
+ throws(
310
+ "manifest set: a subject that is not a regular non-symlink file is refused (P0-1)",
311
+ () => validateManifestSet([man], { ...permissiveOrigin, regularContainedFile: (f: string) => f !== "subject.txt" }),
312
+ "symlink-escape guard",
313
+ );
314
+ throws(
315
+ "manifest set: a signatureSource off the origin work surface is refused",
316
+ () => validateManifestSet([man], { ...permissiveOrigin, onWorkSurface: () => false }),
317
+ "not on the origin work surface",
318
+ );
319
+ throws(
320
+ "manifest set: a symlink signatureSource is refused",
321
+ () =>
322
+ validateManifestSet([man], { ...permissiveOrigin, regularContainedFile: (f: string) => f !== "gates/kill.sh" }),
323
+ "signatureSource gates/kill.sh is not a regular",
324
+ );
325
+ }
326
+
327
+ // ═══ Phase 1c — the pipeline over a synthetic fixture repo ══════════════════
328
+
329
+ function buildFixtureOrigin(): { dir: string; externalTarget: string } {
330
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-qualify-fixture-"));
331
+ // A file OUTSIDE the fixture repo, reachable only through a tracked symlink — the
332
+ // P0-1 escape shape: mutating `linked.txt` would write THIS file.
333
+ const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-qualify-external-"));
334
+ const externalTarget = path.join(externalDir, "external-target.txt");
335
+ fs.writeFileSync(externalTarget, "original-line outside the sandbox\n");
336
+ const write = (rel: string, body: string, mode = 0o644): void => {
337
+ const p = path.join(dir, rel);
338
+ fs.mkdirSync(path.dirname(p), { recursive: true });
339
+ fs.writeFileSync(p, body);
340
+ fs.chmodSync(p, mode);
341
+ };
342
+ write("subject.txt", "alpha\noriginal-line\nomega\n");
343
+ write("double.txt", "dup-line\ndup-line\n");
344
+ // Each fixture gate reads the subject and goes red IFF the planted defect is present.
345
+ write(
346
+ "gates/kill.sh",
347
+ '#!/usr/bin/env bash\nif grep -q defect-line subject.txt; then echo "FAIL: planted defect detected [QK:SELFTEST-KILL]" >&2; exit 1; fi\nexit 0\n',
348
+ 0o755,
349
+ );
350
+ write("gates/survive.sh", "#!/usr/bin/env bash\nexit 0\n", 0o755);
351
+ write(
352
+ "gates/wrong.sh",
353
+ '#!/usr/bin/env bash\nif grep -q defect-line subject.txt; then echo "FAIL: some other assertion tripped [QK:SELFTEST-OTHER]" >&2; exit 1; fi\nexit 0\n',
354
+ 0o755,
355
+ );
356
+ write(
357
+ "gates/hang.sh",
358
+ '#!/usr/bin/env bash\nif grep -q defect-line subject.txt; then sleep 300 & echo $! > "$HOME/grandchild.pid"; sleep 300; fi\nexit 0\n',
359
+ 0o755,
360
+ );
361
+ write("gates/red.sh", '#!/usr/bin/env bash\necho "FAIL: baseline is red [QK:SELFTEST-PRERED]" >&2\nexit 1\n', 0o755);
362
+ write(
363
+ "gates/stray.sh",
364
+ '#!/usr/bin/env bash\nif grep -q defect-line subject.txt; then echo stray > stray-file.txt; echo "FAIL: planted defect detected [QK:SELFTEST-STRAY]" >&2; exit 1; fi\nexit 0\n',
365
+ 0o755,
366
+ );
367
+ write(
368
+ "gates/poison.sh",
369
+ '#!/usr/bin/env bash\nif [ -f poison.marker ]; then echo "FAIL: poisoned state [QK:SELFTEST-POISON]" >&2; exit 1; fi\nif grep -q defect-line subject.txt; then touch poison.marker; echo "FAIL: planted defect detected [QK:SELFTEST-POISON]" >&2; exit 1; fi\nexit 0\n',
370
+ 0o755,
371
+ );
372
+ // Red IFF the literal `$&` replacement text landed byte-for-byte: an interpreting
373
+ // String.replace would write the matched line back instead, this grep would miss,
374
+ // and the mutant would SURVIVE — so KILLED proves the replacement stayed literal.
375
+ write(
376
+ "gates/dollar.sh",
377
+ "#!/usr/bin/env bash\nif grep -qF 'dollar-$&-defect' subject.txt; then echo \"FAIL: literal replacement landed [QK:SELFTEST-DOLLAR]\" >&2; exit 1; fi\nexit 0\n",
378
+ 0o755,
379
+ );
380
+ write("untracked.txt", "present on disk but never git-added\n");
381
+ fs.symlinkSync(externalTarget, path.join(dir, "linked.txt"));
382
+ const git = (...args: string[]): void => {
383
+ const r = spawnSync("git", ["-C", dir, ...args], {
384
+ stdio: "ignore",
385
+ env: {
386
+ ...process.env,
387
+ GIT_CONFIG_GLOBAL: "/dev/null",
388
+ GIT_CONFIG_SYSTEM: "/dev/null",
389
+ GIT_AUTHOR_NAME: "fx",
390
+ GIT_AUTHOR_EMAIL: "fx@localhost",
391
+ GIT_COMMITTER_NAME: "fx",
392
+ GIT_COMMITTER_EMAIL: "fx@localhost",
393
+ },
394
+ });
395
+ assert.equal(r.status, 0, `fixture git ${args.join(" ")} failed`);
396
+ };
397
+ git("init", "-q");
398
+ git("add", "subject.txt", "double.txt", "gates", "linked.txt");
399
+ git("-c", "commit.gpgsign=false", "commit", "-q", "-m", "fixture");
400
+ return { dir, externalTarget };
401
+ }
402
+
403
+ function fixtureSpec(patch: Partial<MutantSpec>): MutantSpec {
404
+ return {
405
+ claim: "SELFTEST-KILL",
406
+ title: "fixture",
407
+ subject: "subject.txt",
408
+ find: ["original-line"],
409
+ replace: ["defect-line"],
410
+ gate: ["bash", "gates/kill.sh"],
411
+ timeoutSeconds: 30,
412
+ signature: "[QK:SELFTEST-KILL]",
413
+ signatureSource: "gates/kill.sh",
414
+ ...patch,
415
+ };
416
+ }
417
+
418
+ const quiet = (): void => {};
419
+
420
+ {
421
+ const { dir: fixtureOrigin, externalTarget } = buildFixtureOrigin();
422
+ try {
423
+ const checks = makeOriginChecks(fixtureOrigin);
424
+ ok("origin checks: a tracked subject is recognized", checks.subjectTracked("subject.txt") === true);
425
+ ok(
426
+ "origin checks: a file present on disk but NOT in the index is refused (hardening #3)",
427
+ checks.subjectTracked("untracked.txt") === false,
428
+ );
429
+ ok(
430
+ "origin checks: token counting reads the origin bytes",
431
+ checks.tokenCount("gates/kill.sh", "[QK:SELFTEST-KILL]") === 1,
432
+ );
433
+ // P0-1 against a REAL tracked symlink (not an injected fake): the lstat check
434
+ // must refuse it even though `git ls-files --error-unmatch` calls it tracked.
435
+ ok(
436
+ "origin checks: a TRACKED SYMLINK is refused by the regular-file check (real lstat)",
437
+ checks.subjectTracked("linked.txt") === true && checks.regularContainedFile("linked.txt") === false,
438
+ );
439
+ ok(
440
+ "origin checks: a regular tracked file passes the regular-file check",
441
+ checks.regularContainedFile("subject.txt") === true,
442
+ );
443
+ ok(
444
+ "origin checks: the work surface includes untracked-non-ignored files (a new gate before its first commit)",
445
+ checks.onWorkSurface("untracked.txt") === true && checks.onWorkSurface("absent-file.txt") === false,
446
+ );
447
+ throws(
448
+ "manifest set: a REAL tracked-symlink subject is refused end-to-end (external escape shape)",
449
+ () =>
450
+ validateManifestSet(
451
+ [{ schemaVersion: 1, lane: "escape", mutants: [fixtureSpec({ subject: "linked.txt" })] }],
452
+ checks,
453
+ ),
454
+ "symlink-escape guard",
455
+ );
456
+
457
+ const snap = createRepoSnapshot(fixtureOrigin);
458
+ try {
459
+ ok(
460
+ "snapshot: replicates only the git surface (untracked.txt IS listed by --others, so it rides too)",
461
+ fs.existsSync(path.join(snap.repoDir, "untracked.txt")),
462
+ );
463
+ ok("snapshot: base dir is mode 0700", (fs.statSync(snap.baseDir).mode & 0o777) === 0o700);
464
+ ok(
465
+ "snapshot: executable modes survive the copy",
466
+ (fs.statSync(path.join(snap.repoDir, "gates/kill.sh")).mode & 0o111) !== 0,
467
+ );
468
+ ok(
469
+ "snapshot: has its own git baseline (porcelain clean)",
470
+ spawnSync("git", ["-C", snap.repoDir, "status", "--porcelain"], {
471
+ encoding: "utf8",
472
+ env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" },
473
+ }).stdout.trim() === "",
474
+ );
475
+
476
+ // KILLED positive + zero-match + multi-match + SURVIVED + WRONG-REASON in one report.
477
+ const report = await qualifyMutants(
478
+ snap,
479
+ [
480
+ fixtureSpec({}),
481
+ fixtureSpec({ claim: "SELFTEST-STALE", signature: "[QK:SELFTEST-STALE]", find: ["never-present-line"] }),
482
+ fixtureSpec({
483
+ claim: "SELFTEST-MULTI",
484
+ signature: "[QK:SELFTEST-MULTI]",
485
+ subject: "double.txt",
486
+ find: ["dup-line"],
487
+ replace: ["defect-line"],
488
+ }),
489
+ fixtureSpec({
490
+ claim: "SELFTEST-SURVIVE",
491
+ signature: "[QK:SELFTEST-SURVIVE]",
492
+ gate: ["bash", "gates/survive.sh"],
493
+ }),
494
+ fixtureSpec({ claim: "SELFTEST-WRONG", signature: "[QK:SELFTEST-WRONG]", gate: ["bash", "gates/wrong.sh"] }),
495
+ fixtureSpec({
496
+ claim: "SELFTEST-DOLLAR",
497
+ signature: "[QK:SELFTEST-DOLLAR]",
498
+ replace: ["dollar-$&-defect"],
499
+ gate: ["bash", "gates/dollar.sh"],
500
+ }),
501
+ ],
502
+ quiet,
503
+ );
504
+ const byClaim = new Map(report.groups.flatMap((g) => g.mutants).map((m) => [m.claim, m.verdict]));
505
+ ok(
506
+ "pipeline: a planted defect the gate catches with its own token → KILLED",
507
+ byClaim.get("SELFTEST-KILL") === "KILLED",
508
+ );
509
+ ok(
510
+ "pipeline: a find that no longer matches → MUTANT-STALE (red, never vacuous-killed)",
511
+ byClaim.get("SELFTEST-STALE") === "MUTANT-STALE",
512
+ );
513
+ ok(
514
+ "pipeline: a find matching twice → MULTI-MATCH (nothing written)",
515
+ byClaim.get("SELFTEST-MULTI") === "MULTI-MATCH",
516
+ );
517
+ ok("pipeline: a gate green under the defect → SURVIVED", byClaim.get("SELFTEST-SURVIVE") === "SURVIVED");
518
+ ok(
519
+ "pipeline: a gate red at ANOTHER claim's token → WRONG-REASON, never rounded up to KILLED",
520
+ byClaim.get("SELFTEST-WRONG") === "WRONG-REASON",
521
+ );
522
+ ok(
523
+ "pipeline: the multi-match subject was never written",
524
+ fs.readFileSync(path.join(snap.repoDir, "double.txt"), "utf8") === "dup-line\ndup-line\n",
525
+ );
526
+ ok(
527
+ "pipeline: a replacement carrying `$&` lands LITERALLY (String.replace patterns are inert) → KILLED",
528
+ byClaim.get("SELFTEST-DOLLAR") === "KILLED",
529
+ );
530
+ ok("pipeline: the snapshot tree stayed clean across restores", report.treeClean && report.porcelainClean);
531
+ ok("pipeline: a report with non-KILLED verdicts does not pass", reportPassed(report) === false);
532
+
533
+ // HANG: bounded, restore still happens, and the whole report stays honest.
534
+ const hangReport = await qualifyMutants(
535
+ snap,
536
+ [
537
+ fixtureSpec({
538
+ claim: "SELFTEST-HANG",
539
+ signature: "[QK:SELFTEST-HANG]",
540
+ gate: ["bash", "gates/hang.sh"],
541
+ timeoutSeconds: 2,
542
+ }),
543
+ ],
544
+ quiet,
545
+ );
546
+ const hang = hangReport.groups[0].mutants[0];
547
+ ok("pipeline: a hanging gate is classified HANG within its bound", hang.verdict === "HANG" && hang.seconds < 30);
548
+ ok(
549
+ "pipeline: the subject is restored even when the gate hung (finally-path restore)",
550
+ fs.readFileSync(path.join(snap.repoDir, "subject.txt"), "utf8") === "alpha\noriginal-line\nomega\n",
551
+ );
552
+
553
+ // CONTROL-PRE red: the fake-KILLED hole — a baseline-red gate must abort its group.
554
+ const preRed = await qualifyMutants(
555
+ snap,
556
+ [fixtureSpec({ claim: "SELFTEST-PRERED", signature: "[QK:SELFTEST-PRERED]", gate: ["bash", "gates/red.sh"] })],
557
+ quiet,
558
+ );
559
+ ok(
560
+ "pipeline: a baseline-red gate → group pre-red, mutant CONTROL-RED (no fake KILLED)",
561
+ preRed.groups[0].control === "pre-red" && preRed.groups[0].mutants[0].verdict === "CONTROL-RED",
562
+ );
563
+ ok("pipeline: a control-red report does not pass", reportPassed(preRed) === false);
564
+
565
+ // Stray write: mutant kills, but the tree manifest catches the impurity.
566
+ const stray = await qualifyMutants(
567
+ snap,
568
+ [fixtureSpec({ claim: "SELFTEST-STRAY", signature: "[QK:SELFTEST-STRAY]", gate: ["bash", "gates/stray.sh"] })],
569
+ quiet,
570
+ );
571
+ ok(
572
+ "pipeline: a gate that wrote a stray file into the tree → treeClean=false (porcelain-invisible writes included)",
573
+ stray.treeClean === false,
574
+ );
575
+ ok(
576
+ "pipeline: an impure-tree report does not pass even with a KILLED mutant",
577
+ stray.groups[0].mutants[0].verdict === "KILLED" && reportPassed(stray) === false,
578
+ );
579
+ fs.rmSync(path.join(snap.repoDir, "stray-file.txt"), { force: true });
580
+
581
+ // State poison: mutant kills but leaves state that turns CONTROL-POST red.
582
+ const poison = await qualifyMutants(
583
+ snap,
584
+ [
585
+ fixtureSpec({
586
+ claim: "SELFTEST-POISON",
587
+ signature: "[QK:SELFTEST-POISON]",
588
+ gate: ["bash", "gates/poison.sh"],
589
+ }),
590
+ ],
591
+ quiet,
592
+ );
593
+ ok(
594
+ "pipeline: state leaked by a mutant run turns CONTROL-POST red (group impure)",
595
+ poison.groups[0].control === "post-red",
596
+ );
597
+ ok("pipeline: a post-red report does not pass", reportPassed(poison) === false);
598
+ fs.rmSync(path.join(snap.repoDir, "poison.marker"), { force: true });
599
+
600
+ // P0-1 RUNTIME guard: the self-test injects specs directly (bypassing
601
+ // validateManifestSet), so the escape must ALSO be stopped at the moment of
602
+ // mutation — refused before a byte is read, external target untouched.
603
+ const externalBefore = fs.readFileSync(externalTarget, "utf8");
604
+ await assert.rejects(
605
+ qualifyMutants(
606
+ snap,
607
+ [
608
+ fixtureSpec({
609
+ claim: "SELFTEST-ESCAPE",
610
+ signature: "[QK:SELFTEST-ESCAPE]",
611
+ subject: "linked.txt",
612
+ gate: ["bash", "gates/survive.sh"],
613
+ }),
614
+ ],
615
+ quiet,
616
+ ),
617
+ /symlink-escape guard/,
618
+ "a tracked-symlink subject must be refused at mutation time, loudly",
619
+ );
620
+ ok(
621
+ "pipeline: the symlink-escape refusal left the EXTERNAL target byte-identical (no write followed the link)",
622
+ fs.readFileSync(externalTarget, "utf8") === externalBefore,
623
+ );
624
+ } finally {
625
+ fs.rmSync(snap.baseDir, { recursive: true, force: true });
626
+ }
627
+
628
+ // P1-5: the origin tripwire hashes the WORK SURFACE CONTENT, not `status
629
+ // --porcelain` text — a porcelain-based tripwire cannot see a byte change inside
630
+ // a file that was ALREADY modified (same ` M` row before and after). Prove the
631
+ // discriminating power on exactly that shape: modify → hash moves; modify the
632
+ // BYTES AGAIN (porcelain text identical) → hash moves again; restore → hash back.
633
+ {
634
+ const subjectPath = path.join(fixtureOrigin, "subject.txt");
635
+ const pristine = fs.readFileSync(subjectPath);
636
+ const h0 = originWorkSurfaceSha(fixtureOrigin);
637
+ fs.writeFileSync(subjectPath, "alpha\nmodified-once\nomega\n");
638
+ const h1 = originWorkSurfaceSha(fixtureOrigin);
639
+ fs.writeFileSync(subjectPath, "alpha\nmodified-twice\nomega\n");
640
+ const h2 = originWorkSurfaceSha(fixtureOrigin);
641
+ fs.writeFileSync(subjectPath, pristine);
642
+ const h3 = originWorkSurfaceSha(fixtureOrigin);
643
+ ok("tripwire: a first modification moves the work-surface hash", h1 !== h0);
644
+ ok(
645
+ "tripwire: a byte change INSIDE an already-modified file moves the hash again (porcelain would not see it)",
646
+ h2 !== h1,
647
+ );
648
+ ok("tripwire: restoring the bytes restores the hash (content-derived, not time-derived)", h3 === h0);
649
+ }
650
+
651
+ // pgroup kill: the grandchild really dies on this host (hardening #6, Linux axis).
652
+ {
653
+ const invocationDir = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-qualify-pgk-"));
654
+ const pidFile = path.join(invocationDir, "grandchild.pid");
655
+ const script = path.join(invocationDir, "spawner.sh");
656
+ fs.writeFileSync(script, `#!/usr/bin/env bash\nsleep 300 & echo $! > ${JSON.stringify(pidFile)}\nwait\n`);
657
+ fs.chmodSync(script, 0o755);
658
+ const run = await runGateBounded({
659
+ cwd: invocationDir,
660
+ argv: ["bash", script],
661
+ timeoutSeconds: 2,
662
+ invocationDir,
663
+ });
664
+ ok("pgroup: the spawner timed out as expected", run.timedOut === true);
665
+ const grandchild = Number(fs.readFileSync(pidFile, "utf8").trim());
666
+ let dead = false;
667
+ for (let i = 0; i < 20 && !dead; i++) {
668
+ try {
669
+ process.kill(grandchild, 0);
670
+ await new Promise((r) => setTimeout(r, 50));
671
+ } catch {
672
+ dead = true;
673
+ }
674
+ }
675
+ ok(`pgroup: the GRANDCHILD (pid ${grandchild}) is dead after the group SIGKILL`, dead);
676
+ fs.rmSync(invocationDir, { recursive: true, force: true });
677
+ }
678
+
679
+ // Stale-snapshot sweep: dead-pid residue is reclaimed, a live runner's dir survives.
680
+ {
681
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-qualify-sweep-"));
682
+ const deadDir = path.join(tmpRoot, "entwurf-qualify-dead1");
683
+ fs.mkdirSync(deadDir);
684
+ fs.writeFileSync(path.join(deadDir, "runner.json"), JSON.stringify({ pid: 999_999_999, startedAt: 0 }));
685
+ const liveDir = path.join(tmpRoot, "entwurf-qualify-live1");
686
+ fs.mkdirSync(liveDir);
687
+ fs.writeFileSync(path.join(liveDir, "runner.json"), JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
688
+ const swept = sweepStaleSnapshots(tmpRoot);
689
+ ok("sweep: a dead-pid snapshot dir is reclaimed", swept.includes(deadDir) && !fs.existsSync(deadDir));
690
+ ok("sweep: a live runner's snapshot dir survives", fs.existsSync(liveDir));
691
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
692
+ }
693
+ } finally {
694
+ fs.rmSync(fixtureOrigin, { recursive: true, force: true });
695
+ fs.rmSync(path.dirname(externalTarget), { recursive: true, force: true });
696
+ }
697
+ }
698
+
699
+ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
700
+
701
+ // ═══ Phase 2 — the real manifests against a snapshot of THIS repo ═══════════
702
+
703
+ {
704
+ const manifests: MutantManifest[] = fs
705
+ .readdirSync(MUTANTS_DIR)
706
+ .filter((f) => f.endsWith(".json"))
707
+ .sort()
708
+ .map((f) => {
709
+ let raw: unknown;
710
+ try {
711
+ raw = JSON.parse(fs.readFileSync(path.join(MUTANTS_DIR, f), "utf8"));
712
+ } catch (err) {
713
+ throw new ManifestError(`${f}: not valid JSON — ${err instanceof Error ? err.message : String(err)}`);
714
+ }
715
+ return validateManifest(raw, f);
716
+ });
717
+ // Vacuous-green guard (matrix-style table integrity): an emptied mutants dir or a
718
+ // manifest lost in a merge must name itself here — "0/0 killed" is not a pass.
719
+ // Extend this inventory and the manifests TOGETHER, never silently.
720
+ const EXPECTED_LANE_MUTANTS: Record<string, number> = {
721
+ "acp-augment": 2,
722
+ "acp-cortex": 12,
723
+ "agy-permission": 6,
724
+ "meta-identity": 1,
725
+ "probe-ordering": 83,
726
+ "self-address": 3,
727
+ "v2-surface": 4,
728
+ };
729
+ const laneTally: Record<string, number> = {};
730
+ for (const man of manifests) laneTally[man.lane] = (laneTally[man.lane] ?? 0) + man.mutants.length;
731
+ assert.deepEqual(
732
+ laneTally,
733
+ EXPECTED_LANE_MUTANTS,
734
+ "mutant lane inventory drifted from the declared contract — extend EXPECTED_LANE_MUTANTS and the manifests together, never silently",
735
+ );
736
+ const allMutants = validateManifestSet(manifests, makeOriginChecks(REPO_DIR));
737
+ const selected = allMutants;
738
+ console.log(
739
+ `[gate-qualification] ${selected.length} mutants across ${manifests.length} lanes (no tiers — full set every run)`,
740
+ );
741
+
742
+ const headBefore = originHead(REPO_DIR);
743
+ const workSurfaceBefore = originWorkSurfaceSha(REPO_DIR);
744
+
745
+ sweepStaleSnapshots(os.tmpdir());
746
+ const snap = createRepoSnapshot(REPO_DIR);
747
+ console.log(
748
+ `[gate-qualification] snapshot: ${snap.repoDir} (${snap.fileCount} files, origin HEAD ${headBefore.slice(0, 12)})`,
749
+ );
750
+ let report: Awaited<ReturnType<typeof qualifyMutants>>;
751
+ try {
752
+ report = await qualifyMutants(snap, selected, (line) => console.log(line));
753
+ } finally {
754
+ fs.rmSync(snap.baseDir, { recursive: true, force: true });
755
+ }
756
+
757
+ // Tripwire, not recovery: the runner never writes the origin, so any drift here is
758
+ // an outside writer racing the qualification — evidence cannot be attributed.
759
+ assert.equal(originHead(REPO_DIR), headBefore, "origin HEAD changed during qualification");
760
+ assert.equal(
761
+ originWorkSurfaceSha(REPO_DIR),
762
+ workSurfaceBefore,
763
+ "origin work surface changed during qualification (content hash, not porcelain text)",
764
+ );
765
+ console.log("[gate-qualification] origin checkout: HEAD + work-surface content hash identical before/after");
766
+
767
+ const killed = report.groups.flatMap((g) => g.mutants).filter((m) => m.verdict === "KILLED");
768
+ const failed = report.groups.flatMap((g) => g.mutants).filter((m) => m.verdict !== "KILLED");
769
+ const controlRed = report.groups.filter((g) => g.control !== "ok");
770
+ if (!report.treeClean || !report.porcelainClean) {
771
+ console.error(
772
+ `[gate-qualification] IMPURE: snapshot tree drifted (treeClean=${report.treeClean} porcelainClean=${report.porcelainClean})`,
773
+ );
774
+ }
775
+ for (const g of controlRed) {
776
+ console.error(`[gate-qualification] CONTROL ${g.control}: ${g.gate.join(" ")}`);
777
+ }
778
+ for (const m of failed) {
779
+ console.error(`[gate-qualification] NOT KILLED: ${m.claim} → ${m.verdict} (${m.detail})`);
780
+ }
781
+ console.log(
782
+ `\n[gate-qualification] qualified claims: ${killed.length}/${selected.length} killed — ${killed.map((m) => m.claim).join(", ")}`,
783
+ );
784
+ assert.ok(reportPassed(report), "gate qualification failed — see NOT KILLED / CONTROL lines above");
785
+ }
786
+
787
+ console.log("[gate-qualification] ok");