@junghanacs/entwurf 0.25.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/AGENTS.md +1 -1
  2. package/BASELINE.md +1 -1
  3. package/CHANGELOG.md +73 -0
  4. package/DELIVERY.md +1 -1
  5. package/README.md +178 -674
  6. package/VERIFY.md +16 -15
  7. package/demo/README.md +1 -1
  8. package/docs/acp-backend-rail.md +6 -2
  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 +12 -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 +11 -11
  24. package/pi-extensions/entwurf-control.ts +50 -637
  25. package/pi-extensions/lib/acp/backend-adapter.ts +13 -1
  26. package/pi-extensions/lib/entwurf-fact-provider.ts +2 -2
  27. package/pi-extensions/lib/entwurf-mcp-server.ts +74 -0
  28. package/pi-extensions/lib/entwurf-peers-render.ts +1 -1
  29. package/pi-extensions/lib/entwurf-v2-decider.ts +2 -2
  30. package/pi-extensions/lib/entwurf-v2-mailbox.ts +2 -2
  31. package/pi-extensions/lib/entwurf-v2-production.ts +3 -3
  32. package/pi-extensions/lib/entwurf-v2-surface.ts +3 -5
  33. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +3 -3
  34. package/pi-extensions/lib/fresh-call-composition.ts +13 -6
  35. package/pi-extensions/lib/meta-mailbox-body.ts +1 -1
  36. package/pi-extensions/lib/mux-fresh-call.ts +3 -3
  37. package/run.sh +163 -50
  38. package/scripts/check-entwurf-peers-surface.ts +8 -15
  39. package/scripts/check-entwurf-self-address.ts +7 -11
  40. package/scripts/check-entwurf-v2-surface.ts +20 -112
  41. package/scripts/check-fresh-call-dispatch.ts +12 -13
  42. package/scripts/check-gate-qualification.ts +248 -4
  43. package/scripts/check-install-surface.ts +3 -2
  44. package/scripts/check-mux-resume-call.ts +24 -35
  45. package/scripts/check-pi-bridge-sender.ts +130 -0
  46. package/scripts/check-pi-mcp-bridge.ts +251 -0
  47. package/scripts/lib/codex-fresh-live-protocol.ts +17 -5
  48. package/scripts/lib/codex-fresh-source-receipts.ts +21 -2
  49. package/scripts/lib/mutation-qualify.ts +269 -44
  50. package/scripts/lib/pi-mcp-bridge-probe.mjs +170 -0
  51. package/scripts/meta-bridge-doctor.sh +16 -5
  52. package/scripts/mutants/acp-foreground-only.json +17 -0
  53. package/scripts/mutants/codex-native.json +11 -11
  54. package/scripts/mutants/control-send-receipt.json +6 -4
  55. package/scripts/mutants/copilot-birth.json +2 -1
  56. package/scripts/mutants/fresh-call-dispatch.json +3 -14
  57. package/scripts/mutants/gate-qualification.json +59 -0
  58. package/scripts/mutants/mux-fresh-call.json +43 -45
  59. package/scripts/mutants/mux-resume-call.json +11 -35
  60. package/scripts/mutants/pack-install.json +15 -4
  61. package/scripts/mutants/pi-bridge-sender.json +54 -0
  62. package/scripts/mutants/pi-floor.json +6 -2
  63. package/scripts/mutants/pi-mcp-bridge.json +36 -0
  64. package/scripts/mutants/pi-mcp-register.json +51 -0
  65. package/scripts/mutants/v2-surface.json +6 -6
  66. package/scripts/resident-rpc-drive.ts +5 -1
  67. package/scripts/smoke-acp-bundled-mcp-live.ts +3 -1
  68. package/scripts/smoke-acp-cortex-live.ts +3 -1
  69. package/scripts/smoke-acp-session-reuse-live.ts +29 -12
  70. package/scripts/smoke-acp-socket-citizen-live.ts +3 -1
  71. package/scripts/smoke-acp-v2-send-live.ts +3 -1
  72. package/scripts/smoke-codex-fresh-live.ts +20 -1
  73. package/scripts/smoke-entwurf-chain-live.ts +6 -2
  74. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -1
  75. package/scripts/smoke-herdr-raw-install-live.sh +1 -1
  76. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  77. package/scripts/smoke-resident-garden-guard.sh +3 -1
@@ -142,7 +142,7 @@ function assertRailSemantics(tag: string, longDescRaw: string, intentDescRaw: st
142
142
  }
143
143
  ok(`${tag} — mode param is isolated (non-vacuous)`, modeDesc.length > 60);
144
144
  ok(
145
- `${tag} — mode param scopes itself to a CONTROL-SOCKET send and denies the other rails [QK:V2SURF-PI-MODE-SCOPE]`,
145
+ `${tag} — mode param scopes itself to a CONTROL-SOCKET send and denies the other rails [QK:V2SURF-MODE-SCOPE]`,
146
146
  /CONTROL-SOCKET send/.test(modeDesc) && /no mode/.test(modeDesc),
147
147
  );
