@junghanacs/entwurf 0.25.1 → 0.30.1

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 (86) hide show
  1. package/AGENTS.md +1 -1
  2. package/BASELINE.md +1 -1
  3. package/CHANGELOG.md +190 -0
  4. package/DELIVERY.md +1 -1
  5. package/README.md +183 -674
  6. package/VERIFY.md +21 -17
  7. package/demo/README.md +1 -1
  8. package/docs/acp-backend-rail.md +115 -18
  9. package/docs/external-mcp-host.md +1 -1
  10. package/docs/setup-clean-host.md +346 -10
  11. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +27 -4
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +55 -1
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +1 -1
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +2 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +2 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +2 -2
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +3 -5
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +3 -3
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +13 -6
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +1 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +3 -3
  22. package/mcp/entwurf-bridge/src/index.ts +30 -3
  23. package/package.json +13 -13
  24. package/pi-extensions/entwurf-control.ts +50 -637
  25. package/pi-extensions/lib/acp/backend-adapter.ts +81 -1
  26. package/pi-extensions/lib/acp/backend.ts +41 -5
  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 +177 -53
  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 +250 -5
  44. package/scripts/check-herdr-plugin-build.ts +159 -57
  45. package/scripts/check-install-surface.ts +3 -2
  46. package/scripts/check-mux-resume-call.ts +24 -35
  47. package/scripts/check-pi-bridge-sender.ts +130 -0
  48. package/scripts/check-pi-mcp-bridge.ts +251 -0
  49. package/scripts/fixtures/herdr-supply.json +8 -8
  50. package/scripts/lib/codex-fresh-live-protocol.ts +17 -5
  51. package/scripts/lib/codex-fresh-source-receipts.ts +21 -2
  52. package/scripts/lib/mutation-qualify.ts +269 -44
  53. package/scripts/lib/pi-mcp-bridge-probe.mjs +170 -0
  54. package/scripts/meta-bridge-doctor.sh +16 -5
  55. package/scripts/mutants/acp-foreground-only.json +17 -0
  56. package/scripts/mutants/acp-prompt-lifecycle.json +2 -2
  57. package/scripts/mutants/acp-vendor-rejection.json +62 -0
  58. package/scripts/mutants/codex-native.json +11 -11
  59. package/scripts/mutants/control-send-receipt.json +6 -4
  60. package/scripts/mutants/copilot-birth.json +2 -1
  61. package/scripts/mutants/fresh-call-dispatch.json +3 -14
  62. package/scripts/mutants/gate-qualification.json +59 -0
  63. package/scripts/mutants/herdr-plugin-build.json +11 -0
  64. package/scripts/mutants/herdr-supply.json +3 -3
  65. package/scripts/mutants/mux-fresh-call.json +43 -45
  66. package/scripts/mutants/mux-resume-call.json +11 -35
  67. package/scripts/mutants/pack-install.json +15 -4
  68. package/scripts/mutants/pi-bridge-sender.json +54 -0
  69. package/scripts/mutants/pi-floor.json +6 -2
  70. package/scripts/mutants/pi-mcp-bridge.json +36 -0
  71. package/scripts/mutants/pi-mcp-register.json +51 -0
  72. package/scripts/mutants/v2-surface.json +6 -6
  73. package/scripts/resident-rpc-drive.ts +5 -1
  74. package/scripts/smoke-acp-bundled-mcp-live.ts +3 -1
  75. package/scripts/smoke-acp-cortex-live.ts +3 -1
  76. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  77. package/scripts/smoke-acp-session-reuse-live.ts +29 -12
  78. package/scripts/smoke-acp-socket-citizen-live.ts +3 -1
  79. package/scripts/smoke-acp-v2-send-live.ts +3 -1
  80. package/scripts/smoke-codex-fresh-live.ts +20 -1
  81. package/scripts/smoke-entwurf-chain-live.ts +6 -2
  82. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -1
  83. package/scripts/smoke-herdr-fresh-call-live.ts +21 -7
  84. package/scripts/smoke-herdr-raw-install-live.sh +1 -1
  85. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  86. package/scripts/smoke-resident-garden-guard.sh +3 -1
@@ -19,6 +19,14 @@
19
19
  * mutants (a gate already red at baseline can produce only fake KILLEDs).
20
20
  * - Bounded: each gate run has a hard timeout and its process GROUP is killed.
21
21
  * - Evidence is claim IDs + killed mutant IDs, never assertion counts.
22
+ * - The snapshot is SOURCE-ONLY. An ignored build output a gate consumes (#125: the
23
+ * compiled entwurf-bridge a pi citizen registers) enters only through a mutant's
24
+ * `build` declaration, and only for that gate's group: the snapshot builds it from its
25
+ * OWN bytes before the control, again after every mutation (so the gate judges the
26
+ * output of the source it is handed), and again from the restored bytes before the
27
+ * post-control, which must reproduce the baseline output exactly. A build red is never
28
+ * a kill, a gate that writes into the output voids its run, and the output is removed
29
+ * when the group ends — no other group ever sees it, and no origin artifact is copied.
22
30
  */
