@junghanacs/entwurf 0.13.1 → 0.14.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 (158) hide show
  1. package/AGENTS.md +59 -15
  2. package/BASELINE.md +3 -3
  3. package/CHANGELOG.md +74 -0
  4. package/CONTRIBUTING.md +13 -9
  5. package/DELIVERY.md +7 -6
  6. package/README.md +37 -28
  7. package/VERIFY.md +22 -14
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +9 -4
  12. package/docs/external-mcp-host.md +4 -5
  13. package/docs/setup-clean-host.md +8 -7
  14. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +155 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +300 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +141 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  38. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  39. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  40. package/mcp/entwurf-bridge/src/index.ts +173 -28
  41. package/mcp/entwurf-bridge/start.sh +2 -2
  42. package/mcp/entwurf-bridge/test.sh +23 -9
  43. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  44. package/package.json +22 -11
  45. package/pi-extensions/entwurf-control.ts +227 -40
  46. package/pi-extensions/lib/acp/backend.ts +71 -12
  47. package/pi-extensions/lib/acp/overlay.ts +8 -6
  48. package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
  49. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  50. package/pi-extensions/lib/entwurf-core.ts +15 -15
  51. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  52. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  53. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  54. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  55. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  56. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  57. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  58. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  59. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  60. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  61. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  62. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  63. package/pi-extensions/lib/meta-session.ts +93 -5
  64. package/pi-extensions/lib/mux-fresh-call.ts +381 -0
  65. package/pi-extensions/lib/mux-launch.ts +267 -0
  66. package/pi-extensions/lib/mux-placement.ts +387 -0
  67. package/pi-extensions/lib/mux-resume-call.ts +189 -0
  68. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  69. package/pi-extensions/lib/session-id.js +8 -5
  70. package/pi-extensions/lib/socket-discovery.ts +3 -3
  71. package/run.sh +471 -201
  72. package/scripts/agy-bridge-config.py +5 -1
  73. package/scripts/check-acp-backend-preflight.ts +1 -1
  74. package/scripts/check-acp-overlay.ts +13 -3
  75. package/scripts/check-acp-stream-hooks.ts +504 -0
  76. package/scripts/check-elapsed.sh +25 -0
  77. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  78. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  79. package/scripts/check-entwurf-control-rpc.ts +4 -3
  80. package/scripts/check-entwurf-resume-args.ts +72 -70
  81. package/scripts/check-entwurf-session-identity.ts +14 -10
  82. package/scripts/check-entwurf-v2-contract.ts +34 -59
  83. package/scripts/check-entwurf-v2-decider.ts +17 -177
  84. package/scripts/check-entwurf-v2-lock.ts +5 -2
  85. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  86. package/scripts/check-entwurf-v2-production.ts +2 -91
  87. package/scripts/check-entwurf-v2-release.ts +10 -105
  88. package/scripts/check-entwurf-v2-runner.ts +4 -85
  89. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  90. package/scripts/check-entwurf-v2-send.ts +0 -28
  91. package/scripts/check-entwurf-v2-surface.ts +157 -128
  92. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  93. package/scripts/check-fresh-cut-gate.sh +1 -1
  94. package/scripts/check-gate-qualification.ts +99 -7
  95. package/scripts/check-install-container.sh +10 -2
  96. package/scripts/check-install-surface.ts +1 -1
  97. package/scripts/check-keyset-overlap.py +1 -1
  98. package/scripts/check-meta-facts.ts +249 -0
  99. package/scripts/check-meta-identity-consumers.ts +1 -1
  100. package/scripts/check-meta-session.ts +169 -0
  101. package/scripts/check-mux-launch-tmux.ts +316 -0
  102. package/scripts/check-mux-launch.ts +288 -0
  103. package/scripts/check-mux-launcher-fence.ts +264 -0
  104. package/scripts/check-mux-parent-artifact.ts +195 -0
  105. package/scripts/check-mux-placement-tmux.ts +322 -0
  106. package/scripts/check-mux-placement.ts +323 -0
  107. package/scripts/check-mux-resume-call.ts +283 -0
  108. package/scripts/check-probe-cli-shim.ts +25 -22
  109. package/scripts/check-probe-ordering.ts +84 -76
  110. package/scripts/check-release-gate-outcomes.ts +127 -7
  111. package/scripts/check-resume-launch-identity.ts +244 -0
  112. package/scripts/check-socket-discovery.ts +1 -1
  113. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  114. package/scripts/inventory-verification-surface.ts +349 -0
  115. package/scripts/lib/claude-launcher-fence.ts +322 -0
  116. package/scripts/lib/mutation-qualify.ts +109 -3
  117. package/scripts/meta-bridge-doctor.sh +6 -8
  118. package/scripts/meta-bridge-state.py +75 -1
  119. package/scripts/meta-facts.ts +60 -0
  120. package/scripts/mutants/acp-overlay.json +17 -0
  121. package/scripts/mutants/acp-stream-hooks.json +158 -0
  122. package/scripts/mutants/bridge-boot-resume.json +45 -0
  123. package/scripts/mutants/meta-facts.json +50 -0
  124. package/scripts/mutants/meta-identity.json +36 -0
  125. package/scripts/mutants/meta-retire.json +47 -0
  126. package/scripts/mutants/mux-boundary.json +196 -0
  127. package/scripts/mutants/mux-fresh-call.json +229 -0
  128. package/scripts/mutants/mux-launcher-fence.json +123 -0
  129. package/scripts/mutants/mux-parent-artifact.json +39 -0
  130. package/scripts/mutants/mux-resume-call.json +148 -0
  131. package/scripts/mutants/probe-ordering.json +0 -1037
  132. package/scripts/mutants/release-gate.json +35 -0
  133. package/scripts/mutants/resume-args.json +76 -0
  134. package/scripts/mutants/resume-launch-identity.json +96 -0
  135. package/scripts/mutants/v2-surface.json +58 -18
  136. package/scripts/mutants/v2-visible-resume.json +215 -0
  137. package/scripts/new-session-id.ts +9 -4
  138. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  139. package/scripts/smoke-agy-native-push-live.ts +6 -17
  140. package/scripts/smoke-entwurf-chain-live.ts +11 -3
  141. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  142. package/scripts/smoke-meta-honesty.sh +1 -1
  143. package/scripts/smoke-meta-install-state.sh +169 -3
  144. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  145. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  146. package/scripts/smoke-pi-attach.ts +1 -1
  147. package/scripts/smoke-user-scope-citizen.sh +1 -1
  148. package/scripts/tsconfig.json +1 -0
  149. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  150. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  151. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  152. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  153. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  154. package/scripts/check-acp-sdk-surface.ts +0 -275
  155. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  156. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  157. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  158. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