148
148
  ok(
@@ -692,39 +692,21 @@ async function main(): Promise<void> {
692
692
  ok("3: surface is ctx-free — no ExtensionAPI", !code.includes("ExtensionAPI"));
693
693
  }
694
694
 
695
- // ── 4: pi-native control wiring guard ─────────────────────────────────────
695
+ // ── 4: pi-native control surface — the RECEIVER half, and no second caller route ──
696
+ // #125: the Entwurf verbs are no longer native pi tools. A pi session reaches them through Pi's
697
+ // built-in MCP as `mcp__entwurf_bridge__<verb>` — the SAME bridge §5 certifies — so the pi-native
698
+ // copies of §5's model-facing checks (rail semantics, dormant honesty, host cap, peers dead row)
699
+ // and the fence/senderProvider wiring they needed are retired with them, not re-proved against a
700
+ // file that no longer has them. What stays here is what this file still owns: the control
701
+ // socket's RECEIVER answer, and the absence of a second caller route.
696
702
  {
697
703
  const src = await fs.readFile(CONTROL_SRC, "utf8");
698
704
  const code = src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
699
- ok("4: pi-native — entwurf-control registers entwurf_v2", /name:\s*"entwurf_v2"/.test(src));
705
+ // Hard Rule 1: one surface name, no dual route. A native registration of any Entwurf verb
706
+ // beside the MCP one would put the bare and the prefixed name in the same session.
700
707
  ok(
701
- "4: pi-native — reaches the fence via a NON-LITERAL dynamic import (string-const specifier)",
702
- /const ENTWURF_V2_SURFACE_MODULE\s*=/.test(code) && /await import\(ENTWURF_V2_SURFACE_MODULE\)/.test(code),
703
- );
704
- // The whole point of the dynamic import: NO static import of the fence v2 chain into the
705
- // emit-capable root program (those literal `.ts` imports would be TS5097).
706
- ok(
707
- "4: pi-native — NO static import of the v2 fence (runner/production/surface) — TS5097 stays closed",
708
- !/import[^;]*from\s*"\.\/lib\/entwurf-v2-(runner|production|surface)\.(js|ts)"/.test(code),
709
- );
710
- // SE-1 2e-a: senderProvider decorates origin:'pi-session' but `replyable` is now an
711
- // HONEST fact (canonical socket existsSync via computeSelfAddressability), NOT a
712
- // hardcoded true. The self-address fence lib is reached through the same non-literal
713
- // dynamic import pattern as the v2 surface.
714
- ok(
715
- "4: pi-native — senderProvider decorates origin:'pi-session' (no hardcoded replyable:true)",
716
- /origin:\s*"pi-session"/.test(code) && !/replyable:\s*true/.test(code),
717
- );
718
- ok(
719
- "4: pi-native — replyability via computeSelfAddressability + existsSync, dynamic-imported",
720
- /const ENTWURF_SELF_ADDRESS_MODULE\s*=/.test(code) &&
721
- /await import\(ENTWURF_SELF_ADDRESS_MODULE\)/.test(code) &&
722
- /computeSelfAddressability/.test(code) &&
723
- /existsSync\(/.test(code),
724
- );
725
- ok(
726
- "4: pi-native — NO static import of the self-address fence (TS5097 stays closed)",
727
- !/import[^;]*from\s*"\.\/lib\/entwurf-self-address\.(js|ts)"/.test(code),
708
+ "4: pi-native — entwurf-control registers NO native tool (the verbs live on the one MCP bridge, #125)",
709
+ !/\bregisterTool\s*\(/.test(code) && !/name:\s*"entwurf_/.test(code),
728
710
  );
729
711
  // #120 P2: the RECEIVER's own answer. The sender can only render a boundary the receiver
730
712
  // actually names, so this is the far end of the same contract — and the bare `delivered`
@@ -745,82 +727,6 @@ async function main(): Promise<void> {
745
727
  );
746
728
  })(),
747
729
  );
748
- // Every SHIPPED rail must appear in the model-facing text. Measured 2026-07-27: both
749
- // surfaces described only control-socket/mailbox and told the model that an
750
- // `unsupported` citizen is reached "→ mailbox" — false for Antigravity, whose native-push
751
- // rail is intercepted BEFORE the mailbox mini-table and has no mailbox at all. A model
752
- // reading that would pick mailbox semantics for a citizen that has none.
753
- //
754
- // Scope this to the registration block, NOT the whole file: the first version of this
755
- // assertion swept `src`, so deleting the sentence from the description still passed on an
756
- // unrelated `native-push` string elsewhere in the module. A rail-completeness claim that
757
- // any import line can satisfy is not a claim.
758
- const piV2Start = src.indexOf("function registerEntwurfV2Tool");
759
- const piV2End = src.indexOf("\nfunction ", piV2Start + 1);
760
- // Require a REAL end marker. `piV2End === -1 ? undefined : …` would silently widen the
761
- // block to end-of-file and keep passing, which is the opposite of the fail-loud this
762
- // scope exists for.
763
- ok(
764
- "4: pi-native — registration block is isolated (non-vacuous scope for the description checks)",
765
- piV2Start !== -1 && piV2End > piV2Start,
766
- );
767
- const piV2Block = src.slice(piV2Start, piV2End);
768
- ok("4: pi-native — registration block is substantial", piV2Block.length > 500);
769
- // BLOCK SCOPE IS NOT ENOUGH — the two model-facing strings mask each other. Measured
770
- // 2026-07-27 (round 4): the `intent` param description was corrected to split the two
771
- // reject reasons while the LONG tool description still advertised the merged, false one,
772
- // and this gate stayed green at 51 checks because both live in the same block and only
773
- // ONE had to carry each literal. So slice them apart and require EACH to be true.
774
- // The start marker MUST be unique to the long description. Measured 2026-07-27 (round 5):
775
- // a bare `description:` matched the `target` PARAM first, so this "long description"
776
- // slice was 4,468 chars spanning target + intent + the long text — the very masking the
777
- // split was added to remove. An over-wide slice is a silent pass, so pin the exact
778
- // backtick opening instead, and assert the two slices are actually disjoint.
779
- const piLongDesc = sliceDescription(piV2Block, "description: `CANONICAL", "\n\t\tparameters:");
780
- const piIntentDesc = sliceDescription(piV2Block, "intent: StringEnum(", "message:");
781
- ok(
782
- "4: pi-native — long/intent slices are disjoint (neither can satisfy the other's claim)",
783
- piLongDesc.length > 0 &&
784
- piIntentDesc.length > 0 &&
785
- !piLongDesc.includes(piIntentDesc) &&
786
- !piIntentDesc.includes(piLongDesc),
787
- );
788
- for (const [what, text] of [
789
- ["long tool description", piLongDesc],
790
- ["intent param description", piIntentDesc],
791
- ] as const) {
792
- ok(`4: pi-native — ${what} is isolated (non-vacuous)`, text.length > 120);
793
- ok(`4: pi-native — ${what} names the native-push rail`, /native-push/.test(text));
794
- assertDormantHonesty(`4: pi-native — ${what}`, text);
795
- }
796
- ok("4: pi-native — long description denies native-push a mailbox", /NO mailbox/.test(piLongDesc));
797
- ok(
798
- "4: pi-native — long description steers live/alive peer → fire-and-forget",
799
- /liveness=alive/.test(piLongDesc) && /fire-and-forget/.test(piLongDesc),
800
- );
801
- assertLongDescExcludesParams("4: pi-native", piLongDesc, [
802
- "target: Type.String(",
803
- "intent: StringEnum(",
804
- "mode: Type.Optional(",
805
- "wants_reply: Type.Optional(",
806
- ]);
807
- const piModeDesc = sliceDescription(piV2Block, "mode: Type.Optional(", "wants_reply:");
808
- assertRailSemantics("4: pi-native", piLongDesc, piIntentDesc, piModeDesc);
809
- assertDescriptionFitsHostCap("4: pi-native", piLongDesc);
810
-
811
- // 4b: pi-native entwurf_peers. Asserted on THIS surface too, not only the MCP one: the two
812
- // descriptions are read by different callers (a resident pi model vs a sibling reaching in
813
- // over MCP), and a correction that lands on one of them is a correction half the garden
814
- // never sees. Same real-boundary rule as every other slice here.
815
- const piPeersStart = src.indexOf('name: "entwurf_peers"');
816
- const piPeersEnd = src.indexOf("parameters:", piPeersStart + 1);
817
- ok(
818
- "4b: pi-native — entwurf_peers description block has a REAL end boundary",
819
- piPeersStart !== -1 && piPeersEnd > piPeersStart,
820
- );
821
- const piPeersBlock = src.slice(piPeersStart, piPeersEnd);
822
- ok("4b: pi-native — entwurf_peers description is isolated (non-vacuous)", piPeersBlock.length > 200);
823
- assertPeersDeadRowHonesty("4b: pi-native", piPeersBlock);
824
730
  }