23
31
 
24
32
  import { spawn, spawnSync } from "node:child_process";
@@ -161,6 +169,20 @@ export interface MutantSpec {
161
169
  signature: string;
162
170
  /** Repo-relative gate source file that owns the claim token. */
163
171
  signatureSource: string;
172
+ /** Optional: an ignored build output this mutant's gate consumes (see the module header). */
173
+ build?: MutantBuild;
174
+ }
175
+
176
+ /**
177
+ * A build whose OUTPUT a gate group consumes. Never a subject: the output is ignored, so
178
+ * the snapshot does not carry it, and a gate requiring it would be CONTROL-RED in every
179
+ * run. Every mutant of one gate declares the same build or none.
180
+ */
181
+ export interface MutantBuild {
182
+ /** Build argv (no shell string), run with cwd = snapshot repo root, fenced and bounded like a gate. */
183
+ argv: string[];
184
+ /** Repo-relative directory the build writes; absent from the work surface, removed after the group. */
185
+ output: string;
164
186
  }
165
187
 
166
188
  export interface MutantManifest {
@@ -182,12 +204,14 @@ const MUTANT_KEYS = [
182
204
  "signature",
183
205
  "signatureSource",
184
206
  ];
207
+ const OPTIONAL_MUTANT_KEYS = ["build"];
208
+ const BUILD_KEYS = ["argv", "output"];
185
209
 
186
210
  export class ManifestError extends Error {}
187
211
 
188
- function requireExactKeys(obj: Record<string, unknown>, keys: string[], where: string): void {
212
+ function requireExactKeys(obj: Record<string, unknown>, keys: string[], where: string, optional: string[] = []): void {
189
213
  for (const k of Object.keys(obj)) {
190
- if (!keys.includes(k)) throw new ManifestError(`${where}: unknown key \`${k}\``);
214
+ if (!keys.includes(k) && !optional.includes(k)) throw new ManifestError(`${where}: unknown key \`${k}\``);
191
215
  }
192
216
  for (const k of keys) {
193
217
  if (!(k in obj)) throw new ManifestError(`${where}: missing key \`${k}\``);
@@ -218,7 +242,7 @@ export function validateManifest(raw: unknown, name: string): MutantManifest {
218
242
  const where = `${name} mutants[${i}]`;
219
243
  if (typeof m !== "object" || m === null) throw new ManifestError(`${where}: must be an object`);
220
244
  const rec = m as Record<string, unknown>;
221
- requireExactKeys(rec, MUTANT_KEYS, where);
245
+ requireExactKeys(rec, MUTANT_KEYS, where, OPTIONAL_MUTANT_KEYS);
222
246
  if (typeof rec.claim !== "string" || !CLAIM_RE.test(rec.claim)) {
223
247
  throw new ManifestError(`${where}: claim must match ${CLAIM_RE}`);
224
248
  }
@@ -251,6 +275,7 @@ export function validateManifest(raw: unknown, name: string): MutantManifest {
251
275
  if (typeof sigSrc !== "string" || path.isAbsolute(sigSrc) || sigSrc.split(/[\\/]/).includes("..")) {
252
276
  throw new ManifestError(`${where}: signatureSource must be a repo-relative path`);
253
277
  }
278
+ const build = "build" in rec ? validateBuild(rec.build, `${where}.build`) : undefined;
254
279
  mutants.push({
255
280
  claim: rec.claim,
256
281
  title: rec.title,
@@ -261,11 +286,54 @@ export function validateManifest(raw: unknown, name: string): MutantManifest {
261
286
  timeoutSeconds: rec.timeoutSeconds,
262
287
  signature: expectedSig,
263
288
  signatureSource: sigSrc,
289
+ ...(build ? { build } : {}),
264
290
  });
265
291
  }
266
292
  return { schemaVersion: 1, lane: doc.lane, mutants };
267
293
  }
268
294
 
295
+ function validateBuild(raw: unknown, where: string): MutantBuild {
296
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
297
+ throw new ManifestError(`${where}: must be an object`);
298
+ }
299
+ const rec = raw as Record<string, unknown>;
300
+ requireExactKeys(rec, BUILD_KEYS, where);
301
+ const argv = requireLines(rec.argv, `${where}.argv`);
302
+ const output = rec.output;
303
+ if (
304
+ typeof output !== "string" ||
305
+ path.isAbsolute(output) ||
306
+ output.split("/").some((seg) => seg === "" || seg === "." || seg === "..") ||
307
+ output.includes("\\")
308
+ ) {
309
+ throw new ManifestError(`${where}: output must be a normalized repo-relative directory (got ${String(output)})`);
310
+ }
311
+ const top = output.split("/")[0];
312
+ if (top === "node_modules" || top === ".git") {
313
+ throw new ManifestError(`${where}: output may not live under ${top}`);
314
+ }
315
+ return { argv, output };
316
+ }
317
+
318
+ /**
319
+ * The one build a gate group declares, or none. A group is keyed by its exact gate argv;
320
+ * two mutants of it declaring different builds (or one declaring none) would hand the same
321
+ * gate different inputs depending on run order, so the set is refused — at manifest time and
322
+ * again at run time, because the self-test injects specs directly.
323
+ */
324
+ export function groupBuild(specs: MutantSpec[]): MutantBuild | undefined {
325
+ const first = JSON.stringify(specs[0]?.build ?? null);
326
+ for (const m of specs) {
327
+ const mine = JSON.stringify(m.build ?? null);
328
+ if (mine !== first) {
329
+ throw new ManifestError(
330
+ `gate ${JSON.stringify(m.gate)} mixes build declarations (${first} vs ${mine} at ${m.claim}) — every mutant of one gate declares the same build or none`,
331
+ );
332
+ }
333
+ }
334
+ return specs[0]?.build;
335
+ }
336
+
269
337
  export interface OriginChecks {
270
338
  /** claim → true when the subject is tracked in the origin index. */
271
339
  subjectTracked: (subject: string) => boolean;
@@ -323,6 +391,12 @@ export function validateManifestSet(manifests: MutantManifest[], origin: OriginC
323
391
  all.push(m);
324
392
  }
325
393
  }
394
+ const byGate = new Map<string, MutantSpec[]>();
395
+ for (const m of all) {
396
+ const key = JSON.stringify(m.gate);
397
+ byGate.set(key, [...(byGate.get(key) ?? []), m]);
398
+ }
399
+ for (const specs of byGate.values()) groupBuild(specs);
326
400
  return all;
327
401
  }
328
402
 
@@ -461,21 +535,26 @@ export function createRepoSnapshot(originDir: string, tmpRoot: string = os.tmpdi
461
535
  * misses ignored-path writes; this does not (hardening #4).
462
536
  */
463
537
  export function computeTreeManifest(repoDir: string): string {
538
+ return digestTree(repoDir, [".git", "node_modules"]);
539
+ }
540
+
541
+ /** The same content manifest over any directory, skipping the named top-level entries. */
542
+ function digestTree(root: string, skipAtRoot: readonly string[]): string {
464
543
  const lines: string[] = [];
465
544
  const walk = (rel: string): void => {
466
- const abs = path.join(repoDir, rel);
545
+ const abs = path.join(root, rel);
467
546
  for (const name of fs.readdirSync(abs).sort()) {
468
- if (rel === "" && (name === ".git" || name === "node_modules")) continue;
547
+ if (rel === "" && skipAtRoot.includes(name)) continue;
469
548
  const childRel = rel === "" ? name : `${rel}/${name}`;
470
- const st = fs.lstatSync(path.join(repoDir, childRel));
549
+ const st = fs.lstatSync(path.join(root, childRel));
471
550
  if (st.isSymbolicLink()) {
472
- lines.push(`${childRel}\0link\0${fs.readlinkSync(path.join(repoDir, childRel))}`);
551
+ lines.push(`${childRel}\0link\0${fs.readlinkSync(path.join(root, childRel))}`);
473
552
  } else if (st.isDirectory()) {
474
553
  lines.push(`${childRel}\0dir`);
475
554
  walk(childRel);
476
555
  } else if (st.isFile()) {
477
556
  const sha = createHash("sha256")
478
- .update(fs.readFileSync(path.join(repoDir, childRel)))
557
+ .update(fs.readFileSync(path.join(root, childRel)))
479
558
  .digest("hex");
480
559
  lines.push(`${childRel}\0${(st.mode & 0o777).toString(8)}\0${sha}`);
481
560
  } else {
@@ -487,6 +566,57 @@ export function computeTreeManifest(repoDir: string): string {
487
566
  return createHash("sha256").update(lines.join("\n")).digest("hex");
488
567
  }
489
568
 
569
+ // ── declared build output (source-only snapshot, #125) ──────────────────────
570
+
571
+ /**
572
+ * Absolute path of a group's declared build output, refused unless it is ABSENT from the
573
+ * snapshot: the snapshot is the work surface, so a present output would be source bytes the
574
+ * build (and the end-of-group removal) would overwrite. Every existing ancestor must be a
575
+ * real directory inside the snapshot, so neither the build nor the removal can follow a link out,
576
+ * and the parent must already exist: a build that created source-tree directories would leave
577
+ * them behind after the removal.
578
+ */
579
+ function declaredBuildOutput(repoDir: string, build: MutantBuild): string {
580
+ const abs = path.join(repoDir, build.output);
581
+ const rel = path.relative(repoDir, abs);
582
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
583
+ throw new Error(`declared build output ${build.output} is not inside the snapshot — refusing`);
584
+ }
585
+ if (fs.existsSync(abs) || isLink(abs)) {
586
+ throw new Error(
587
+ `declared build output ${build.output} already exists in the snapshot — it is on the work surface, so building or removing it would touch source bytes; refusing`,
588
+ );
589
+ }
590
+ const repoReal = fs.realpathSync(repoDir) + path.sep;
591
+ for (let dir = path.dirname(abs); dir !== repoDir; dir = path.dirname(dir)) {
592
+ if (isLink(dir) || (fs.existsSync(dir) && !(fs.realpathSync(dir) + path.sep).startsWith(repoReal))) {
593
+ throw new Error(`declared build output ${build.output} has an ancestor that leaves the snapshot — refusing`);
594
+ }
595
+ }
596
+ if (!fs.existsSync(path.dirname(abs)) || !fs.lstatSync(path.dirname(abs)).isDirectory()) {
597
+ throw new Error(`declared build output ${build.output} has no parent directory in the snapshot — refusing`);
598
+ }
599
+ return abs;
600
+ }
601
+
602
+ function isLink(p: string): boolean {
603
+ try {
604
+ return fs.lstatSync(p).isSymbolicLink();
605
+ } catch {
606
+ return false;
607
+ }
608
+ }
609
+
610
+ /** Content digest of the build output, or null when it is not a real directory (a build that wrote nothing there). */
611
+ function buildOutputDigest(abs: string): string | null {
612
+ try {
613
+ if (!fs.lstatSync(abs).isDirectory()) return null;
614
+ } catch {
615
+ return null;
616
+ }
617
+ return digestTree(abs, []);
618
+ }
619
+
490
620
  // ── bounded gate execution ──────────────────────────────────────────────────
491
621
 
492
622
  export interface GateRunResult {
@@ -670,6 +800,12 @@ export async function qualifyMutants(
670
800
  list.push(m);
671
801
  groups.set(key, list);
672
802
  }
803
+ // Every group's declaration is judged before the first gate runs, so a bad one late in the
804
+ // set refuses the run up front instead of discarding an hour of earlier groups.
805
+ for (const specs of groups.values()) {
806
+ const build = groupBuild(specs);
807
+ if (build) declaredBuildOutput(snapshot.repoDir, build);
808
+ }
673
809
 
674
810
  const results: GroupResult[] = [];
675
811
  let invocationSeq = 0;
@@ -683,37 +819,79 @@ export async function qualifyMutants(
683
819
  }
684
820
  };
685
821
 
686
- for (const [key, groupMutants] of groups) {
687
- const gate = JSON.parse(key) as string[];
688
- const groupTimeout = Math.max(...groupMutants.map((m) => m.timeoutSeconds));
689
- const group: GroupResult = { gate, control: "skipped", mutants: [] };
690
- results.push(group);
822
+ const markControlRed = (group: GroupResult, specs: MutantSpec[], detail: string): void => {
823
+ group.control = "pre-red";
824
+ for (const m of specs) {
825
+ group.mutants.push({
826
+ claim: m.claim,
827
+ verdict: classifyMutantRun({
828
+ controlPreOk: false,
829
+ matchCount: 1,
830
+ timedOut: false,
831
+ exitCode: null,
832
+ signatureOnFailureLine: false,
833
+ restoredOk: true,
834
+ }),
835
+ seconds: 0,
836
+ subjectSha256: "",
837
+ detail,
838
+ });
839
+ }
840
+ };
841
+
842
+ // The declared output, made from the snapshot's CURRENT bytes. Removed first, so the digest
843
+ // sees this build's output alone, whatever the build script does about stale files. Green only
844
+ // when the build exited 0 and left a real directory at the declared path; `digest` is null otherwise.
845
+ const buildOutput = async (
846
+ build: MutantBuild,
847
+ outputAbs: string,
848
+ timeoutSeconds: number,
849
+ ): Promise<{ run: GateRunResult; digest: string | null }> => {
850
+ fs.rmSync(outputAbs, { recursive: true, force: true });
851
+ const run = await runOnce(build.argv, timeoutSeconds);
852
+ return { run, digest: !run.timedOut && run.exitCode === 0 ? buildOutputDigest(outputAbs) : null };
853
+ };
854
+
855
+ const runGroup = async (
856
+ gate: string[],
857
+ groupMutants: MutantSpec[],
858
+ groupTimeout: number,
859
+ group: GroupResult,
860
+ build: MutantBuild | undefined,
861
+ outputAbs: string | null,
862
+ ): Promise<void> => {
863
+ let baseline: string | null = null;
864
+ if (build && outputAbs) {
865
+ const b = await buildOutput(build, outputAbs, groupTimeout);
866
+ log(
867
+ ` build ${build.argv.join(" ")} → ${build.output}: ${b.digest ? "built" : `RED (exit=${b.run.exitCode} timedOut=${b.run.timedOut})`} in ${b.run.seconds.toFixed(1)}s`,
868
+ );
869
+ if (!b.digest) {
870
+ logControlOutput(b.run.output, log);
871
+ markControlRed(
872
+ group,
873
+ groupMutants,
874
+ "declared build red at baseline — the gate's input could not be made from the snapshot's own bytes",
875
+ );
876
+ return;
877
+ }
878
+ baseline = b.digest;
879
+ }
880
+ // A gate that writes into its declared build output would judge the next run against
881
+ // bytes no build produced: every gate run below is bracketed by an output digest.
882
+ const wroteOutput = (expected: string | null): boolean =>
883
+ outputAbs !== null && buildOutputDigest(outputAbs) !== expected;
691
884
 
692
885
  const pre = await runOnce(gate, groupTimeout);
693
- const controlPreOk = !pre.timedOut && pre.exitCode === 0;
886
+ const preWrote = wroteOutput(baseline);
887
+ const controlPreOk = !pre.timedOut && pre.exitCode === 0 && !preWrote;
694
888
  log(
695
- ` control-pre ${gate.join(" ")}: ${controlPreOk ? "green" : `RED (exit=${pre.exitCode} timedOut=${pre.timedOut})`} in ${pre.seconds.toFixed(1)}s`,
889
+ ` control-pre ${gate.join(" ")}: ${controlPreOk ? "green" : preWrote ? "RED (the gate wrote into its declared build output)" : `RED (exit=${pre.exitCode} timedOut=${pre.timedOut})`} in ${pre.seconds.toFixed(1)}s`,
696
890
  );
697
891
  if (!controlPreOk) {
698
892
  logControlOutput(pre.output, log);
699
- group.control = "pre-red";
700
- for (const m of groupMutants) {
701
- group.mutants.push({
702
- claim: m.claim,
703
- verdict: classifyMutantRun({
704
- controlPreOk: false,
705
- matchCount: 1,
706
- timedOut: false,
707
- exitCode: null,
708
- signatureOnFailureLine: false,
709
- restoredOk: true,
710
- }),
711
- seconds: 0,
712
- subjectSha256: "",
713
- detail: "control-pre red — no kill can be claimed against a baseline-red gate",
714
- });
715
- }
716
- continue;
893
+ markControlRed(group, groupMutants, "control-pre red — no kill can be claimed against a baseline-red gate");
894
+ return;
717
895
  }
718
896
 
719
897
  let groupImpure = false;
@@ -770,18 +948,34 @@ export async function qualifyMutants(
770
948
  source.replace(find, () => m.replace.join("\n")),
771
949
  );
772
950
  let run: GateRunResult;
951
+ let buildRed = false;
952
+ let outputWritten = false;
773
953
  try {
774
- run = await runOnce(m.gate, m.timeoutSeconds);
954
+ if (build && outputAbs) {
955
+ // Rebuilt from the MUTATED bytes: the gate judges the output of the source it is handed.
956
+ const b = await buildOutput(build, outputAbs, m.timeoutSeconds);
957
+ if (b.digest === null) {
958
+ buildRed = true;
959
+ run = b.run;
960
+ } else {
961
+ run = await runOnce(m.gate, m.timeoutSeconds);
962
+ outputWritten = wroteOutput(b.digest);
963
+ }
964
+ } else {
965
+ run = await runOnce(m.gate, m.timeoutSeconds);
966
+ }
775
967
  } finally {
776
968
  fs.writeFileSync(subjectAbs, originalBytes);
777
969
  }
778
- const restoredOk = sha256File(subjectAbs) === originalSha;
970
+ const restoredOk = sha256File(subjectAbs) === originalSha && !outputWritten;
779
971
  const verdict = classifyMutantRun({
780
972
  controlPreOk: true,
781
973
  matchCount: 1,
782
974
  timedOut: run.timedOut,
783
- exitCode: run.exitCode,
784
- signatureOnFailureLine: signatureAttributedToFailure(run.output, m.signature, run.failedTitles),
975
+ // A build that exited 0 without leaving its declared output is a build red too.
976
+ exitCode: buildRed && run.exitCode === 0 ? 1 : run.exitCode,
977
+ // A build red is never a kill, whatever the build printed: the gate did not run.
978
+ signatureOnFailureLine: !buildRed && signatureAttributedToFailure(run.output, m.signature, run.failedTitles),
785
979
  restoredOk,
786
980
  });
787
981
  if (!restoredOk) groupImpure = true;
@@ -790,27 +984,58 @@ export async function qualifyMutants(
790
984
  verdict,
791
985
  seconds: run.seconds,
792
986
  subjectSha256: originalSha,
793
- detail:
794
- `exit=${run.exitCode} timedOut=${run.timedOut} signature=${m.signature}` +
795
- ` attribution=${describeAttribution(run.failedTitles)}`,
987
+ detail: buildRed
988
+ ? `declared build red under the mutation (exit=${run.exitCode} timedOut=${run.timedOut}) — the gate did not run`
989
+ : `exit=${run.exitCode} timedOut=${run.timedOut} signature=${m.signature}` +
990
+ ` attribution=${describeAttribution(run.failedTitles)}` +
991
+ (outputWritten ? " — the gate wrote into its declared build output" : ""),
796
992
  });
797
993
  log(
798
- ` claim ${m.claim}: ${verdict} in ${run.seconds.toFixed(1)}s (subject ${m.subject} sha256=${originalSha.slice(0, 12)}…)`,
994
+ ` claim ${m.claim}: ${verdict} in ${run.seconds.toFixed(1)}s (subject ${m.subject} sha256=${originalSha.slice(0, 12)}…)${buildRed ? " [build red]" : ""}${outputWritten ? " [gate wrote build output]" : ""}`,
799
995
  );
800
996
  if (!restoredOk) break; // contaminated snapshot — stop the group
801
997
  }
802
998
 
803
999
  if (groupImpure) {
804
1000
  group.control = "post-red";
805
- continue;
1001
+ return;
1002
+ }
1003
+ if (build && outputAbs) {
1004
+ // From the RESTORED bytes the build must reproduce its baseline output exactly.
1005
+ const b = await buildOutput(build, outputAbs, groupTimeout);
1006
+ if (b.digest !== baseline) {
1007
+ group.control = "post-red";
1008
+ log(
1009
+ ` control-post build ${build.argv.join(" ")}: RED (exit=${b.run.exitCode} timedOut=${b.run.timedOut}) — the restored bytes did not rebuild the baseline output`,
1010
+ );
1011
+ logControlOutput(b.run.output, log);
1012
+ return;
1013
+ }
806
1014
  }
807
1015
  const post = await runOnce(gate, groupTimeout);
808
- const controlPostOk = !post.timedOut && post.exitCode === 0;
1016
+ const postWrote = wroteOutput(baseline);
1017
+ const controlPostOk = !post.timedOut && post.exitCode === 0 && !postWrote;
809
1018
  group.control = controlPostOk ? "ok" : "post-red";
810
1019
  log(
811
- ` 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`,
1020
+ ` control-post ${gate.join(" ")}: ${controlPostOk ? "green" : postWrote ? "RED (the gate wrote into its declared build output)" : `RED (exit=${post.exitCode} timedOut=${post.timedOut}) — restore contamination or gate state leak`} in ${post.seconds.toFixed(1)}s`,
812
1021
  );
813
1022
  if (!controlPostOk) logControlOutput(post.output, log);
1023
+ };
1024
+
1025
+ for (const [key, groupMutants] of groups) {
1026
+ const gate = JSON.parse(key) as string[];
1027
+ const groupTimeout = Math.max(...groupMutants.map((m) => m.timeoutSeconds));
1028
+ const build = groupBuild(groupMutants);
1029
+ const outputAbs = build ? declaredBuildOutput(snapshot.repoDir, build) : null;
1030
+ const group: GroupResult = { gate, control: "skipped", mutants: [] };
1031
+ results.push(group);
1032
+ try {
1033
+ await runGroup(gate, groupMutants, groupTimeout, group, build, outputAbs);
1034
+ } finally {
1035
+ // The output is this group's alone: removed whatever happened, so no later group — and
1036
+ // not the post-run tree manifest — ever sees it. declaredBuildOutput pinned its ancestors.
1037
+ if (outputAbs) fs.rmSync(outputAbs, { recursive: true, force: true });
1038
+ }
814
1039
  }
815
1040
 
816
1041
  const postTree = computeTreeManifest(snapshot.repoDir);
@@ -0,0 +1,170 @@
1
+ // Child probe for scripts/check-pi-mcp-bridge.ts (#125). Runs in its OWN process with a throwaway
2
+ // HOME / PI_CODING_AGENT_DIR set by the parent — entwurf-control reads HOME at module load and births
3
+ // into PI_CODING_AGENT_DIR, so this must never run inside an operator session.
4
+ //
5
+ // REAL: Pi's AgentSessionRuntime (createAgentSessionRuntime → newSession, the CLI /new contract), its
6
+ // extension loader, the built-in MCP extension registered exactly as the CLI registers it (a
7
+ // `builtin: true` entry → the `builtin:mcp` path), Pi's default stdio transport, entwurf-control's
8
+ // record birth and registration, and the COMPILED entwurf-bridge child. FAUX: only the model's
9
+ // responses (pi-ai `fauxProvider` on the public ModelRuntime seam). No network, no paid call.
10
+ //
11
+ // It REPORTS, it does not judge: every fact goes out as JSON on the last stdout line and the parent
12
+ // decides. The only thing it reads beyond Pi's own answers is /proc (children of THIS process).
13
+ //
14
+ // Usage: node pi-mcp-bridge-probe.mjs <repoRoot> <cwd>
15
+ import { readdirSync, readFileSync, readlinkSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall, getCurrentTools } from "@earendil-works/pi-ai";
18
+ import {
19
+ createAgentSessionFromServices,
20
+ createAgentSessionRuntime,
21
+ createAgentSessionServices,
22
+ createMcpExtension,
23
+ ModelRuntime,
24
+ SessionManager,
25
+ } from "@earendil-works/pi-coding-agent";
26
+
27
+ const [repo, cwd] = process.argv.slice(2);
28
+ const agentDir = process.env.PI_CODING_AGENT_DIR;
29
+ const out = {
30
+ parentPid: process.pid,
31
+ execPath: process.execPath,
32
+ phases: {},
33
+ declaredPerCall: [],
34
+ toolResults: [],
35
+ errors: [],
36
+ };
37
+
38
+ function proc(pid) {
39
+ try {
40
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
41
+ const rest = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
42
+ return { state: rest[0], ppid: Number(rest[1]), starttime: rest[19] };
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ // Every direct child of this process, with the facts the kernel holds for it: argv, the two identity
48
+ // env keys, and its working directory.
49
+ function children() {
50
+ const kids = [];
51
+ for (const d of readdirSync("/proc")) {
52
+ if (!/^\d+$/.test(d)) continue;
53
+ const info = proc(Number(d));
54
+ if (!info || info.ppid !== process.pid) continue;
55
+ const kid = { pid: Number(d), ...info, argv: [], env: {}, cwd: null };
56
+ try {
57
+ kid.argv = readFileSync(`/proc/${d}/cmdline`, "utf8").split("\0").filter(Boolean);
58
+ for (const kv of readFileSync(`/proc/${d}/environ`, "utf8").split("\0")) {
59
+ const i = kv.indexOf("=");
60
+ const k = kv.slice(0, i);
61
+ if (k === "PI_SESSION_ID" || k === "PI_AGENT_ID") kid.env[k] = kv.slice(i + 1);
62
+ }
63
+ kid.cwd = readlinkSync(`/proc/${d}/cwd`);
64
+ } catch (err) {
65
+ kid.readError = String(err instanceof Error ? err.message : err);
66
+ }
67
+ kids.push(kid);
68
+ }
69
+ return kids;
70
+ }
71
+
72
+ const faux = fauxProvider();
73
+ const callSelf = (ctx) => {
74
+ out.declaredPerCall.push(getCurrentTools(ctx.messages).map((t) => t.name));
75
+ return fauxAssistantMessage(
76
+ [fauxToolCall("mcp__entwurf_bridge__entwurf_self", {}, { id: `pmb-self-${out.declaredPerCall.length}` })],
77
+ {
78
+ stopReason: "toolUse",
79
+ },
80
+ );
81
+ };
82
+ faux.setResponses([
83
+ callSelf,
84
+ fauxAssistantMessage([fauxText("s1 done")]),
85
+ callSelf,
86
+ fauxAssistantMessage([fauxText("s2 done")]),
87
+ ]);
88
+ const modelRuntime = await ModelRuntime.create({
89
+ authPath: join(agentDir, "auth.json"),
90
+ modelsPath: join(agentDir, "models.json"),
91
+ });
92
+ modelRuntime.registerNativeProvider(faux.provider);
93
+
94
+ const createRuntime = async ({ cwd: c, agentDir: a, sessionManager, sessionStartEvent }) => {
95
+ const services = await createAgentSessionServices({
96
+ cwd: c,
97
+ agentDir: a,
98
+ modelRuntime,
99
+ extensionFlagValues: new Map([["entwurf-control", true]]),
100
+ resourceLoaderOptions: {
101
+ additionalExtensionPaths: [join(repo, "pi-extensions", "entwurf-control.ts")],
102
+ extensionFactories: [{ name: "mcp", factory: createMcpExtension(), replaceable: true, builtin: true }],
103
+ },
104
+ });
105
+ const created = await createAgentSessionFromServices({
106
+ services,
107
+ sessionManager,
108
+ sessionStartEvent,
109
+ model: faux.getModel(),
110
+ });
111
+ return { ...created, services, diagnostics: services.diagnostics };
112
+ };
113
+
114
+ let runtime;
115
+ try {
116
+ runtime = await createAgentSessionRuntime(createRuntime, {
117
+ cwd,
118
+ agentDir,
119
+ sessionManager: SessionManager.create(cwd),
120
+ });
121
+
122
+ async function turn(tag) {
123
+ const session = runtime.session;
124
+ const unsubscribe = session.subscribe((ev) => {
125
+ if (ev.type === "tool_execution_end") {
126
+ const text = (ev.result?.content ?? [])
127
+ .filter((c) => c.type === "text")
128
+ .map((c) => c.text)
129
+ .join("\n");
130
+ out.toolResults.push({ tag, toolName: ev.toolName, isError: ev.isError === true, text: text.slice(0, 1200) });
131
+ }
132
+ });
133
+ try {
134
+ await session.bindExtensions({
135
+ mode: "rpc",
136
+ onError: (e) => out.errors.push(`${tag} ${e.event ?? ""}: ${e.error}`),
137
+ });
138
+ await session.prompt(`probe ${tag}`);
139
+ } catch (err) {
140
+ out.errors.push(`${tag} prompt: ${err instanceof Error ? err.message : String(err)}`);
141
+ } finally {
142
+ unsubscribe();
143
+ }
144
+ return session.sessionManager.getSessionId();
145
+ }
146
+
147
+ const native1 = await turn("s1");
148
+ const s1Children = children();
149
+ out.phases.s1 = { native: native1, children: s1Children };
150
+ out.phases.newSession = await runtime.newSession();
151
+ out.phases.afterNew = {
152
+ parentAlive: proc(process.pid) !== null,
153
+ p1: s1Children.map((k) => ({ pid: k.pid, starttime: k.starttime, now: proc(k.pid) })),
154
+ children: children(),
155
+ };
156
+ const native2 = await turn("s2");
157
+ out.phases.s2 = { native: native2, children: children() };
158
+ out.fauxCalls = faux.state.callCount;
159
+ out.fauxPending = faux.getPendingResponseCount();
160
+ } catch (err) {
161
+ out.errors.push(`probe: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
162
+ } finally {
163
+ try {
164
+ await runtime?.dispose();
165
+ } catch (err) {
166
+ out.errors.push(`dispose: ${err instanceof Error ? err.message : String(err)}`);
167
+ }
168
+ }
169
+ console.log(JSON.stringify(out));
170
+ process.exit(0);
@@ -1252,12 +1252,23 @@ V2_MCP_SRC="$REPO/mcp/entwurf-bridge/src/index.ts"
1252
1252
  if [ -f "$V2_CONTROL_SRC" ] && grep -q 'ENTWURF_FLAG = "entwurf-control"' "$V2_CONTROL_SRC" && grep -q 'registerFlag(ENTWURF_FLAG' "$V2_CONTROL_SRC"; then
1253
1253
  ok "pi-native --entwurf-control flag registered (ENTWURF_FLAG)"
1254
1254
  else
1255
- bad "pi-native --entwurf-control flag not found in entwurf-control.ts — the v2 pi tool depends on it"
1255
+ bad "pi-native --entwurf-control flag not found in entwurf-control.ts — record birth and the MCP registration depend on it"
1256
1256
  fi
1257
- if [ -f "$V2_CONTROL_SRC" ] && grep -q 'registerEntwurfV2Tool' "$V2_CONTROL_SRC" && grep -q 'name: "entwurf_v2"' "$V2_CONTROL_SRC"; then
1258
- ok "pi-native entwurf_v2 tool registered"
1257
+ # #125: a pi session no longer carries native Entwurf tools — the extension registers the compiled
1258
+ # entwurf-bridge with Pi's built-in MCP after record birth. This is STATIC DETECTION only: it proves
1259
+ # the shipped source makes that registration and the compiled entry it points at shipped. It does NOT
1260
+ # prove a running pi spawned the child or that a same-name mcp.json entry did not replace it (Pi's
1261
+ # `/mcp` reports that) — runtime reachability is the MCP verb check below and `doctor-pi-provider`.
1262
+ V2_BRIDGE_DIST="$REPO/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js"
1263
+ if [ -f "$V2_CONTROL_SRC" ] && grep -q 'pi.registerMcpServer(' "$V2_CONTROL_SRC" && grep -q 'ENTWURF_MCP_SERVER_NAME' "$V2_CONTROL_SRC"; then
1264
+ ok "pi-native: entwurf-control registers the entwurf-bridge MCP server (static detection)"
1259
1265
  else
1260
- bad "pi-native entwurf_v2 tool not found in entwurf-control.ts"
1266
+ bad "pi-native: entwurf-control.ts does not register the entwurf-bridge MCP server — a pi session would have no Entwurf verbs"
1267
+ fi
1268
+ if [ -f "$V2_BRIDGE_DIST" ]; then
1269
+ ok "pi-native: compiled entwurf-bridge entry present (the path the registration points at)"
1270
+ else
1271
+ bad "pi-native: compiled entwurf-bridge entry missing at $V2_BRIDGE_DIST — run: pnpm run build-bridge"
1261
1272
  fi
1262
1273
  if [ -f "$V2_MCP_SRC" ] && grep -q 'server.tool(' "$V2_MCP_SRC" && grep -q '"entwurf_v2"' "$V2_MCP_SRC"; then
1263
1274
  ok "MCP entwurf_v2 verb registered"
@@ -1282,7 +1293,7 @@ case "$REPO" in
1282
1293
  ;;
1283
1294
  *)
1284
1295
  if [ -f "$V2_SURFACE_SRC" ] && (cd "$REPO" && node --experimental-strip-types scripts/check-entwurf-v2-surface.ts >/dev/null 2>&1); then
1285
- ok "check-entwurf-v2-surface gate passes (surface adapter + pi-native/MCP wiring)"
1296
+ ok "check-entwurf-v2-surface gate passes (surface adapter + MCP wiring + pi receiver)"
1286
1297
  else
1287
1298
  bad "check-entwurf-v2-surface gate FAILED or surface adapter missing — run: ./run.sh check-entwurf-v2-surface"
1288
1299
  fi
@@ -0,0 +1,17 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "acp-foreground-only",
4
+ "mutants": [
5
+ {
6
+ "claim": "ACP-CLAUDE-FOREGROUND-ONLY",
7
+ "title": "the claude launch env loses the background-task switch — a backgrounded command outlives an ACP cancel and is orphaned to init when the child exits",
8
+ "subject": "pi-extensions/lib/acp/backend-adapter.ts",
9
+ "find": ["\t\treturn { ...claudeLaunchEnvDefaults(), ...CLAUDE_FOREGROUND_ONLY_ENV };"],
10
+ "replace": ["\t\treturn claudeLaunchEnvDefaults();"],
11
+ "gate": ["bash", "run.sh", "check-acp-foreground-only"],
12
+ "timeoutSeconds": 60,
13
+ "signature": "[QK:ACP-CLAUDE-FOREGROUND-ONLY]",
14
+ "signatureSource": "pi-extensions/lib/acp/backend-adapter.test.ts"
15
+ }
16
+ ]
17
+ }