@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,794 @@
1
+ /**
2
+ * mutation-qualify — the gate-qualification runner core (kill-proof discipline).
3
+ *
4
+ * A gate is a test only if re-planting a closed defect makes that gate fail, for the
5
+ * exact reason it claims to guard. This module carries the minimal machinery to prove
6
+ * that automatically: declarative mutant manifests (one mutant = one closed defect,
7
+ * hand-written, never generated), an isolated SNAPSHOT repo the mutation runs in (the
8
+ * real checkout is never written), and a pure verdict classifier the self-test can
9
+ * exhaust as a truth table.
10
+ *
11
+ * Boundaries (agreed 2026-07-27, GLG+GPT design review — do not widen):
12
+ * - NOT a general mutation platform: no AST operators, no generated mutants, no
13
+ * kill-ratio scoring. Mutants are committed regression memorials only.
14
+ * - Mutation NEVER touches the real checkout. Everything — apply, gate run,
15
+ * restore — happens inside a temp snapshot replicating tracked+untracked files;
16
+ * the caller verifies the origin HEAD + work-surface CONTENT hash are identical
17
+ * before/after (porcelain text alone misses byte drift in already-modified files).
18
+ * - Every unique gate command gets an unmutated CONTROL run before and after its
19
+ * mutants (a gate already red at baseline can produce only fake KILLEDs).
20
+ * - Bounded: each gate run has a hard timeout and its process GROUP is killed.
21
+ * - Evidence is claim IDs + killed mutant IDs, never assertion counts.
22
+ */
23
+
24
+ import { spawn, spawnSync } from "node:child_process";
25
+ import { createHash } from "node:crypto";
26
+ import * as fs from "node:fs";
27
+ import * as os from "node:os";
28
+ import * as path from "node:path";
29
+
30
+ // ── verdicts ────────────────────────────────────────────────────────────────
31
+
32
+ export type MutantVerdict =
33
+ | "KILLED" // bounded nonzero exit + the claim token on a failure line
34
+ | "SURVIVED" // gate stayed green with the defect planted
35
+ | "WRONG-REASON" // gate went red, but not at the claimed assertion
36
+ | "HANG" // gate exceeded its bound; process group killed
37
+ | "MUTANT-STALE" // find matched 0 times — production drifted; re-derive the mutant
38
+ | "MULTI-MATCH" // find matched >1 — refuse before writing anything
39
+ | "CONTROL-RED" // the unmutated CONTROL failed; no KILLED can be claimed
40
+ | "IMPURE"; // restore/containment failed; evidence is contaminated
41
+
42
+ export interface MutantRunFacts {
43
+ controlPreOk: boolean;
44
+ matchCount: number;
45
+ timedOut: boolean;
46
+ /** child exit code; null when the child was killed (timeout). */
47
+ exitCode: number | null;
48
+ signatureOnFailureLine: boolean;
49
+ restoredOk: boolean;
50
+ }
51
+
52
+ /**
53
+ * The pure verdict mapping. Order is load-bearing and mirrored by the self-test
54
+ * truth table: control validity first, then match integrity, then purity, then the
55
+ * bounded-run outcome. `restoredOk` outranks the run outcome — a KILLED whose
56
+ * restore failed is contaminated evidence, not a kill.
57
+ */
58
+ export function classifyMutantRun(f: MutantRunFacts): MutantVerdict {
59
+ if (!f.controlPreOk) return "CONTROL-RED";
60
+ if (f.matchCount === 0) return "MUTANT-STALE";
61
+ if (f.matchCount > 1) return "MULTI-MATCH";
62
+ if (!f.restoredOk) return "IMPURE";
63
+ if (f.timedOut) return "HANG";
64
+ // A null exit outside our own timeout is a SIGNAL CRASH, not a bounded nonzero exit
65
+ // — even with the claim token somewhere in the output it proves nothing about the
66
+ // gate's assertion, so it can never be rounded up to KILLED (P0-2, 2026-07-27).
67
+ if (f.exitCode === null) return "WRONG-REASON";
68
+ if (f.exitCode === 0) return "SURVIVED";
69
+ return f.signatureOnFailureLine ? "KILLED" : "WRONG-REASON";
70
+ }
71
+
72
+ /**
73
+ * The claim token counts only on a FAILURE line. Gate labels print on success too
74
+ * (` ok <label>`), so a mutation that trips a LATER assertion would otherwise
75
+ * read its own passing ok-line as the kill signature.
76
+ */
77
+ export function signatureOnFailureLine(output: string, token: string): boolean {
78
+ return output.split("\n").some((line) => line.includes(token) && !/^\s*ok\b/.test(line));
79
+ }
80
+
81
+ // ── manifest schema (fail-loud, exact keys) ─────────────────────────────────
82
+
83
+ export interface MutantSpec {
84
+ /** Stable claim id, ^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*$, globally unique across manifests. */
85
+ claim: string;
86
+ title: string;
87
+ /** Repo-relative production subject. Must be tracked in the ORIGIN git index. */
88
+ subject: string;
89
+ /** Exact source lines (joined by \n); must occur exactly once in the subject. */
90
+ find: string[];
91
+ /** The defect-restoring replacement lines (joined by \n); must differ from find. */
92
+ replace: string[];
93
+ /** Gate argv (no shell string), run with cwd = snapshot repo root. */
94
+ gate: string[];
95
+ timeoutSeconds: number;
96
+ /** Always `[QK:<claim>]`; must appear exactly once in signatureSource. */
97
+ signature: string;
98
+ /** Repo-relative gate source file that owns the claim token. */
99
+ signatureSource: string;
100
+ }
101
+
102
+ export interface MutantManifest {
103
+ schemaVersion: 1;
104
+ lane: string;
105
+ mutants: MutantSpec[];
106
+ }
107
+
108
+ const CLAIM_RE = /^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*$/;
109
+ const MANIFEST_KEYS = ["schemaVersion", "lane", "mutants"];
110
+ const MUTANT_KEYS = [
111
+ "claim",
112
+ "title",
113
+ "subject",
114
+ "find",
115
+ "replace",
116
+ "gate",
117
+ "timeoutSeconds",
118
+ "signature",
119
+ "signatureSource",
120
+ ];
121
+
122
+ export class ManifestError extends Error {}
123
+
124
+ function requireExactKeys(obj: Record<string, unknown>, keys: string[], where: string): void {
125
+ for (const k of Object.keys(obj)) {
126
+ if (!keys.includes(k)) throw new ManifestError(`${where}: unknown key \`${k}\``);
127
+ }
128
+ for (const k of keys) {
129
+ if (!(k in obj)) throw new ManifestError(`${where}: missing key \`${k}\``);
130
+ }
131
+ }
132
+
133
+ function requireLines(v: unknown, where: string): string[] {
134
+ if (!Array.isArray(v) || v.length === 0 || !v.every((s) => typeof s === "string")) {
135
+ throw new ManifestError(`${where}: must be a non-empty array of strings`);
136
+ }
137
+ return v as string[];
138
+ }
139
+
140
+ /** Validate one manifest document; throws ManifestError with the exact defect. */
141
+ export function validateManifest(raw: unknown, name: string): MutantManifest {
142
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
143
+ throw new ManifestError(`${name}: manifest must be a JSON object`);
144
+ }
145
+ const doc = raw as Record<string, unknown>;
146
+ requireExactKeys(doc, MANIFEST_KEYS, name);
147
+ if (doc.schemaVersion !== 1) throw new ManifestError(`${name}: unknown schemaVersion ${String(doc.schemaVersion)}`);
148
+ if (typeof doc.lane !== "string" || doc.lane.length === 0) throw new ManifestError(`${name}: lane must be a string`);
149
+ if (!Array.isArray(doc.mutants) || doc.mutants.length === 0) {
150
+ throw new ManifestError(`${name}: mutants must be a non-empty array`);
151
+ }
152
+ const mutants: MutantSpec[] = [];
153
+ for (const [i, m] of doc.mutants.entries()) {
154
+ const where = `${name} mutants[${i}]`;
155
+ if (typeof m !== "object" || m === null) throw new ManifestError(`${where}: must be an object`);
156
+ const rec = m as Record<string, unknown>;
157
+ requireExactKeys(rec, MUTANT_KEYS, where);
158
+ if (typeof rec.claim !== "string" || !CLAIM_RE.test(rec.claim)) {
159
+ throw new ManifestError(`${where}: claim must match ${CLAIM_RE}`);
160
+ }
161
+ if (typeof rec.title !== "string" || rec.title.length === 0) throw new ManifestError(`${where}: title required`);
162
+ const subject = rec.subject;
163
+ if (typeof subject !== "string" || subject.length === 0) throw new ManifestError(`${where}: subject required`);
164
+ if (path.isAbsolute(subject) || subject.split(/[\\/]/).includes("..")) {
165
+ throw new ManifestError(`${where}: subject must be repo-relative without \`..\` (got ${subject})`);
166
+ }
167
+ if (subject === "node_modules" || subject.startsWith("node_modules/")) {
168
+ throw new ManifestError(`${where}: subject may not live under node_modules (a shared dependency symlink)`);
169
+ }
170
+ const find = requireLines(rec.find, `${where}.find`);
171
+ const replace = requireLines(rec.replace, `${where}.replace`);
172
+ if (find.join("\n") === replace.join("\n")) throw new ManifestError(`${where}: replace must differ from find`);
173
+ const gate = requireLines(rec.gate, `${where}.gate`);
174
+ if (
175
+ typeof rec.timeoutSeconds !== "number" ||
176
+ !Number.isInteger(rec.timeoutSeconds) ||
177
+ rec.timeoutSeconds < 1 ||
178
+ rec.timeoutSeconds > 600
179
+ ) {
180
+ throw new ManifestError(`${where}: timeoutSeconds must be an integer in 1..600`);
181
+ }
182
+ const expectedSig = `[QK:${rec.claim}]`;
183
+ if (rec.signature !== expectedSig) {
184
+ throw new ManifestError(`${where}: signature must be exactly ${expectedSig} (got ${String(rec.signature)})`);
185
+ }
186
+ const sigSrc = rec.signatureSource;
187
+ if (typeof sigSrc !== "string" || path.isAbsolute(sigSrc) || sigSrc.split(/[\\/]/).includes("..")) {
188
+ throw new ManifestError(`${where}: signatureSource must be a repo-relative path`);
189
+ }
190
+ mutants.push({
191
+ claim: rec.claim,
192
+ title: rec.title,
193
+ subject,
194
+ find,
195
+ replace,
196
+ gate,
197
+ timeoutSeconds: rec.timeoutSeconds,
198
+ signature: expectedSig,
199
+ signatureSource: sigSrc,
200
+ });
201
+ }
202
+ return { schemaVersion: 1, lane: doc.lane, mutants };
203
+ }
204
+
205
+ export interface OriginChecks {
206
+ /** claim → true when the subject is tracked in the origin index. */
207
+ subjectTracked: (subject: string) => boolean;
208
+ /** true when the path is a REGULAR non-symlink file whose realpath stays inside the origin. */
209
+ regularContainedFile: (file: string) => boolean;
210
+ /** true when the path is on the origin work surface (tracked or untracked-non-ignored). */
211
+ onWorkSurface: (file: string) => boolean;
212
+ /** how many times a token occurs in the origin bytes of a file (0 when unreadable). */
213
+ tokenCount: (file: string, token: string) => number;
214
+ }
215
+
216
+ /**
217
+ * Cross-manifest validation against the ORIGIN repo: global claim uniqueness,
218
+ * subject tracked in the origin git index (hardening #3 — "happens to exist in the
219
+ * snapshot" is not the contract), subject AND signatureSource lstat-regular
220
+ * non-symlink with realpath containment (P0-1 — a tracked SYMLINK subject would let
221
+ * the snapshot's read/write follow it OUT of the sandbox), and the claim token
222
+ * present exactly once in its declared gate source. signatureSource is checked
223
+ * against the WORK SURFACE (tracked ∪ untracked-non-ignored — exactly the snapshot
224
+ * replication set) rather than the index alone, so a brand-new gate file can carry
225
+ * its own claims before its first commit.
226
+ */
227
+ export function validateManifestSet(manifests: MutantManifest[], origin: OriginChecks): MutantSpec[] {
228
+ const all: MutantSpec[] = [];
229
+ const seen = new Map<string, string>();
230
+ for (const man of manifests) {
231
+ for (const m of man.mutants) {
232
+ const prior = seen.get(m.claim);
233
+ if (prior !== undefined) {
234
+ throw new ManifestError(`duplicate claim ${m.claim} (lanes ${prior} and ${man.lane})`);
235
+ }
236
+ seen.set(m.claim, man.lane);
237
+ if (!origin.subjectTracked(m.subject)) {
238
+ throw new ManifestError(`${m.claim}: subject ${m.subject} is not tracked in the origin git index`);
239
+ }
240
+ if (!origin.regularContainedFile(m.subject)) {
241
+ throw new ManifestError(
242
+ `${m.claim}: subject ${m.subject} is not a regular non-symlink file inside the origin (symlink-escape guard)`,
243
+ );
244
+ }
245
+ if (!origin.onWorkSurface(m.signatureSource)) {
246
+ throw new ManifestError(`${m.claim}: signatureSource ${m.signatureSource} is not on the origin work surface`);
247
+ }
248
+ if (!origin.regularContainedFile(m.signatureSource)) {
249
+ throw new ManifestError(
250
+ `${m.claim}: signatureSource ${m.signatureSource} is not a regular non-symlink file inside the origin`,
251
+ );
252
+ }
253
+ const n = origin.tokenCount(m.signatureSource, m.signature);
254
+ if (n !== 1) {
255
+ throw new ManifestError(
256
+ `${m.claim}: token ${m.signature} occurs ${n}× in ${m.signatureSource} (need exactly 1)`,
257
+ );
258
+ }
259
+ all.push(m);
260
+ }
261
+ }
262
+ return all;
263
+ }
264
+
265
+ // ── snapshot repo ───────────────────────────────────────────────────────────
266
+
267
+ const SNAPSHOT_PREFIX = "entwurf-qualify-";
268
+
269
+ const SNAPSHOT_GIT_ENV = {
270
+ GIT_CONFIG_GLOBAL: "/dev/null",
271
+ GIT_CONFIG_SYSTEM: "/dev/null",
272
+ GIT_CONFIG_NOSYSTEM: "1",
273
+ GIT_AUTHOR_NAME: "entwurf-qualify",
274
+ GIT_AUTHOR_EMAIL: "qualify@localhost",
275
+ GIT_COMMITTER_NAME: "entwurf-qualify",
276
+ GIT_COMMITTER_EMAIL: "qualify@localhost",
277
+ };
278
+
279
+ function gitIn(dir: string, args: string[]): string {
280
+ const r = spawnSync("git", ["-C", dir, ...args], {
281
+ encoding: "utf8",
282
+ env: { ...process.env, ...SNAPSHOT_GIT_ENV },
283
+ maxBuffer: 64 * 1024 * 1024,
284
+ });
285
+ if (r.status !== 0) {
286
+ throw new Error(`git ${args.join(" ")} failed in ${dir}: ${r.stderr || r.stdout}`);
287
+ }
288
+ return r.stdout;
289
+ }
290
+
291
+ /**
292
+ * Sweep stale snapshot dirs left by a SIGKILLed/powered-off run. Only dirs whose
293
+ * recorded runner pid is dead are removed; a dir with no runner.json is removed only
294
+ * once it is old (another runner may be mid-mkdtemp). Residue is inert tmp garbage —
295
+ * the real checkout was never written — so this is tidiness, not recovery.
296
+ */
297
+ export function sweepStaleSnapshots(tmpRoot: string): string[] {
298
+ const swept: string[] = [];
299
+ let entries: string[];
300
+ try {
301
+ entries = fs.readdirSync(tmpRoot);
302
+ } catch {
303
+ return swept;
304
+ }
305
+ for (const name of entries) {
306
+ if (!name.startsWith(SNAPSHOT_PREFIX)) continue;
307
+ const dir = path.join(tmpRoot, name);
308
+ const marker = path.join(dir, "runner.json");
309
+ let dead = false;
310
+ try {
311
+ const rec = JSON.parse(fs.readFileSync(marker, "utf8")) as { pid?: number };
312
+ if (typeof rec.pid !== "number") dead = true;
313
+ else if (rec.pid !== process.pid) {
314
+ try {
315
+ process.kill(rec.pid, 0);
316
+ } catch {
317
+ dead = true;
318
+ }
319
+ }
320
+ } catch {
321
+ try {
322
+ dead = Date.now() - fs.statSync(dir).mtimeMs > 60_000;
323
+ } catch {
324
+ continue;
325
+ }
326
+ }
327
+ if (dead) {
328
+ fs.rmSync(dir, { recursive: true, force: true });
329
+ swept.push(dir);
330
+ }
331
+ }
332
+ return swept;
333
+ }
334
+
335
+ export interface Snapshot {
336
+ baseDir: string;
337
+ repoDir: string;
338
+ fileCount: number;
339
+ }
340
+
341
+ /**
342
+ * Replicate the origin working surface (tracked + untracked, ignored excluded) into
343
+ * an isolated snapshot repo with its own git baseline, so gates that consult
344
+ * `git status --porcelain` keep their purity checks, and the runner can assert the
345
+ * whole snapshot tree afterward. node_modules is shared via a dependency symlink,
346
+ * created after the baseline commit and excluded from git — it can never be a
347
+ * mutation subject (schema refuses it), and the selected qualification children
348
+ * treat that shared dependency tree as read-only and never run a package manager.
349
+ */
350
+ export function createRepoSnapshot(originDir: string, tmpRoot: string = os.tmpdir()): Snapshot {
351
+ const baseDir = fs.mkdtempSync(path.join(tmpRoot, SNAPSHOT_PREFIX));
352
+ fs.chmodSync(baseDir, 0o700);
353
+ fs.writeFileSync(path.join(baseDir, "runner.json"), JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
354
+ const repoDir = path.join(baseDir, "repo");
355
+ fs.mkdirSync(repoDir, { mode: 0o700 });
356
+
357
+ const listOut = gitIn(originDir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
358
+ const files = [...new Set(listOut.split("\0").filter((f) => f.length > 0))];
359
+ let copied = 0;
360
+ for (const rel of files) {
361
+ const src = path.join(originDir, rel);
362
+ let st: fs.Stats;
363
+ try {
364
+ st = fs.lstatSync(src);
365
+ } catch {
366
+ continue; // tracked in index but deleted from the worktree — the worktree is authority
367
+ }
368
+ const dst = path.join(repoDir, rel);
369
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
370
+ if (st.isSymbolicLink()) {
371
+ fs.symlinkSync(fs.readlinkSync(src), dst);
372
+ } else if (st.isFile()) {
373
+ fs.copyFileSync(src, dst);
374
+ fs.chmodSync(dst, st.mode & 0o777);
375
+ } else {
376
+ throw new Error(`refusing to snapshot special file ${rel} (mode ${st.mode.toString(8)})`);
377
+ }
378
+ copied++;
379
+ }
380
+
381
+ gitIn(repoDir, ["init", "-q"]);
382
+ gitIn(repoDir, ["add", "-A"]);
383
+ gitIn(repoDir, ["-c", "commit.gpgsign=false", "commit", "-q", "-m", "qualification baseline"]);
384
+
385
+ // node_modules AFTER the baseline commit, git-excluded (hardening #2).
386
+ const originModules = path.join(originDir, "node_modules");
387
+ if (fs.existsSync(originModules)) {
388
+ fs.symlinkSync(originModules, path.join(repoDir, "node_modules"));
389
+ fs.appendFileSync(path.join(repoDir, ".git", "info", "exclude"), "node_modules\n");
390
+ }
391
+ return { baseDir, repoDir, fileCount: copied };
392
+ }
393
+
394
+ /**
395
+ * Content manifest of the snapshot tree — path+mode+sha256 for files, target for
396
+ * symlinks, bare entries for dirs — excluding .git and node_modules. Porcelain alone
397
+ * misses ignored-path writes; this does not (hardening #4).
398
+ */
399
+ export function computeTreeManifest(repoDir: string): string {
400
+ const lines: string[] = [];
401
+ const walk = (rel: string): void => {
402
+ const abs = path.join(repoDir, rel);
403
+ for (const name of fs.readdirSync(abs).sort()) {
404
+ if (rel === "" && (name === ".git" || name === "node_modules")) continue;
405
+ const childRel = rel === "" ? name : `${rel}/${name}`;
406
+ const st = fs.lstatSync(path.join(repoDir, childRel));
407
+ if (st.isSymbolicLink()) {
408
+ lines.push(`${childRel}\0link\0${fs.readlinkSync(path.join(repoDir, childRel))}`);
409
+ } else if (st.isDirectory()) {
410
+ lines.push(`${childRel}\0dir`);
411
+ walk(childRel);
412
+ } else if (st.isFile()) {
413
+ const sha = createHash("sha256")
414
+ .update(fs.readFileSync(path.join(repoDir, childRel)))
415
+ .digest("hex");
416
+ lines.push(`${childRel}\0${(st.mode & 0o777).toString(8)}\0${sha}`);
417
+ } else {
418
+ lines.push(`${childRel}\0special`);
419
+ }
420
+ }
421
+ };
422
+ walk("");
423
+ return createHash("sha256").update(lines.join("\n")).digest("hex");
424
+ }
425
+
426
+ // ── bounded gate execution ──────────────────────────────────────────────────
427
+
428
+ export interface GateRunResult {
429
+ exitCode: number | null;
430
+ timedOut: boolean;
431
+ output: string;
432
+ seconds: number;
433
+ }
434
+
435
+ const OUTPUT_CAP = 4 * 1024 * 1024;
436
+
437
+ /** Env prefixes the outer fence strips so a gate child starts from a neutral host. */
438
+ const STRIP_ENV_PREFIXES = ["ENTWURF_", "AGY_", "PI_SESSION_ID", "PI_AGENT_ID"];
439
+
440
+ function fencedEnv(invocationDir: string): NodeJS.ProcessEnv {
441
+ const env: NodeJS.ProcessEnv = {};
442
+ for (const [k, v] of Object.entries(process.env)) {
443
+ if (STRIP_ENV_PREFIXES.some((p) => k === p || k.startsWith(p))) continue;
444
+ env[k] = v;
445
+ }
446
+ for (const d of ["home", "xdg-data", "xdg-config", "xdg-cache", "xdg-state"]) {
447
+ fs.mkdirSync(path.join(invocationDir, d), { recursive: true });
448
+ }
449
+ env.HOME = path.join(invocationDir, "home");
450
+ // TMPDIR is deliberately INHERITED, not fenced into the invocation dir: gates carry
451
+ // their own mkdtemp+cleanup discipline, and a unix-socket cell (meta-identity's
452
+ // socket-shaped record) must bind under the ~108-byte sun_path limit — an invocation
453
+ // -nested TMPDIR pushed it over and turned the CONTROL red for a runner-side reason.
454
+ env.XDG_DATA_HOME = path.join(invocationDir, "xdg-data");
455
+ env.XDG_CONFIG_HOME = path.join(invocationDir, "xdg-config");
456
+ env.XDG_CACHE_HOME = path.join(invocationDir, "xdg-cache");
457
+ env.XDG_STATE_HOME = path.join(invocationDir, "xdg-state");
458
+ return env;
459
+ }
460
+
461
+ /**
462
+ * Run one gate bounded: detached (its own process group), fenced HOME/XDG in a FRESH
463
+ * per-invocation directory (hardening #1; TMPDIR is inherited — see fencedEnv),
464
+ * SIGKILL to the whole group on timeout. The invocation dir lives OUTSIDE the
465
+ * snapshot repo tree and is removed by the caller after the run.
466
+ */
467
+ export function runGateBounded(opts: {
468
+ cwd: string;
469
+ argv: string[];
470
+ timeoutSeconds: number;
471
+ invocationDir: string;
472
+ }): Promise<GateRunResult> {
473
+ const started = Date.now();
474
+ return new Promise((resolve, reject) => {
475
+ const child = spawn(opts.argv[0], opts.argv.slice(1), {
476
+ cwd: opts.cwd,
477
+ env: fencedEnv(opts.invocationDir),
478
+ detached: true,
479
+ stdio: ["ignore", "pipe", "pipe"],
480
+ });
481
+ let output = "";
482
+ let timedOut = false;
483
+ const append = (chunk: Buffer): void => {
484
+ if (output.length < OUTPUT_CAP) output += chunk.toString("utf8");
485
+ };
486
+ child.stdout.on("data", append);
487
+ child.stderr.on("data", append);
488
+ const timer = setTimeout(() => {
489
+ timedOut = true;
490
+ try {
491
+ process.kill(-child.pid!, "SIGKILL");
492
+ } catch {
493
+ // group already gone
494
+ }
495
+ }, opts.timeoutSeconds * 1000);
496
+ child.on("error", (err) => {
497
+ clearTimeout(timer);
498
+ reject(err);
499
+ });
500
+ child.on("close", (code) => {
501
+ clearTimeout(timer);
502
+ resolve({ exitCode: code, timedOut, output, seconds: (Date.now() - started) / 1000 });
503
+ });
504
+ });
505
+ }
506
+
507
+ // ── orchestration: CONTROL → mutants → RESTORE → CONTROL per gate group ─────
508
+
509
+ export interface MutantResult {
510
+ claim: string;
511
+ verdict: MutantVerdict;
512
+ seconds: number;
513
+ subjectSha256: string;
514
+ detail: string;
515
+ }
516
+
517
+ export interface GroupResult {
518
+ gate: string[];
519
+ control: "ok" | "pre-red" | "post-red" | "skipped";
520
+ mutants: MutantResult[];
521
+ }
522
+
523
+ export interface QualifyReport {
524
+ groups: GroupResult[];
525
+ treeClean: boolean;
526
+ porcelainClean: boolean;
527
+ }
528
+
529
+ export function reportPassed(report: QualifyReport): boolean {
530
+ return (
531
+ report.treeClean &&
532
+ report.porcelainClean &&
533
+ report.groups.every((g) => g.control === "ok" && g.mutants.every((m) => m.verdict === "KILLED"))
534
+ );
535
+ }
536
+
537
+ function sha256File(file: string): string {
538
+ return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
539
+ }
540
+
541
+ function countOccurrences(haystack: string, needle: string): number {
542
+ let count = 0;
543
+ let at = haystack.indexOf(needle);
544
+ while (at !== -1) {
545
+ count++;
546
+ at = haystack.indexOf(needle, at + needle.length);
547
+ }
548
+ return count;
549
+ }
550
+
551
+ /**
552
+ * Run every mutant grouped by its gate command, with the control-mutant-restore-
553
+ * control state machine. A red CONTROL-PRE aborts the whole group (every mutant
554
+ * reports CONTROL-RED — a baseline-red gate can only produce fake kills); a failed
555
+ * restore or a red CONTROL-POST marks the group IMPURE. The caller compares tree
556
+ * manifests around the whole run.
557
+ */
558
+ export async function qualifyMutants(
559
+ snapshot: Snapshot,
560
+ mutants: MutantSpec[],
561
+ log: (line: string) => void,
562
+ ): Promise<QualifyReport> {
563
+ const preTree = computeTreeManifest(snapshot.repoDir);
564
+ const groups = new Map<string, MutantSpec[]>();
565
+ for (const m of mutants) {
566
+ const key = JSON.stringify(m.gate);
567
+ const list = groups.get(key) ?? [];
568
+ list.push(m);
569
+ groups.set(key, list);
570
+ }
571
+
572
+ const results: GroupResult[] = [];
573
+ let invocationSeq = 0;
574
+ const runOnce = async (argv: string[], timeoutSeconds: number): Promise<GateRunResult> => {
575
+ const invocationDir = path.join(snapshot.baseDir, `invocation-${invocationSeq++}`);
576
+ fs.mkdirSync(invocationDir, { mode: 0o700 });
577
+ try {
578
+ return await runGateBounded({ cwd: snapshot.repoDir, argv, timeoutSeconds, invocationDir });
579
+ } finally {
580
+ fs.rmSync(invocationDir, { recursive: true, force: true });
581
+ }
582
+ };
583
+
584
+ for (const [key, groupMutants] of groups) {
585
+ const gate = JSON.parse(key) as string[];
586
+ const groupTimeout = Math.max(...groupMutants.map((m) => m.timeoutSeconds));
587
+ const group: GroupResult = { gate, control: "skipped", mutants: [] };
588
+ results.push(group);
589
+
590
+ const pre = await runOnce(gate, groupTimeout);
591
+ const controlPreOk = !pre.timedOut && pre.exitCode === 0;
592
+ log(
593
+ ` control-pre ${gate.join(" ")}: ${controlPreOk ? "green" : `RED (exit=${pre.exitCode} timedOut=${pre.timedOut})`} in ${pre.seconds.toFixed(1)}s`,
594
+ );
595
+ if (!controlPreOk) {
596
+ group.control = "pre-red";
597
+ for (const m of groupMutants) {
598
+ group.mutants.push({
599
+ claim: m.claim,
600
+ verdict: classifyMutantRun({
601
+ controlPreOk: false,
602
+ matchCount: 1,
603
+ timedOut: false,
604
+ exitCode: null,
605
+ signatureOnFailureLine: false,
606
+ restoredOk: true,
607
+ }),
608
+ seconds: 0,
609
+ subjectSha256: "",
610
+ detail: "control-pre red — no kill can be claimed against a baseline-red gate",
611
+ });
612
+ }
613
+ continue;
614
+ }
615
+
616
+ let groupImpure = false;
617
+ for (const m of groupMutants) {
618
+ const subjectAbs = path.join(snapshot.repoDir, m.subject);
619
+ // P0-1 runtime guard, IN ADDITION to the manifest-time origin check: the
620
+ // snapshot preserves tracked symlinks, and readFileSync/writeFileSync FOLLOW
621
+ // them — a symlink subject would mutate whatever it points at, potentially
622
+ // OUTSIDE the snapshot. Refuse before reading a byte; also pin realpath
623
+ // containment so no path component smuggles the write out. This path is
624
+ // reachable without validateManifestSet (the self-test injects specs
625
+ // directly), so the guard must live here, not only in validation.
626
+ const subjectStat = fs.lstatSync(subjectAbs);
627
+ if (!subjectStat.isFile() || subjectStat.isSymbolicLink()) {
628
+ throw new Error(
629
+ `subject ${m.subject} is not a regular non-symlink file in the snapshot — refusing to mutate (symlink-escape guard)`,
630
+ );
631
+ }
632
+ const repoReal = fs.realpathSync(snapshot.repoDir) + path.sep;
633
+ if (!fs.realpathSync(subjectAbs).startsWith(repoReal)) {
634
+ throw new Error(`subject ${m.subject} resolves outside the snapshot repo — refusing to mutate`);
635
+ }
636
+ const originalBytes = fs.readFileSync(subjectAbs);
637
+ const originalSha = createHash("sha256").update(originalBytes).digest("hex");
638
+ const source = originalBytes.toString("utf8");
639
+ const find = m.find.join("\n");
640
+ const matchCount = countOccurrences(source, find);
641
+ if (matchCount !== 1) {
642
+ const verdict = classifyMutantRun({
643
+ controlPreOk: true,
644
+ matchCount,
645
+ timedOut: false,
646
+ exitCode: null,
647
+ signatureOnFailureLine: false,
648
+ restoredOk: true,
649
+ });
650
+ group.mutants.push({
651
+ claim: m.claim,
652
+ verdict,
653
+ seconds: 0,
654
+ subjectSha256: originalSha,
655
+ detail: `find matched ${matchCount}× in ${m.subject} — nothing was written`,
656
+ });
657
+ log(` claim ${m.claim}: ${verdict} (find matched ${matchCount}×)`);
658
+ continue;
659
+ }
660
+
661
+ // A function replacement keeps the declared bytes literal: with a string
662
+ // second argument, String.replace interprets `$&`/`$'`/"$`"/`$$` as
663
+ // substitution patterns, so a replacement carrying one (bash `$$`, ANSI-C
664
+ // `$'…'`) would silently plant DIFFERENT bytes than the manifest declares.
665
+ fs.writeFileSync(
666
+ subjectAbs,
667
+ source.replace(find, () => m.replace.join("\n")),
668
+ );
669
+ let run: GateRunResult;
670
+ try {
671
+ run = await runOnce(m.gate, m.timeoutSeconds);
672
+ } finally {
673
+ fs.writeFileSync(subjectAbs, originalBytes);
674
+ }
675
+ const restoredOk = sha256File(subjectAbs) === originalSha;
676
+ const verdict = classifyMutantRun({
677
+ controlPreOk: true,
678
+ matchCount: 1,
679
+ timedOut: run.timedOut,
680
+ exitCode: run.exitCode,
681
+ signatureOnFailureLine: signatureOnFailureLine(run.output, m.signature),
682
+ restoredOk,
683
+ });
684
+ if (!restoredOk) groupImpure = true;
685
+ group.mutants.push({
686
+ claim: m.claim,
687
+ verdict,
688
+ seconds: run.seconds,
689
+ subjectSha256: originalSha,
690
+ detail: `exit=${run.exitCode} timedOut=${run.timedOut} signature=${m.signature}`,
691
+ });
692
+ log(
693
+ ` claim ${m.claim}: ${verdict} in ${run.seconds.toFixed(1)}s (subject ${m.subject} sha256=${originalSha.slice(0, 12)}…)`,
694
+ );
695
+ if (!restoredOk) break; // contaminated snapshot — stop the group
696
+ }
697
+
698
+ if (groupImpure) {
699
+ group.control = "post-red";
700
+ continue;
701
+ }
702
+ const post = await runOnce(gate, groupTimeout);
703
+ const controlPostOk = !post.timedOut && post.exitCode === 0;
704
+ group.control = controlPostOk ? "ok" : "post-red";
705
+ log(
706
+ ` control-post ${gate.join(" ")}: ${controlPostOk ? "green" : `RED (exit=${post.exitCode} timedOut=${post.timedOut}) — restore contamination or gate state leak`} in ${post.seconds.toFixed(1)}s`,
707
+ );
708
+ }
709
+
710
+ const postTree = computeTreeManifest(snapshot.repoDir);
711
+ const porcelain = gitIn(snapshot.repoDir, ["status", "--porcelain"]).trim();
712
+ return { groups: results, treeClean: postTree === preTree, porcelainClean: porcelain === "" };
713
+ }
714
+
715
+ // ── origin repo helpers ─────────────────────────────────────────────────────
716
+
717
+ export function originHead(originDir: string): string {
718
+ return gitIn(originDir, ["rev-parse", "HEAD"]).trim();
719
+ }
720
+
721
+ /**
722
+ * Content hash of the origin WORK SURFACE — every tracked or untracked-non-ignored
723
+ * path with its type, mode, and content sha (symlinks by target). `git status
724
+ * --porcelain` alone cannot see a byte change inside a file that was ALREADY
725
+ * modified (same ` M` row, same porcelain text), so a tripwire built on it would
726
+ * miss exactly the write it exists to catch (P1-5). This one does not.
727
+ */
728
+ export function originWorkSurfaceSha(originDir: string): string {
729
+ const listOut = gitIn(originDir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
730
+ const files = [...new Set(listOut.split("\0").filter((f) => f.length > 0))].sort();
731
+ const lines: string[] = [];
732
+ for (const rel of files) {
733
+ const abs = path.join(originDir, rel);
734
+ let st: fs.Stats;
735
+ try {
736
+ st = fs.lstatSync(abs);
737
+ } catch {
738
+ lines.push(`${rel}\0missing`);
739
+ continue;
740
+ }
741
+ if (st.isSymbolicLink()) {
742
+ lines.push(`${rel}\0link\0${fs.readlinkSync(abs)}`);
743
+ } else if (st.isFile()) {
744
+ const sha = createHash("sha256").update(fs.readFileSync(abs)).digest("hex");
745
+ lines.push(`${rel}\0${(st.mode & 0o777).toString(8)}\0${sha}`);
746
+ } else {
747
+ lines.push(`${rel}\0special`);
748
+ }
749
+ }
750
+ return createHash("sha256").update(lines.join("\n")).digest("hex");
751
+ }
752
+
753
+ export function makeOriginChecks(originDir: string): OriginChecks {
754
+ return {
755
+ subjectTracked: (subject: string): boolean => {
756
+ const r = spawnSync("git", ["-C", originDir, "ls-files", "--error-unmatch", "--", subject], {
757
+ stdio: "ignore",
758
+ env: { ...process.env, ...SNAPSHOT_GIT_ENV },
759
+ });
760
+ return r.status === 0;
761
+ },
762
+ regularContainedFile: (file: string): boolean => {
763
+ try {
764
+ const abs = path.join(originDir, file);
765
+ const st = fs.lstatSync(abs);
766
+ if (!st.isFile() || st.isSymbolicLink()) return false;
767
+ return fs.realpathSync(abs).startsWith(fs.realpathSync(originDir) + path.sep);
768
+ } catch {
769
+ return false;
770
+ }
771
+ },
772
+ onWorkSurface: (file: string): boolean => {
773
+ const r = spawnSync(
774
+ "git",
775
+ ["-C", originDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", file],
776
+ { encoding: "utf8", env: { ...process.env, ...SNAPSHOT_GIT_ENV } },
777
+ );
778
+ return (
779
+ r.status === 0 &&
780
+ r.stdout
781
+ .split("\0")
782
+ .filter((f) => f.length > 0)
783
+ .includes(file)
784
+ );
785
+ },
786
+ tokenCount: (file: string, token: string): number => {
787
+ try {
788
+ return countOccurrences(fs.readFileSync(path.join(originDir, file), "utf8"), token);
789
+ } catch {
790
+ return 0;
791
+ }
792
+ },
793
+ };
794
+ }