825
731
 
826
732
  // ── 5: MCP bridge wiring guard ────────────────────────────────────────────
@@ -1052,15 +958,16 @@ async function main(): Promise<void> {
1052
958
  );
1053
959
  }
1054
960
 
1055
- // ── #120 P2 (D8): the OUTCOME list in both tool descriptions names every rail ──
1056
- // A caller reads one of these two blurbs and nothing else before dispatching. The control
961
+ // ── #120 P2 (D8): the OUTCOME list in the tool description names every rail ──
962
+ // A caller reads this blurb and nothing else before dispatching. (#125: the pi-native copy is
963
+ // retired with the native verbs; a pi session reads the bridge's own blurb.) The control
1057
964
  // rail's four boundaries are the ONLY receiver-acceptance values; a mailbox answers with an
1058
965
  // ENQUEUE receipt and native-push with an INJECTION, and folding those into the boundary union
1059
966
  // would tell a caller that `mailbox-enqueued` is something a receiver observed about its own
1060
967
  // queues. Missing either rail is red — the earlier draft listed `mailbox-enqueued` beside the
1061
968
  // four and left native-push out entirely, and nothing noticed.
1062
969
  {
1063
- const outcomeSites = ["mcp/entwurf-bridge/src/index.ts", "pi-extensions/entwurf-control.ts"];
970
+ const outcomeSites = ["mcp/entwurf-bridge/src/index.ts"];
1064
971
  const missing: string[] = [];
1065
972
  for (const rel of outcomeSites) {
1066
973
  const text = await fs.readFile(path.join(REPO, rel), "utf8");
@@ -1080,12 +987,14 @@ async function main(): Promise<void> {
1080
987
  }
1081
988
  }
1082
989
  ok(
1083
- `[QK:SEND-OUTCOME-LIST-NAMES-EVERY-RAIL] both tool descriptions name each rail's own result — the control receiver's four acceptance boundaries (and that those four are the whole union), the mailbox ENQUEUE receipt, the native-push INJECTION, a rejection, and accepted-but-lock-dirty — so a caller is never told that one rail's vocabulary describes another's (missing: ${JSON.stringify(missing)})`,
990
+ `[QK:SEND-OUTCOME-LIST-NAMES-EVERY-RAIL] the tool description names each rail's own result — the control receiver's four acceptance boundaries (and that those four are the whole union), the mailbox ENQUEUE receipt, the native-push INJECTION, a rejection, and accepted-but-lock-dirty — so a caller is never told that one rail's vocabulary describes another's (missing: ${JSON.stringify(missing)})`,
1084
991
  missing.length === 0,
1085
992
  );
1086
993
  }
1087
994
 
1088
- // ── #120 P2: the four caller-facing prose sites, checked for what they SAY ──
995
+ // ── #120 P2: the caller-facing prose sites, checked for what they SAY ──
996
+ // (#125: three sites now — the pi-native copy left with the native verbs; a pi session reads
997
+ // the bridge's own `mode` text, which stays the first site below.)
1089
998
  // `[측정 2026-09-22, pi-agent-core 0.87.0; 재측정 2026-09-23, 0.87.1 — 좌표 동일]` neither mode interrupts anything: `agent-loop.js:186`
1090
999
  // drains steering after a turn ENDS and `:191-197` drains follow-ups only once the inner loop