@@ -7,7 +7,8 @@
7
7
  * pass through, absent `mode`/`wants_reply` stay undefined (decider defaults, no double).
8
8
  * 2. renderEntwurfV2Result — each result kind → the right `{ text, isError }`, surfacing the
9
9
  * carry-overs: reject reason + target-locked diagnostic / control N3 rejectReason /
10
- * spawn lock-retained diagnostic / N1 delivered-but-lock-dirty.
10
+ * N1 delivered-but-lock-dirty — plus the two reject HINTS an operator meets after the
11
+ * visible-first cut (dormant-fire-forget-unsupported, indeterminate-no-spawn).
11
12
  * 3. surface source guard — `entwurf-v2-surface.ts` is ctx-free (no ExtensionContext/API).
12
13
  * 4. control wiring guard — `entwurf-control.ts` registers `entwurf_v2`, reaches the fence
13
14
  * ONLY via a NON-LITERAL dynamic import (a string-const specifier), NEVER a static import
@@ -23,8 +24,6 @@ import { fileURLToPath } from "node:url";
23
24
  import type { EntwurfV2RunResult } from "../pi-extensions/lib/entwurf-v2-runner.ts";
24
25
  import {
25
26
  actionableRejectHint,
26
- ENTWURF_PREFIX_ROOTS_ENV,
27
- parseEntwurfPrefixRootsEnv,
28
27
  renderEntwurfV2Result,
29
28
  type SurfaceEntwurfV2Params,
30
29
  toDispatchInput,
@@ -64,13 +63,12 @@ function modelText(slice: string): string {
64
63
  * unsupported-citizen outcome set in ways the code does not support. Measured against
65
64
  * source, not prose:
66
65
  * - LOCK: `entwurf-v2-decider.ts` returns a held claim ONLY on the control-socket-domain
67
- * branch and that branch covers the live send AND the dormant cell's spawn-bg resume.
68
- * The mailbox and native-push branches both carry `lock: null`. "socket paths take a
69
- * per-target lock" read as "the socket transport locks", which got spawn-bg (a SEPARATE
70
- * relaunch transport that nonetheless locks) and native-push (a live send that does NOT)
71
- * exactly backwards.
66
+ * branch. The mailbox and native-push branches both carry `lock: null`. "socket paths
67
+ * take a per-target lock" read as "the socket transport locks", which got native-push
68
+ * (a live send that does NOT lock) exactly backwards. The DOMAIN wording survived the
69
+ * visible-first cut; the second in-domain transport it also covered did not.
72
70
  * - MODE: `mode` exists on exactly ONE ExecutionPlan variant, `control-socket`. The
73
- * meta-mailbox and native-push plans have no such field and spawn-bg says so in a comment.
71
+ * meta-mailbox and native-push plans have no such field.
74
72
  * "mode applies to a live send" is false for native-push, which IS a live send.
75
73
  * - THIRD RESULT: `resolveDispatch` downgrades the unsupported fire-and-forget cell to
76
74
  * `mailbox-undeliverable` when the separate deliverability fact is false, so an
@@ -90,10 +88,9 @@ function assertRailSemantics(tag: string, longDescRaw: string, intentDescRaw: st
90
88
  `${tag} — long description scopes the per-target lock to the control-socket DOMAIN [QK:V2SURF-MCP-LOCK-DOMAIN]`,
91
89
  /control-socket-DOMAIN\s+dispatch/.test(longDesc) && /lock-free/.test(longDesc),
92
90
  );
93
- ok(
94
- `${tag} long description says spawn-bg ALSO runs under that domain's lock`,
95
- /spawn-bg/.test(longDesc) && /still runs under that domain's lock/.test(longDesc),
96
- );
91
+ // The spawn-bg half of this claim went with the transport itself (visible-first cut): there is
92
+ // no second in-domain transport left to run under the lock, so asserting one would be a green
93
+ // gate proving retired behavior. What survives is the DOMAIN scope, pinned just above.
97
94
  ok(
98
95
  `${tag} — long description says the mailbox AND native-push rails are lock-free`,
99
96
  /mailbox and native-push rails are lock-free/.test(longDesc),
@@ -154,6 +151,69 @@ function assertRailSemantics(tag: string, longDescRaw: string, intentDescRaw: st
154
151
  );
155
152
  }
156
153
 
154
+ /**
155
+ * DORMANT HONESTY — the model-facing replacement for the retired `V2SURF-MERGED-REJECT` claim,
156
+ * whose subject (two distinct OWNED rejects) left with the `owned-outcome` intent.
157
+ *
158
+ * After the visible-first cut a dormant socket-domain citizen is unreachable by every verb, and
159
+ * these four strings are the only place a caller learns that BEFORE spending a dispatch. So each
160
+ * one must, on its own:
161
+ * - name the reason it will actually get back (`dormant-fire-forget-unsupported`);
162
+ * - name the intent that used to answer there (`owned-outcome`) — silently deleting it leaves
163
+ * an operator who read last week's docs with no way to find out what happened;
164
+ * - say it was `withdrawn`, ADJACENTLY to that name. Presence alone is not enough: naming a
165
+ * retired intent without the retirement verb beside it reads as an offer, which is exactly
166
+ * the "advertised as selectable" failure this claim exists to forbid;
167
+ * - carry NEITHER retired advertisement form (`owned-outcome = …` enum styling, or the old
168
+ * "DORMANT socket-domain citizen … never auto-converted" sentence that described picking it).
169
+ *
170
+ * ORDER IS LOAD-BEARING, and not for readability. `ok` throws on the FIRST failure, and the kill
171
+ * signature is read off the failing line — so the `[QK:…]` label must sit on the assertion this
172
+ * claim's mutant reaches FIRST. That mutant drops the reason AND the withdrawal in one edit; with
173
+ * the reason check leading, an UNLABELLED line would throw first and the kill would read as
174
+ * WRONG-REASON. The adjacency check therefore goes first and carries the only token; the other two
175
+ * run unlabelled behind it — still real assertions, just not this claim's signature.
176
+ */
177
+ function assertDormantHonesty(tag: string, rawText: string): void {
178
+ const text = modelText(rawText);
179
+ const at = text.indexOf("owned-outcome");
180
+ ok(
181
+ `${tag} — names the withdrawn intent owned-outcome and says "withdrawn" beside it (not as an offer) [QK:V2SURF-DORMANT-HONESTY]`,
182
+ at >= 0 && /withdrawn/.test(text.slice(at, at + 200)),
183
+ );
184
+ ok(`${tag} — names the reason a dormant target actually returns`, /dormant-fire-forget-unsupported/.test(text));
185
+ ok(
186
+ `${tag} — never advertises owned-outcome as selectable`,
187
+ !/owned-outcome\s*=/.test(text) && !/DORMANT socket-domain citizen/.test(text) && !/auto-converted/.test(text),
188
+ );
189
+ }
190
+
191
+ /**
192
+ * PEERS DEAD-ROW HONESTY. `entwurf_peers` is a FACT surface and stays one: this claim must never
193
+ * be satisfiable by adding a per-row action/routing field, which is why it is asserted on the
194
+ * DESCRIPTION and paired with a negative that forbids the row-level fix. What the description owes
195
+ * a caller is that `dead` is a reported fact carrying no invitation — the citizen is dormant and,
196
+ * since the visible-first cut, reachable by no verb at all.
197
+ */
198
+ function assertPeersDeadRowHonesty(tag: string, rawBlock: string): void {
199
+ const text = modelText(rawBlock);
200
+ ok(
201
+ `${tag} — entwurf_peers says a dead/dormant row is a REPORTED FACT that grants no action [QK:V2SURF-PEERS-DEAD-ROW]`,
202
+ /REPORTED FACT/.test(text) && /grants no action/.test(text) && /not an invitation to dispatch/.test(text),
203
+ );
204
+ ok(
205
+ `${tag} — entwurf_peers calls a dead row currently unreachable by any verb`,
206
+ /dormant/.test(text) && /unreachable by any verb/.test(text),
207
+ );
208
+ // The forbidden repair: teaching the row itself to carry a verb. The facts-only rule is what
209
+ // keeps this surface from becoming a second dispatch table, so a description that promised a
210
+ // per-row action would be a worse fix than the confusion it removes.
211
+ ok(
212
+ `${tag} — entwurf_peers still denies per-row routing verbs (facts-only)`,
213
+ /never per-row routing verbs|no per-row routing field/.test(text),
214
+ );
215
+ }
216
+
157
217
  /**
158
218
  * Containment-disjointness is NOT enough to prove a description slice is tight. Round 5
159
219
  * (2026-07-27) shipped a "long description" slice of 4,468 chars that SPANNED the parameter
@@ -227,7 +287,7 @@ async function main(): Promise<void> {
227
287
  {
228
288
  const full: SurfaceEntwurfV2Params = {
229
289
  target: GID,
230
- intent: "owned-outcome",
290
+ intent: "fire-and-forget",
231
291
  message: "hi",
232
292
  mode: "steer",
233
293
  wants_reply: true,
@@ -235,7 +295,7 @@ async function main(): Promise<void> {
235
295
  const di = toDispatchInput(full);
236
296
  ok(
237
297
  "1: target/intent/message pass through",
238
- di.target === GID && di.intent === "owned-outcome" && di.message === "hi",
298
+ di.target === GID && di.intent === "fire-and-forget" && di.message === "hi",
239
299
  );
240
300
  ok("1: wants_reply → wantsReply (snake→camel)", di.wantsReply === true);
241
301
  ok("1: mode passes through", di.mode === "steer");
@@ -301,41 +361,9 @@ async function main(): Promise<void> {
301
361
  cr.isError && cr.text.includes("dormant-fire-forget-unsupported"),
302
362
  );
303
363
 
304
- // spawn lock-retained fail-closed diagnostic
305
- const retained: EntwurfV2RunResult = {
306
- kind: "executed",
307
- receipt: { ...SUCCESS_RECEIPT, transport: "spawn-bg" },
308
- transport: "spawn-bg",
309
- outcome: {
310
- transport: "spawn-bg",
311
- result: {
312
- kind: "lock-retained",
313
- released: false,
314
- reason: "observe-failed",
315
- diagnostic: {
316
- targetGardenId: GID,
317
- lockPath: "/locks/x.lock",
318
- expectedSocketPath: "/ctl/x.sock",
319
- observeTimeoutMs: 30000,
320
- killGraceMs: 5000,
321
- },
322
- },
323
- },
324
- };
325
- const ret = renderEntwurfV2Result(retained);
326
- ok(
327
- "2: spawn lock-retained → isError + diagnostic surfaced",
328
- ret.isError && ret.text.includes("LOCK RETAINED") && ret.text.includes("/locks/x.lock"),
329
- );
330
-
331
- // spawn socket-alive → delivered
332
- const alive: EntwurfV2RunResult = {
333
- kind: "executed",
334
- receipt: { ...SUCCESS_RECEIPT, transport: "spawn-bg" },
335
- transport: "spawn-bg",
336
- outcome: { transport: "spawn-bg", result: { kind: "socket-alive", released: true, pid: 7 } },
337
- };
338
- ok("2: spawn socket-alive → not error", !renderEntwurfV2Result(alive).isError);
364
+ // The two spawn-bg render cells (lock-retained / socket-alive) were deleted with the
365
+ // transport itself. Nothing renders them anymore, and `EntwurfV2Transport` no longer
366
+ // admits the literal — a cell kept here would only prove the type is still wrong.
339
367
 
340
368
  // meta-mailbox → enqueued
341
369
  const mailbox: EntwurfV2RunResult = {
@@ -373,17 +401,6 @@ async function main(): Promise<void> {
373
401
  !renderEntwurfV2Result(npRetried).isError && renderEntwurfV2Result(npRetried).text.includes("retry"),
374
402
  );
375
403
 
376
- // native-push owned reject → hint to switch to fire-and-forget
377
- const npReject: EntwurfV2RunResult = {
378
- kind: "rejected",
379
- receipt: { ok: false, reason: "native-push-no-resume-authority", observedLiveness: "alive" },
380
- };
381
- const npRej = renderEntwurfV2Result(npReject);
382
- ok(
383
- "2: native-push-no-resume-authority → isError + fire-and-forget hint",
384
- npRej.isError && npRej.text.includes("fire-and-forget"),
385
- );
386
-
387
404
  // N1: execution-failed with finalizedOutcome + releaseFailed → delivered-but-dirty
388
405
  const n1: EntwurfV2RunResult = {
389
406
  kind: "execution-failed",
@@ -410,28 +427,65 @@ async function main(): Promise<void> {
410
427
  };
411
428
  ok("2: plain execution-failed → isError", renderEntwurfV2Result(failed).isError);
412
429
 
413
- // Detour B (B-a): backend-liveness-unsupported reject still a reject (isError),
414
- // but the text carries the actionable "use fire-and-forget mailbox" hint. The
415
- // reject stays honest (reason unchanged, no auto-convert) only the render guides.
416
- const metaReject: EntwurfV2RunResult = {
430
+ // Detour B (B-a) after the visible-first cut: the two OWNED rejects this block used to
431
+ // exercise (`backend-liveness-unsupported`, `owned-live-no-autosend`) left the reason
432
+ // union with the intent that produced them. The cell that now carries the whole cost of
433
+ // the cut is `dormant-fire-forget-unsupported`, and it is the one an operator actually
434
+ // meets — so it, not a retired reason, is what this block must prove.
435
+ const dormantReject: EntwurfV2RunResult = {
417
436
  kind: "rejected",
418
- receipt: { ok: false, reason: "backend-liveness-unsupported", observedLiveness: "unsupported" },
437
+ receipt: { ok: false, reason: "dormant-fire-forget-unsupported", observedLiveness: "dead" },
419
438
  };
420
- const mr = renderEntwurfV2Result(metaReject);
439
+ const dr = renderEntwurfV2Result(dormantReject);
421
440
  ok(
422
- "2: backend-liveness-unsupported reject → isError + actionable fire-and-forget/mailbox hint",
423
- mr.isError &&
424
- mr.text.includes("backend-liveness-unsupported") &&
425
- mr.text.includes("fire-and-forget") &&
426
- mr.text.includes("mailbox"),
441
+ "2: dormant-fire-forget-unsupported reject → isError + reason surfaced",
442
+ dr.isError && dr.text.includes("dormant-fire-forget-unsupported"),
427
443
  );
444
+ // The dormant hint is the ONLY place a caller learns why an id that entwurf_peers listed
445
+ // is unreachable. A reader who takes "reject" for "wrong id" goes looking in the wrong
446
+ // place, so the hint must distinguish the two: record intact, session not running.
447
+ const dormantHint = actionableRejectHint("dormant-fire-forget-unsupported") ?? "";
428
448
  ok(
429
- "2B: actionableRejectHint guides meta-session owned fire-and-forget mailbox",
430
- (actionableRejectHint("backend-liveness-unsupported") ?? "").includes("fire-and-forget"),
449
+ "2B: dormant hint separates 'record intact' from 'session not running' (not a bad id)",
450
+ /record is intact/.test(dormantHint) && /session is not running/.test(dormantHint),
431
451
  );
432
452
  ok(
433
- "2B: actionableRejectHint guides owned-live fire-and-forget",
434
- (actionableRejectHint("owned-live-no-autosend") ?? "").includes("fire-and-forget"),
453
+ "2B: dormant hint names the withdrawn intent and the visible-first rule, and hands the operator the resume verb",
454
+ /owned-outcome|resume that used to answer here/.test(dormantHint) &&
455
+ /withdrawn/.test(dormantHint) &&
456
+ /visible-first/.test(dormantHint) &&
457
+ /entwurf_resume_call/.test(dormantHint),
458
+ );
459
+ // The frozen wire id still spells "-no-spawn" although nothing spawns anymore. The id is
460
+ // deliberately NOT renamed (renaming a public reject is its own contract cut), so the
461
+ // hint carries the whole correction: probe inconclusive, and nothing ran.
462
+ const indetReject: EntwurfV2RunResult = {
463
+ kind: "rejected",
464
+ receipt: { ok: false, reason: "indeterminate-no-spawn", observedLiveness: "indeterminate" },
465
+ };
466
+ const ir = renderEntwurfV2Result(indetReject);
467
+ ok(
468
+ "2: indeterminate-no-spawn reject → isError + reason surfaced",
469
+ ir.isError && ir.text.includes("indeterminate-no-spawn"),
470
+ );
471
+ const indetHint = actionableRejectHint("indeterminate-no-spawn") ?? "";
472
+ // ORDER IS LOAD-BEARING (same rule as assertDormantHonesty): `ok` throws on the FIRST
473
+ // failure and the kill signature is read off the failing line, so the labelled assertion
474
+ // must be the one this claim's mutant reaches first. Its terse replacement text fails the
475
+ // length floor too — leading with that UNLABELLED check would throw first and the kill
476
+ // would read as WRONG-REASON. The no-start facts are also the claim's actual subject, so
477
+ // they lead and carry the only token; existence/UNKNOWN run unlabelled behind them.
478
+ ok(
479
+ "2B: indeterminate hint states NOTHING was delivered and NO process was started " +
480
+ "[QK:V2SURF-INDETERMINATE-NO-START]",
481
+ /NOTHING was delivered/.test(indetHint) && /NO process was started/.test(indetHint),
482
+ );
483
+ ok("2B: indeterminate hint exists at all (the frozen '-no-spawn' id cannot explain itself)", indetHint.length > 80);
484
+ // "the PROBE was inconclusive", never "the socket answered": `indeterminate` also covers a
485
+ // probe that got no answer, was refused by permissions, or timed out.
486
+ ok(
487
+ "2B: indeterminate hint says the PROBE was inconclusive and liveness is UNKNOWN, not a measured death",
488
+ /probe was inconclusive/.test(indetHint) && /UNKNOWN/.test(indetHint) && /not a measured death/.test(indetHint),
435
489
  );
436
490
  ok(
437
491
  "2B: actionableRejectHint returns undefined for a reject with no next step",
@@ -450,22 +504,11 @@ async function main(): Promise<void> {
450
504
  );
451
505
  }
452
506
 
453
- // ── 6: parseEntwurfPrefixRootsEnv (5d-4b operator-policy SSOT) ─────────────
454
- {
455
- const D = path.delimiter;
456
- ok("6: env name is ENTWURF_PREFIX_ROOTS", ENTWURF_PREFIX_ROOTS_ENV === "ENTWURF_PREFIX_ROOTS");
457
- ok("6: undefined [] (no prefix promotion)", parseEntwurfPrefixRootsEnv(undefined).length === 0);
458
- ok("6: empty string → []", parseEntwurfPrefixRootsEnv("").length === 0);
459
- ok("6: delimiters-only → []", parseEntwurfPrefixRootsEnv(`${D}${D}`).length === 0);
460
- const two = parseEntwurfPrefixRootsEnv(`/repos/gh${D}/repos/work`);
461
- ok("6: delimiter-separated → entries", two.length === 2 && two[0] === "/repos/gh" && two[1] === "/repos/work");
462
- const trimmed = parseEntwurfPrefixRootsEnv(` /a ${D} ${D} /b `);
463
- ok("6: trims + drops empty segments", trimmed.length === 2 && trimmed[0] === "/a" && trimmed[1] === "/b");
464
- // A nonexistent/typo path is KEPT verbatim (no throw, no validation) — preflight's
465
- // normalize handles it, and a typo must never broaden approve nor fail the dispatch.
466
- const typo = parseEntwurfPrefixRootsEnv("/this/does/not/exist");
467
- ok("6: nonexistent path kept verbatim (no throw)", typo.length === 1 && typo[0] === "/this/does/not/exist");
468
- }
507
+ // ── 6: RETIRED — parseEntwurfPrefixRootsEnv / ENTWURF_PREFIX_ROOTS ────────
508
+ // The prefix-roots env SSOT existed to feed the trust preflight, and the preflight existed
509
+ // to guard the resume verdict. Both left with the intent. The surface no longer exports
510
+ // either symbol, so there is nothing here to certify — and a gate that kept parsing a
511
+ // deleted export would be testing itself.
469
512
 
470
513
  // ── 3: surface source guard — ctx-free ────────────────────────────────────
471
514
  {
@@ -510,7 +553,7 @@ async function main(): Promise<void> {
510
553
  !/import[^;]*from\s*"\.\/lib\/entwurf-self-address\.(js|ts)"/.test(code),
511
554
  );
512
555
  // Every SHIPPED rail must appear in the model-facing text. Measured 2026-07-27: both
513
- // surfaces described only control-socket/spawn-bg/mailbox and told the model that an
556
+ // surfaces described only control-socket/mailbox and told the model that an
514
557
  // `unsupported` citizen is reached "→ mailbox" — false for Antigravity, whose native-push
515
558
  // rail is intercepted BEFORE the mailbox mini-table and has no mailbox at all. A model
516
559
  // reading that would pick mailbox semantics for a citizen that has none.
@@ -555,27 +598,7 @@ async function main(): Promise<void> {
555
598
  ] as const) {
556
599
  ok(`4: pi-native — ${what} is isolated (non-vacuous)`, text.length > 120);
557
600
  ok(`4: pi-native — ${what} names the native-push rail`, /native-push/.test(text));
558
- // The two no-resume-authority rejects are DIFFERENT reasons: a native-push backend IS
559
- // probe-measured, so calling its reject `backend-liveness-unsupported` is the exact lie
560
- // `entwurf-v2-contract.ts:143` warns about in so many words.
561
- ok(
562
- `4: pi-native — ${what} separates the self-fetch and native-push owned rejects`,
563
- /backend-liveness-unsupported/.test(text) && /native-push-no-resume-authority/.test(text),
564
- );
565
- // Direct tripwire for the exact sentence that shipped: merging the two backends under
566
- // one reason. Presence-only pins cannot catch this — the merged claim can sit right
567
- // beside the correct literals.
568
- ok(
569
- `4: pi-native — ${what} never merges the two backends under one reject reason`,
570
- !/self-fetch and native-push alike/.test(text),
571
- );
572
- // Caller-intent steer (live-peer owned-outcome bug): owned-outcome is dormant-only and
573
- // NEVER auto-converted. Pinned PER STRING — the file-wide version of this check could
574
- // be satisfied by whichever description still carried it.
575
- ok(
576
- `4: pi-native — ${what} says owned-outcome is dormant-only + never auto-converted`,
577
- /DORMANT socket-domain citizen/.test(text) && /auto-converted/.test(text),
578
- );
601
+ assertDormantHonesty(`4: pi-native ${what}`, text);
579
602
  }
580
603
  ok("4: pi-native — long description denies native-push a mailbox", /NO mailbox/.test(piLongDesc));
581
604
  ok(
@@ -591,6 +614,20 @@ async function main(): Promise<void> {
591
614
  const piModeDesc = sliceDescription(piV2Block, "mode: Type.Optional(", "wants_reply:");
592
615
  assertRailSemantics("4: pi-native", piLongDesc, piIntentDesc, piModeDesc);
593
616
  assertDescriptionFitsHostCap("4: pi-native", piLongDesc);
617
+
618
+ // 4b: pi-native entwurf_peers. Asserted on THIS surface too, not only the MCP one: the two
619
+ // descriptions are read by different callers (a resident pi model vs a sibling reaching in
620
+ // over MCP), and a correction that lands on one of them is a correction half the garden
621
+ // never sees. Same real-boundary rule as every other slice here.
622
+ const piPeersStart = src.indexOf('name: "entwurf_peers"');
623
+ const piPeersEnd = src.indexOf("parameters:", piPeersStart + 1);
624
+ ok(
625
+ "4b: pi-native — entwurf_peers description block has a REAL end boundary",
626
+ piPeersStart !== -1 && piPeersEnd > piPeersStart,
627
+ );
628
+ const piPeersBlock = src.slice(piPeersStart, piPeersEnd);
629
+ ok("4b: pi-native — entwurf_peers description is isolated (non-vacuous)", piPeersBlock.length > 200);
630
+ assertPeersDeadRowHonesty("4b: pi-native", piPeersBlock);
594
631
  }
595
632
 
596
633
  // ── 5: MCP bridge wiring guard ────────────────────────────────────────────
@@ -652,20 +689,7 @@ async function main(): Promise<void> {
652
689
  ] as const) {
653
690
  ok(`5: MCP — ${what} is isolated (non-vacuous)`, text.length > 120);
654
691
  ok(`5: MCP — ${what} names the native-push rail`, /native-push/.test(text));
655
- ok(
656
- `5: MCP — ${what} separates the self-fetch and native-push owned rejects [QK:V2SURF-MERGED-REJECT]`,
657
- /backend-liveness-unsupported/.test(text) && /native-push-no-resume-authority/.test(text),
658
- );
659
- ok(
660
- `5: MCP — ${what} never merges the two backends under one reject reason`,
661
- !/self-fetch and native-push alike/.test(text),
662
- );
663
- // Same per-string caller-intent pin as block 4 (a sibling reaching in over MCP reads
664
- // THIS description, not the pi-native one).
665
- ok(
666
- `5: MCP — ${what} says owned-outcome is dormant-only + never auto-converted`,
667
- /DORMANT socket-domain citizen/.test(text) && /auto-converted/.test(text),
668
- );
692
+ assertDormantHonesty(`5: MCP — ${what}`, text);
669
693
  }
670
694
  ok("5: MCP — long description denies native-push a mailbox", /NO mailbox/.test(mcpLongDesc));
671
695
  ok(
@@ -697,6 +721,11 @@ async function main(): Promise<void> {
697
721
  "5: MCP — entwurf_peers no longer claims `unsupported` does NOT mean unreachable",
698
722
  !/does NOT mean unreachable/.test(peersBlock),
699
723
  );
724
+ // Visible-first cut, operator-surface half: a dormant citizen still gets a row here, and a
725
+ // caller who reads "it is in the list" as "I can dispatch to it" spends a dispatch to find
726
+ // out otherwise. The FACT stays a fact — no per-row action field, no routing verb, which is
727
+ // the rule this surface exists under — so the correction has to live in the description.
728
+ assertPeersDeadRowHonesty("5: MCP", peersBlock);
700
729
 
701
730
  // F-7: entwurf_inbox_read described itself as draining "your own" inbox while the handler
702
731
  // passes the CALLER-SUPPLIED gardenId straight to readMetaInbox with no comparison against