1091
1000
  // has ended. A caller who reads "interrupt the current turn" picks `steer` for urgency and gets
@@ -1099,7 +1008,6 @@ async function main(): Promise<void> {
1099
1008
  {
1100
1009
  const sites = [
1101
1010
  "mcp/entwurf-bridge/src/index.ts",
1102
- "pi-extensions/entwurf-control.ts",
1103
1011
  "pi-extensions/lib/entwurf-v2-contract.ts",
1104
1012
  "pi-extensions/lib/entwurf-v2-contract-schema.ts",
1105
1013
  ];
@@ -20,9 +20,9 @@
20
20
  * FCD-HERDR-BACKEND-FIRST a non-pilot backend refuses before any preflight and any mutation
21
21
  * FCD-CODEX-PREFLIGHT-TMUX the Codex preflight still gates the tmux rail, and only that rail
22
22
  * FCD-ONE-NONCE one nonce is minted per call and reaches the selected rail
23
- * FCD-SURFACE-PARITY both surfaces call the same dispatcher and renderer, with no
24
- * second copy of the rail choice or the preflight ordering
25
- * FCD-PI-DYNAMIC-IMPORT pi still reaches it through its lazy dynamic import
23
+ * FCD-SURFACE-ROOT-ONLY the one public surface (the bridge; pi reaches it through Pi's
24
+ * built-in MCP since #125) calls the dispatcher and renderer, with
25
+ * no copy of the rail choice or preflight, and pi keeps no second path
26
26
  * FCD-RENDER-PER-RAIL each rail renders its own receipt; no universal tmux prose
27
27
  * FCD-HERDR-VIEW-NOT-ADDRESS the herdr receipt says its coordinates are a view
28
28
  * FCD-RECOVERY-VISIBLE a post-split failure shows closed vs orphan-unreclaimed:reason
@@ -190,11 +190,16 @@ async function main(): Promise<void> {
190
190
  /result: await herdrFreshCall\(/.test(dispatchCode),
191
191
  );
192
192
 
193
- // ── one composition root, two surfaces ───────────────────────────────────────────────
193
+ // ── one composition root, one public surface ─────────────────────────────────────────
194
+ // #125: the pi-native fresh-call copy is retired — a pi session reaches the bridge's through Pi's
195
+ // built-in MCP — so the claim is no longer parity between two copies. It is that the ONE
196
+ // surface reaches the root without carrying rail choice, preflight or render of its own, and
197
+ // that the pi extension did not keep a second path to the root beside it.
194
198
  ok(
195
- "[QK:FCD-SURFACE-PARITY] BOTH surfaces reach the dispatcher and its renderer, and NEITHER carries its own rail choice, its own Codex preflight or its own rail-specific render — a defect planted in one copy would survive on the other",
196
- PI_SRC.includes("dispatchFreshCall(") &&
197
- PI_SRC.includes("renderDispatchedFreshCall(") &&
199
+ "[QK:FCD-SURFACE-ROOT-ONLY] the bridge reaches the dispatcher and its renderer and carries no rail choice, Codex preflight or rail-specific render of its own; the pi extension carries no fresh-call path at all",
200
+ !PI_SRC.includes("dispatchFreshCall(") &&
201
+ !PI_SRC.includes("renderDispatchedFreshCall(") &&
202
+ !PI_SRC.includes("fresh-call-dispatch") &&
198
203
  MCP_SRC.includes("dispatchFreshCall({") &&
199
204
  MCP_SRC.includes("renderDispatchedFreshCall(") &&
200
205
  // Both DESCRIBE the rule to callers ("inside herdr (HERDR_ENV=1)…") and must keep
@@ -206,12 +211,6 @@ async function main(): Promise<void> {
206
211
  !MCP_SRC.includes("renderHerdrFreshCall") &&
207
212
  !PI_SRC.includes("renderHerdrFreshCall"),
208
213
  );
209
- ok(
210
- "[QK:FCD-PI-DYNAMIC-IMPORT] pi still reaches the composition root through its LAZY dynamic import — a static import would pull the rails into pi's startup path, which is the fence that keeps extension load cheap",
211
- /const FRESH_CALL_DISPATCH_MODULE = "\.\/lib\/fresh-call-dispatch\.ts";/.test(PI_SRC) &&
212
- PI_SRC.includes("await import(FRESH_CALL_DISPATCH_MODULE)") &&
213
- !/^import .*fresh-call-dispatch/m.test(PI_SRC),
214
- );
215
214
 
216
215
  // ── rendering stays rail-honest ──────────────────────────────────────────────────────
217
216
  const herdrRefusal = renderDispatchedFreshCall({
@@ -505,6 +505,45 @@ if (ARGS.mode === "attribution-self-test") {
505
505
  validateManifestSet([man], { ...permissiveOrigin, regularContainedFile: (f: string) => f !== "gates/kill.sh" }),
506
506
  "signatureSource gates/kill.sh is not a regular",
507
507
  );
508
+
509
+ // A declared build output (#125): optional, exact keys, a normalized repo-relative
510
+ // directory outside node_modules/.git, and one declaration per gate group.
511
+ const build = { argv: ["bash", "build.sh"], output: "out" };
512
+ ok(
513
+ "manifest: a declared build validates and rides the spec",
514
+ JSON.stringify(validateManifest(mutate({ build }), "m").mutants[0].build) === JSON.stringify(build),
515
+ );
516
+ ok("manifest: a spec without a build carries no build key", !("build" in man.mutants[0]));
517
+ throws(
518
+ "manifest: an unknown build key is refused",
519
+ () => validateManifest(mutate({ build: { ...build, env: {} } }), "m"),
520
+ "unknown key",
521
+ );
522
+ throws(
523
+ "manifest: a shell-string build is refused (argv array only)",
524
+ () => validateManifest(mutate({ build: { ...build, argv: "bash build.sh" } }), "m"),
525
+ "non-empty array",
526
+ );
527
+ for (const output of ["../out", "/tmp/out", "out/../x", "./out", "out/", "node_modules/x", ".git/x"]) {
528
+ throws(
529
+ `manifest: a build output ${JSON.stringify(output)} is refused`,
530
+ () => validateManifest(mutate({ build: { ...build, output } }), "m"),
531
+ "output",
532
+ );
533
+ }
534
+ const withBuild = validateManifest(
535
+ {
536
+ ...good,
537
+ lane: "built",
538
+ mutants: [{ ...good.mutants[0], claim: "SELFTEST-BUILT", signature: "[QK:SELFTEST-BUILT]", build }],
539
+ },
540
+ "c",
541
+ );
542
+ throws(
543
+ "[QK:QUALIFY-BUILD-GROUP-CONSISTENT] manifest set: one gate whose mutants declare different builds (here: one none) is refused — the same gate would be handed different inputs by run order",
544
+ () => validateManifestSet([man, withBuild], permissiveOrigin),
545
+ "mixes build declarations",
546
+ );
508
547
  }
509
548
 
510
549
  // ═══ Phase 1c — the pipeline over a synthetic fixture repo ══════════════════
@@ -879,6 +918,207 @@ const quiet = (): void => {};
879
918
  }
880
919
  }
881
920
 
921
+ // ═══ Phase 1c-b — a declared build output over a source-only fixture (#125) ═══
922
+ //
923
+ // The snapshot replicates the work surface only, so an IGNORED build output a gate consumes
924
+ // (the compiled entwurf-bridge) is absent, and such a gate is CONTROL-RED in every run. A
925
+ // mutant may declare that build instead. Each cell is a discriminating negative: a runner
926
+ // that skipped the rebuild after a mutation, read a build red as a kill, let one group's
927
+ // output reach another, or ignored a gate writing into the output flips exactly one of them.
928
+
929
+ function buildDeclaredOutputFixture(): string {
930
+ const dir = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-qualify-build-")));
931
+ const write = (rel: string, body: string): void => {
932
+ const p = path.join(dir, rel);
933
+ fs.mkdirSync(path.dirname(p), { recursive: true });
934
+ fs.writeFileSync(p, body);
935
+ };
936
+ write(".gitignore", "out/\n");
937
+ write("src.txt", "alpha\noriginal-line\nomega\n");
938
+ write("tracked-out/keep.txt", "a tracked directory is source, never a build output\n");
939
+ // The build prints the build-red mutant's own token on its FAILURE line on purpose: a
940
+ // runner that read build output as gate output would call the broken build a kill.
941
+ write(
942
+ "build.sh",
943
+ 'set -e\nrm -rf out\nmkdir -p out\nif grep -q break-build src.txt; then echo "FAIL: build broke [QK:SELFTEST-BUILD-RED]" >&2; exit 1; fi\ncp src.txt out/built.txt\n',
944
+ );
945
+ // Reads ONLY the build output: it can see the planted defect only if the runner rebuilt.
946
+ write(
947
+ "gates/built.sh",
948
+ 'if [ ! -f out/built.txt ]; then echo "FAIL: build output missing" >&2; exit 1; fi\nif grep -q defect-line out/built.txt; then echo "FAIL: built defect [QK:SELFTEST-BUILD-KILL]" >&2; exit 1; fi\nexit 0\n',
949
+ );
950
+ write(
951
+ "gates/writer.sh",
952
+ 'if [ ! -f out/built.txt ]; then exit 1; fi\nif grep -q defect-line out/built.txt; then echo stray > out/stray.txt; echo "FAIL: built defect [QK:SELFTEST-BUILD-WRITER]" >&2; exit 1; fi\nexit 0\n',
953
+ );
954
+ write(
955
+ "gates/no-output.sh",
956
+ 'if [ -e out ]; then echo "FAIL: a build output leaked out of its group" >&2; exit 1; fi\nexit 0\n',
957
+ );
958
+ const git = (...args: string[]): void => {
959
+ const r = spawnSync("git", ["-C", dir, ...args], {
960
+ stdio: "ignore",
961
+ env: {
962
+ ...process.env,
963
+ GIT_CONFIG_GLOBAL: "/dev/null",
964
+ GIT_CONFIG_SYSTEM: "/dev/null",
965
+ GIT_AUTHOR_NAME: "fx",
966
+ GIT_AUTHOR_EMAIL: "fx@localhost",
967
+ GIT_COMMITTER_NAME: "fx",
968
+ GIT_COMMITTER_EMAIL: "fx@localhost",
969
+ },
970
+ });
971
+ assert.equal(r.status, 0, `fixture git ${args.join(" ")} failed`);
972
+ };
973
+ git("init", "-q");
974
+ git("add", "-A");
975
+ git("-c", "commit.gpgsign=false", "commit", "-q", "-m", "fixture");
976
+ return dir;
977
+ }
978
+
979
+ {
980
+ const origin = buildDeclaredOutputFixture();
981
+ const build = { argv: ["bash", "build.sh"], output: "out" };
982
+ const spec = (patch: Partial<MutantSpec>): MutantSpec => ({
983
+ claim: "SELFTEST-BUILD-KILL",
984
+ title: "fixture",
985
+ subject: "src.txt",
986
+ find: ["original-line"],
987
+ replace: ["defect-line"],
988
+ gate: ["bash", "gates/built.sh"],
989
+ timeoutSeconds: 30,
990
+ signature: "[QK:SELFTEST-BUILD-KILL]",
991
+ signatureSource: "gates/built.sh",
992
+ build,
993
+ ...patch,
994
+ });
995
+ const snap = createRepoSnapshot(origin);
996
+ try {
997
+ ok(
998
+ "build fixture: the snapshot is source-only — the ignored output is not replicated",
999
+ !fs.existsSync(path.join(snap.repoDir, "out")),
1000
+ );
1001
+ const report = await qualifyMutants(
1002
+ snap,
1003
+ [
1004
+ // No declaration, same script: the #125 shape — the gate needs an output nobody built.
1005
+ // It runs FIRST, so its verdict never depends on whether an earlier group cleaned up.
1006
+ spec({
1007
+ claim: "SELFTEST-BUILD-UNDECLARED",
1008
+ signature: "[QK:SELFTEST-BUILD-UNDECLARED]",
1009
+ gate: ["bash", "gates/built.sh", "undeclared"],
1010
+ build: undefined,
1011
+ }),
1012
+ spec({}),
1013
+ spec({
1014
+ claim: "SELFTEST-BUILD-RED",
1015
+ signature: "[QK:SELFTEST-BUILD-RED]",
1016
+ replace: ["break-build"],
1017
+ }),
1018
+ spec({
1019
+ claim: "SELFTEST-NO-LEAK",
1020
+ signature: "[QK:SELFTEST-NO-LEAK]",
1021
+ gate: ["bash", "gates/no-output.sh"],
1022
+ build: undefined,
1023
+ }),
1024
+ ],
1025
+ quiet,
1026
+ );
1027
+ const byClaim = new Map(report.groups.flatMap((g) => g.mutants.map((m) => [m.claim, { g, m }] as const)));
1028
+ ok(
1029
+ "[QK:QUALIFY-BUILD-TRACKS-MUTATION] a gate that reads ONLY the declared build output kills a mutant planted in the build's SOURCE — the runner rebuilt from the mutated bytes, and the restored bytes rebuilt the baseline (control-post green)",
1030
+ byClaim.get("SELFTEST-BUILD-KILL")?.m.verdict === "KILLED" &&
1031
+ byClaim.get("SELFTEST-BUILD-KILL")?.g.control === "ok",
1032
+ );
1033
+ ok(
1034
+ "[QK:QUALIFY-BUILD-RED-NOT-A-KILL] a mutation that breaks the build is never a kill, even when the build prints the claim token on its failure line — the gate did not run",
1035
+ byClaim.get("SELFTEST-BUILD-RED")?.m.verdict === "WRONG-REASON" &&
1036
+ (byClaim.get("SELFTEST-BUILD-RED")?.m.detail ?? "").includes("declared build red"),
1037
+ );
1038
+ ok(
1039
+ "[QK:QUALIFY-BUILD-SCOPED-TO-GROUP] the declared output belongs to its group alone — a later group never sees it, it is gone when the run ends, and the snapshot tree and porcelain are exactly as before",
1040
+ byClaim.get("SELFTEST-NO-LEAK")?.g.control === "ok" &&
1041
+ !fs.existsSync(path.join(snap.repoDir, "out")) &&
1042
+ report.treeClean &&
1043
+ report.porcelainClean,
1044
+ );
1045
+ ok(
1046
+ "build fixture: a gate that needs an output its group did not declare is CONTROL-RED (the #125 qualification shape)",
1047
+ byClaim.get("SELFTEST-BUILD-UNDECLARED")?.m.verdict === "CONTROL-RED",
1048
+ );
1049
+
1050
+ const writer = await qualifyMutants(
1051
+ snap,
1052
+ [
1053
+ spec({
1054
+ claim: "SELFTEST-BUILD-WRITER",
1055
+ signature: "[QK:SELFTEST-BUILD-WRITER]",
1056
+ gate: ["bash", "gates/writer.sh"],
1057
+ signatureSource: "gates/writer.sh",
1058
+ }),
1059
+ ],
1060
+ quiet,
1061
+ );
1062
+ ok(
1063
+ "[QK:QUALIFY-BUILD-OUTPUT-GUARDED] a gate that writes into its declared build output voids its own run — IMPURE, never KILLED, and the group is post-red",
1064
+ writer.groups[0].mutants[0].verdict === "IMPURE" && writer.groups[0].control === "post-red",
1065
+ );
1066
+ ok("build fixture: an impure-output report does not pass", reportPassed(writer) === false);
1067
+
1068
+ const baselineRed = await qualifyMutants(
1069
+ snap,
1070
+ [spec({ build: { argv: ["bash", "-c", "exit 3"], output: "out" } })],
1071
+ quiet,
1072
+ );
1073
+ ok(
1074
+ "build fixture: a build red at baseline voids the group — pre-red, every mutant CONTROL-RED",
1075
+ baselineRed.groups[0].control === "pre-red" && baselineRed.groups[0].mutants[0].verdict === "CONTROL-RED",
1076
+ );
1077
+ await assert.rejects(
1078
+ qualifyMutants(snap, [spec({ build: { argv: ["bash", "build.sh"], output: "tracked-out" } })], quiet),
1079
+ /already exists in the snapshot/,
1080
+ "a declared output that is on the work surface must be refused before anything runs",
1081
+ );
1082
+ ok(
1083
+ "build fixture: a declared output that is on the work surface is refused before anything runs, and its source bytes are untouched",
1084
+ fs.existsSync(path.join(snap.repoDir, "tracked-out", "keep.txt")),
1085
+ );
1086
+ await assert.rejects(
1087
+ qualifyMutants(snap, [spec({ build: { argv: ["bash", "build.sh"], output: "absent-parent/out" } })], quiet),
1088
+ /no parent directory in the snapshot/,
1089
+ "a declared output whose parent the snapshot lacks must be refused — the build would leave that directory behind",
1090
+ );
1091
+ ok(
1092
+ "build fixture: a declared output whose parent is absent is refused before anything runs, and nothing was created",
1093
+ !fs.existsSync(path.join(snap.repoDir, "absent-parent")),
1094
+ );
1095
+ // A build that never cleans its output (append-only) still reproduces its baseline: the
1096
+ // runner removes the output before every build, so the contract does not rest on the script.
1097
+ const appending = await qualifyMutants(
1098
+ snap,
1099
+ [
1100
+ spec({
1101
+ gate: ["bash", "gates/built.sh", "appending"],
1102
+ build: {
1103
+ argv: ["bash", "-c", "mkdir -p out && cp src.txt out/built.txt && echo run >> out/append.log"],
1104
+ output: "out",
1105
+ },
1106
+ }),
1107
+ ],
1108
+ quiet,
1109
+ );
1110
+ ok(
1111
+ "build fixture: a build that does not clean its own output still kills and reproduces its baseline (control ok, tree clean)",
1112
+ appending.groups[0].mutants[0].verdict === "KILLED" &&
1113
+ appending.groups[0].control === "ok" &&
1114
+ appending.treeClean,
1115
+ );
1116
+ } finally {
1117
+ fs.rmSync(snap.baseDir, { recursive: true, force: true });
1118
+ fs.rmSync(origin, { recursive: true, force: true });
1119
+ }
1120
+ }
1121
+
882
1122
  // ═══ Phase 1c-g — the PARTIAL group receipt over the fixture (#124 P1) ═════
883
1123
  //
884
1124
  // `--group` is a mixed CLI/process surface (argv, snapshot, gate children, a file
@@ -3081,6 +3321,7 @@ let manifestCount: number;
3081
3321
  "acp-backend-preflight": 1,
3082
3322
  "acp-augment": 10,
3083
3323
  "acp-cortex": 14,
3324
+ "acp-foreground-only": 1,
3084
3325
  "acp-launch-namespace": 2,
3085
3326
  "acp-overlay": 1,
3086
3327
  "acp-prompt-lifecycle": 15,
@@ -3101,11 +3342,14 @@ let manifestCount: number;
3101
3342
  "copilot-launch": 14,
3102
3343
  "pi-launch": 9,
3103
3344
  "pi-floor": 1,
3345
+ "pi-bridge-sender": 4,
3346
+ "pi-mcp-bridge": 2,
3347
+ "pi-mcp-register": 3,
3104
3348
  "copilot-receive": 20,
3105
3349
  "entwurf-peers": 1,
3106
- "fresh-call-dispatch": 12,
3350
+ "fresh-call-dispatch": 11,
3107
3351
  "fresh-cut": 3,
3108
- "gate-qualification": 2,
3352
+ "gate-qualification": 7,
3109
3353
  "herdr-placement": 13,
3110
3354
  "herdr-plugin": 25,
3111
3355
  "herdr-plugin-profile": 14,
@@ -3123,10 +3367,10 @@ let manifestCount: number;
3123
3367
  "mux-fresh-call": 59,
3124
3368
  "mux-launcher-fence": 8,
3125
3369
  "mux-parent-artifact": 3,
3126
- "pack-install": 2,
3370
+ "pack-install": 3,
3127
3371
  "pi-package-ownership": 8,
3128
3372
  "qualification-ledger": 42,
3129
- "mux-resume-call": 12,
3373
+ "mux-resume-call": 10,
3130
3374
  "omp-birth": 13,
3131
3375
  "omp-fresh": 24,
3132
3376
  "omp-receive": 11,
@@ -260,8 +260,9 @@ const operatorCmds = [...targets].filter(([cmd, ts]) => !isDevGate(cmd) && ts.le
260
260
  .replace(/'(?:[^'\\]|\\.)*'/g, "''");
261
261
  const RUNSH_DRIVE = /(?:\$\{?RUN\}?|run\.sh)\s+(install|setup|remove-user-scope|remove)\b/;
262
262
  // install/setup write the operator's REAL user-scope surfaces under "$HOME"
263
- // (user settings.json registration ignores PI_CODING_AGENT_DIR), so sandboxing
264
- // the agent dir is not isolation for them; only HOME is. (setup:links died with
263
+ // (settings.json registration follows PI_CODING_AGENT_DIR when set, but provider
264
+ // state and the rest stay HOME/XDG-rooted), so sandboxing the agent dir alone is not
265
+ // isolation for them; HOME is required. (setup:links died with
265
266
  // the target registry — #50 C3.)
266
267
  const HOME_ROOTED = new Set(["install", "setup"]);
267
268
 
@@ -212,6 +212,7 @@ function main(): void {
212
212
 
213
213
  const PI_SURFACE = read("pi-extensions/entwurf-control.ts");
214
214
  const MCP_SURFACE = read("mcp/entwurf-bridge/src/index.ts");
215
+ const PI_CODE = PI_SURFACE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
215
216
  const V2_RESUME = read("pi-extensions/lib/entwurf-v2-visible-resume.ts");
216
217
  // IMPORTS, not mentions: the module header names the mux side deliberately (that is the
217
218
  // architecture being documented), so an assertion on prose would forbid the explanation
@@ -222,55 +223,43 @@ function main(): void {
222
223
  v2ImportSpecifiers.length > 0 && !v2ImportSpecifiers.some((s) => /mux-/.test(s)),
223
224
  );
224
225
  ok(
225
- "both surfaces are the composition root: each imports the mux launcher AND the v2 composition and joins them itself",
226
- /mux-resume-call/.test(PI_SURFACE) &&
227
- /entwurf-v2-visible-resume/.test(PI_SURFACE) &&
228
- /mux-resume-call/.test(MCP_SURFACE) &&
229
- /entwurf-v2-visible-resume/.test(MCP_SURFACE),
226
+ "the bridge is the composition root: it imports the mux launcher AND the v2 composition and joins them itself",
227
+ /mux-resume-call/.test(MCP_SURFACE) && /entwurf-v2-visible-resume/.test(MCP_SURFACE),
230
228
  );
231
-
232
- // ── the NATIVE PI registration, judged as a registration ─────────────────────
233
- // The MCP half is judged on the real runtime tools/list by check-entwurf-bridge-boot.
234
- // Native pi has no equivalent boot oracle here, so its registration is read from source —
235
- // but narrowly, on the block itself, because "the module is imported" would stay true with
236
- // the tool deleted.
237
- const PI_BLOCK = (PI_SURFACE.split('name: "entwurf_resume_call",')[1] ?? "").split("\n}")[0];
229
+ // #125: pi reaches entwurf_resume_call through Pi's built-in MCP — the bridge above IS its
230
+ // surface. The native pi registration, its target-only TypeBox schema and its description are
231
+ // retired with it (registration and target-only are held on the runtime tools/list by
232
+ // BRIDGEBOOT-RESUME-CALL-REGISTERED / -TARGET-ONLY, the description by DESC-CONTRACT-MCP below).
233
+ // What is left to say about the pi file is that it does not come back as a second root.
238
234
  ok(
239
- "[QK:MUXRESUME-PI-SURFACE-REGISTERED] the native pi surface REGISTERS entwurf_resume_call and calls that registration during setup — an imported-but-unregistered verb is invisible to the operator while every import assertion stays green",
240
- PI_SURFACE.includes('name: "entwurf_resume_call"') && /registerResumeCallTool\(pi\);/.test(PI_SURFACE),
235
+ "the pi extension no longer joins the resume halves itself — no second composition root beside the bridge",
236
+ !/mux-resume-call/.test(PI_CODE) && !/entwurf-v2-visible-resume/.test(PI_CODE),
241
237
  );
238
+ // The pass-through half the native claim used to carry, now on the one surface that has it:
239
+ // the handler hands the validated target to the v2 composition UNCHANGED. The schema proves the
240
+ // input is target-only; this proves the value that reaches the record lookup is that input.
241
+ const MCP_RESUME_BLOCK = (MCP_SURFACE.split('"entwurf_resume_call",')[1] ?? "").split("\n);")[0];
242
242
  ok(
243
- "[QK:MUXRESUME-PI-SURFACE-TARGET-ONLY] the native schema is target-only and the handler passes params.target straight through — a model, task or prompt knob would be a second way to decide what a resumed citizen is, when the record already decided",
244
- /Type\.Object\(\{\s*target: Type\.String\(/.test(PI_BLOCK) &&
245
- /visibleResume\(params\.target,/.test(PI_BLOCK) &&
246
- !/model|task|prompt/.test(PI_BLOCK.split("parameters:")[1]?.split("async execute")[0] ?? ""),
243
+ "[QK:MUXRESUME-MCP-TARGET-PASSTHROUGH] the bridge handler passes its target straight to visibleResume — a rewritten, defaulted or substituted id would reopen a DIFFERENT citizen than the one the caller named",
244
+ /async \(\{ target \}\) =>/.test(MCP_RESUME_BLOCK) &&
245
+ /await visibleResume\(target, makeVisibleResumeDeps\(visibleResumeLaunch\)\)/.test(MCP_RESUME_BLOCK),
247
246
  );
248
247
 
249
- // ── the description contract, both surfaces ──────────────────────────────────
248
+ // ── the description contract ────────────────────────────────────────────────
250
249
  // Not prose parity — three claims an operator acts on, plus the host's own cap.
251
- const PI_DESC = (PI_BLOCK.split("description: `")[1] ?? "").split("`,")[0];
252
250
  const MCP_DESC = (MCP_SURFACE.split('"entwurf_resume_call",')[1] ?? "").split("{")[0];
253
251
  const statesTheContract = (text: string): boolean =>
254
252
  /dormant/i.test(text) && /no turn/i.test(text) && /observation/i.test(text) && /launch/i.test(text);
255
253
 
256
- for (const [which, text] of [
257
- ["native pi", PI_DESC],
258
- ["MCP bridge", MCP_DESC],
259
- ] as const) {
260
- ok(
261
- `${which}: the resume description fits the host's 2048-char tool-description cap`,
262
- text.length > 0 && text.length <= 2048,
263
- );
264
- }
265
- // The two claim tokens are written OUT, not interpolated: qualification requires each
266
- // `[QK:…]` to appear literally exactly once in its gate source, so a token assembled at
267
- // runtime names a claim no manifest can bind to.
268
254
  ok(
269
- "[QK:MUXRESUME-DESC-CONTRACT-PI] native pi: the description states the three things a caller acts on — DORMANT targets only, no turn is run, and TWO receipts of which the observation is the one that says the citizen is back",
270
- statesTheContract(PI_DESC),
255
+ "MCP bridge: the resume description fits the host's 2048-char tool-description cap",
256
+ MCP_DESC.length > 0 && MCP_DESC.length <= 2048,
271
257
  );
258
+ // The claim token is written OUT, not interpolated: qualification requires each `[QK:…]` to
259
+ // appear literally exactly once in its gate source, so a token assembled at runtime names a
260
+ // claim no manifest can bind to.
272
261
  ok(
273
- "[QK:MUXRESUME-DESC-CONTRACT-MCP] MCP bridge: the description states the same three things — DORMANT targets only, no turn is run, and TWO receipts whose observation is the fact that matters",
262
+ "[QK:MUXRESUME-DESC-CONTRACT-MCP] MCP bridge: the description states the three things a caller acts on — DORMANT targets only, no turn is run, and TWO receipts whose observation is the fact that matters",
274
263
  statesTheContract(MCP_DESC),
275
264
  );
276
265
  } finally {