@junghanacs/entwurf 0.13.0 → 0.14.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 (193) hide show
  1. package/AGENTS.md +52 -17
  2. package/BASELINE.md +60 -206
  3. package/CHANGELOG.md +64 -0
  4. package/CONTRIBUTING.md +14 -10
  5. package/DELIVERY.md +118 -261
  6. package/README.md +91 -431
  7. package/VERIFY.md +74 -95
  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 +236 -0
  12. package/docs/external-mcp-host.md +131 -0
  13. package/docs/fresh-cut-policy.md +99 -0
  14. package/docs/setup-clean-host.md +124 -328
  15. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  38. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  39. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  40. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  41. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  42. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  43. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  44. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  45. package/mcp/entwurf-bridge/src/index.ts +176 -67
  46. package/mcp/entwurf-bridge/start.sh +2 -2
  47. package/mcp/entwurf-bridge/test.sh +23 -9
  48. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  49. package/package.json +25 -11
  50. package/pi-extensions/entwurf-control.ts +220 -71
  51. package/pi-extensions/lib/acp/acp-client.ts +22 -0
  52. package/pi-extensions/lib/acp/augment.ts +42 -3
  53. package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
  54. package/pi-extensions/lib/acp/backend.ts +366 -38
  55. package/pi-extensions/lib/acp/config.ts +1 -1
  56. package/pi-extensions/lib/acp/engraving.ts +43 -1
  57. package/pi-extensions/lib/acp/event-mapper.ts +16 -13
  58. package/pi-extensions/lib/acp/models.ts +4 -4
  59. package/pi-extensions/lib/acp/overlay.ts +11 -9
  60. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  61. package/pi-extensions/lib/entwurf-core.ts +15 -15
  62. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  63. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  64. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  65. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  66. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  67. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  68. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  69. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  70. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  71. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  72. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  73. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  74. package/pi-extensions/lib/meta-session.ts +93 -5
  75. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  76. package/pi-extensions/lib/mux-launch.ts +267 -0
  77. package/pi-extensions/lib/mux-placement.ts +387 -0
  78. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  79. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  80. package/pi-extensions/lib/session-id.js +8 -5
  81. package/pi-extensions/lib/socket-discovery.ts +3 -3
  82. package/prompts/engraving.md +13 -5
  83. package/run.sh +688 -282
  84. package/scripts/agy-bridge-config.py +5 -1
  85. package/scripts/check-acp-backend-preflight.ts +1 -1
  86. package/scripts/check-acp-carrier-augment.ts +246 -10
  87. package/scripts/check-acp-cortex.ts +5 -5
  88. package/scripts/check-acp-overlay.ts +13 -3
  89. package/scripts/check-acp-prompt-lifecycle.ts +565 -0
  90. package/scripts/check-acp-stop-reason.ts +342 -0
  91. package/scripts/check-acp-stream-hooks.ts +504 -0
  92. package/scripts/check-elapsed.sh +25 -0
  93. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  94. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  95. package/scripts/check-entwurf-control-rpc.ts +4 -3
  96. package/scripts/check-entwurf-resume-args.ts +72 -70
  97. package/scripts/check-entwurf-session-identity.ts +14 -10
  98. package/scripts/check-entwurf-v2-contract.ts +34 -59
  99. package/scripts/check-entwurf-v2-decider.ts +17 -177
  100. package/scripts/check-entwurf-v2-lock.ts +5 -2
  101. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  102. package/scripts/check-entwurf-v2-production.ts +2 -91
  103. package/scripts/check-entwurf-v2-release.ts +10 -105
  104. package/scripts/check-entwurf-v2-runner.ts +4 -85
  105. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  106. package/scripts/check-entwurf-v2-send.ts +0 -28
  107. package/scripts/check-entwurf-v2-surface.ts +198 -128
  108. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  109. package/scripts/check-fresh-cut-gate.sh +1 -1
  110. package/scripts/check-gate-qualification.ts +101 -7
  111. package/scripts/check-install-container.sh +10 -2
  112. package/scripts/check-install-surface.ts +1 -1
  113. package/scripts/check-keyset-overlap.py +1 -1
  114. package/scripts/check-meta-facts.ts +249 -0
  115. package/scripts/check-meta-identity-consumers.ts +1 -1
  116. package/scripts/check-meta-session.ts +169 -0
  117. package/scripts/check-mux-launch-tmux.ts +316 -0
  118. package/scripts/check-mux-launch.ts +288 -0
  119. package/scripts/check-mux-launcher-fence.ts +264 -0
  120. package/scripts/check-mux-parent-artifact.ts +195 -0
  121. package/scripts/check-mux-placement-tmux.ts +322 -0
  122. package/scripts/check-mux-placement.ts +323 -0
  123. package/scripts/check-mux-resume-call.ts +282 -0
  124. package/scripts/check-probe-cli-shim.ts +25 -22
  125. package/scripts/check-probe-ordering.ts +121 -81
  126. package/scripts/check-release-gate-outcomes.ts +452 -0
  127. package/scripts/check-resume-launch-identity.ts +244 -0
  128. package/scripts/check-socket-discovery.ts +1 -1
  129. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  130. package/scripts/inventory-verification-surface.ts +349 -0
  131. package/scripts/lib/claude-launcher-fence.ts +322 -0
  132. package/scripts/lib/live-skip.ts +33 -0
  133. package/scripts/lib/mutation-qualify.ts +109 -3
  134. package/scripts/lib/probe-acp-turn.ts +33 -12
  135. package/scripts/lib/step-outcome.sh +88 -0
  136. package/scripts/meta-bridge-doctor.sh +6 -8
  137. package/scripts/meta-facts.ts +60 -0
  138. package/scripts/mutants/acp-augment.json +106 -0
  139. package/scripts/mutants/acp-cortex.json +2 -2
  140. package/scripts/mutants/acp-overlay.json +17 -0
  141. package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
  142. package/scripts/mutants/acp-stop-reason.json +80 -0
  143. package/scripts/mutants/acp-stream-hooks.json +158 -0
  144. package/scripts/mutants/bridge-boot-resume.json +45 -0
  145. package/scripts/mutants/meta-facts.json +50 -0
  146. package/scripts/mutants/meta-identity.json +36 -0
  147. package/scripts/mutants/mux-boundary.json +196 -0
  148. package/scripts/mutants/mux-fresh-call.json +185 -0
  149. package/scripts/mutants/mux-launcher-fence.json +123 -0
  150. package/scripts/mutants/mux-parent-artifact.json +39 -0
  151. package/scripts/mutants/mux-resume-call.json +148 -0
  152. package/scripts/mutants/probe-ordering.json +6 -1021
  153. package/scripts/mutants/release-gate.json +140 -0
  154. package/scripts/mutants/resume-args.json +76 -0
  155. package/scripts/mutants/resume-launch-identity.json +96 -0
  156. package/scripts/mutants/v2-surface.json +74 -23
  157. package/scripts/mutants/v2-visible-resume.json +215 -0
  158. package/scripts/new-session-id.ts +9 -4
  159. package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
  160. package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
  161. package/scripts/smoke-acp-cortex-live.ts +8 -14
  162. package/scripts/smoke-acp-long-turn-live.ts +185 -0
  163. package/scripts/smoke-acp-mcp-live.ts +2 -2
  164. package/scripts/smoke-acp-memory-containment-live.ts +2 -2
  165. package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
  166. package/scripts/smoke-acp-overlay-live.ts +2 -2
  167. package/scripts/smoke-acp-provider-live.ts +2 -2
  168. package/scripts/smoke-acp-raw-turn-live.ts +3 -3
  169. package/scripts/smoke-acp-session-reuse-live.ts +2 -2
  170. package/scripts/smoke-acp-skill-live.ts +2 -2
  171. package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
  172. package/scripts/smoke-acp-v2-send-live.ts +4 -3
  173. package/scripts/smoke-agy-native-push-live.ts +10 -20
  174. package/scripts/smoke-claude-native-resume-live.sh +13 -3
  175. package/scripts/smoke-entwurf-chain-live.ts +352 -0
  176. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
  177. package/scripts/smoke-meta-honesty.sh +1 -1
  178. package/scripts/smoke-meta-install-state.sh +4 -0
  179. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  180. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  181. package/scripts/smoke-pi-attach.ts +1 -1
  182. package/scripts/smoke-user-scope-citizen.sh +1 -1
  183. package/scripts/tsconfig.json +1 -0
  184. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  185. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  186. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  187. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  188. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  189. package/scripts/check-acp-sdk-surface.ts +0 -236
  190. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  191. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  192. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  193. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
@@ -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
@@ -162,6 +222,45 @@ function assertRailSemantics(tag: string, longDescRaw: string, intentDescRaw: st
162
222
  * parameter marker in the long-description slice: if the slice ever swallows the schema
163
223
  * again, this fails instead of silently satisfying the schema's own text.
164
224
  */
225
+ /**
226
+ * HOST TRUNCATION CAP. Measured 2026-07-30 on both Claude ACP and stock Claude Code: a tool
227
+ * description longer than this is cut mid-sentence and the tail is replaced with `… [truncated]`.
228
+ * The shipped entwurf_v2 text was 4,022 chars, so everything from the INTENT contract onward —
229
+ * the reject taxonomy, the three-valued probe, the lock scope — never reached the model that had
230
+ * to choose an intent. Every other assertion in this file checks that a sentence is PRESENT in
231
+ * source; none of them can see that the host threw half of it away. This one can.
232
+ *
233
+ * Judged on `modelText` (the rendered string), not the source slice: pi-native wraps inside one
234
+ * template literal and the MCP surface `+`-concatenates, so only the normalized text has the
235
+ * length the host actually measures.
236
+ */
237
+ const HOST_DESCRIPTION_CAP = 2048;
238
+
239
+ /**
240
+ * The string the HOST measures, recovered from either surface's source scaffolding: drop the
241
+ * `description:` key and the outer template-literal / string-literal fence, and unescape `\"`.
242
+ * Counting the raw slice instead would charge the text for punctuation the model never sees —
243
+ * and the two surfaces carry DIFFERENT punctuation, so the cap would mean two different things.
244
+ */
245
+ function renderedDescription(raw: string): string {
246
+ const flat = modelText(raw);
247
+ // Anchor on the description's own first word rather than on scaffolding: the two surfaces put
248
+ // DIFFERENT preludes in front of it (the MCP slice starts at the tool name), and counting those
249
+ // would report a number the host never measures — a cap gate that cites the wrong length gets
250
+ // quoted as if it were the real one.
251
+ const from = flat.indexOf("CANONICAL");
252
+ return (from >= 0 ? flat.slice(from) : flat).replace(/[`"],?$/, "").replace(/\\"/g, '"');
253
+ }
254
+
255
+ function assertDescriptionFitsHostCap(tag: string, longDescRaw: string): void {
256
+ const rendered = renderedDescription(longDescRaw);
257
+ ok(
258
+ `${tag} — long description fits the host's ${HOST_DESCRIPTION_CAP}-char cap (${rendered.length}) ` +
259
+ "[QK:V2SURF-DESC-FITS-HOST-CAP]",
260
+ rendered.length <= HOST_DESCRIPTION_CAP,
261
+ );
262
+ }
263
+
165
264
  function assertLongDescExcludesParams(tag: string, longDesc: string, markers: readonly string[]): void {
166
265
  for (const marker of markers) {
167
266
  ok(`${tag} — long description slice excludes the \`${marker}\` parameter marker`, !longDesc.includes(marker));
@@ -188,7 +287,7 @@ async function main(): Promise<void> {
188
287
  {
189
288
  const full: SurfaceEntwurfV2Params = {
190
289
  target: GID,
191
- intent: "owned-outcome",
290
+ intent: "fire-and-forget",
192
291
  message: "hi",
193
292
  mode: "steer",
194
293
  wants_reply: true,
@@ -196,7 +295,7 @@ async function main(): Promise<void> {
196
295
  const di = toDispatchInput(full);
197
296
  ok(
198
297
  "1: target/intent/message pass through",
199
- di.target === GID && di.intent === "owned-outcome" && di.message === "hi",
298
+ di.target === GID && di.intent === "fire-and-forget" && di.message === "hi",
200
299
  );
201
300
  ok("1: wants_reply → wantsReply (snake→camel)", di.wantsReply === true);
202
301
  ok("1: mode passes through", di.mode === "steer");
@@ -262,41 +361,9 @@ async function main(): Promise<void> {
262
361
  cr.isError && cr.text.includes("dormant-fire-forget-unsupported"),
263
362
  );
264
363
 
265
- // spawn lock-retained fail-closed diagnostic
266
- const retained: EntwurfV2RunResult = {
267
- kind: "executed",
268
- receipt: { ...SUCCESS_RECEIPT, transport: "spawn-bg" },
269
- transport: "spawn-bg",
270
- outcome: {
271
- transport: "spawn-bg",
272
- result: {
273
- kind: "lock-retained",
274
- released: false,
275
- reason: "observe-failed",
276
- diagnostic: {
277
- targetGardenId: GID,
278
- lockPath: "/locks/x.lock",
279
- expectedSocketPath: "/ctl/x.sock",
280
- observeTimeoutMs: 30000,
281
- killGraceMs: 5000,
282
- },
283
- },
284
- },
285
- };
286
- const ret = renderEntwurfV2Result(retained);
287
- ok(
288
- "2: spawn lock-retained → isError + diagnostic surfaced",
289
- ret.isError && ret.text.includes("LOCK RETAINED") && ret.text.includes("/locks/x.lock"),
290
- );
291
-
292
- // spawn socket-alive → delivered
293
- const alive: EntwurfV2RunResult = {
294
- kind: "executed",
295
- receipt: { ...SUCCESS_RECEIPT, transport: "spawn-bg" },
296
- transport: "spawn-bg",
297
- outcome: { transport: "spawn-bg", result: { kind: "socket-alive", released: true, pid: 7 } },
298
- };
299
- 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.
300
367
 
301
368
  // meta-mailbox → enqueued
302
369
  const mailbox: EntwurfV2RunResult = {
@@ -334,17 +401,6 @@ async function main(): Promise<void> {
334
401
  !renderEntwurfV2Result(npRetried).isError && renderEntwurfV2Result(npRetried).text.includes("retry"),
335
402
  );
336
403
 
337
- // native-push owned reject → hint to switch to fire-and-forget
338
- const npReject: EntwurfV2RunResult = {
339
- kind: "rejected",
340
- receipt: { ok: false, reason: "native-push-no-resume-authority", observedLiveness: "alive" },
341
- };
342
- const npRej = renderEntwurfV2Result(npReject);
343
- ok(
344
- "2: native-push-no-resume-authority → isError + fire-and-forget hint",
345
- npRej.isError && npRej.text.includes("fire-and-forget"),
346
- );
347
-
348
404
  // N1: execution-failed with finalizedOutcome + releaseFailed → delivered-but-dirty
349
405
  const n1: EntwurfV2RunResult = {
350
406
  kind: "execution-failed",
@@ -371,28 +427,65 @@ async function main(): Promise<void> {
371
427
  };
372
428
  ok("2: plain execution-failed → isError", renderEntwurfV2Result(failed).isError);
373
429
 
374
- // Detour B (B-a): backend-liveness-unsupported reject still a reject (isError),
375
- // but the text carries the actionable "use fire-and-forget mailbox" hint. The
376
- // reject stays honest (reason unchanged, no auto-convert) only the render guides.
377
- 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 = {
378
436
  kind: "rejected",
379
- receipt: { ok: false, reason: "backend-liveness-unsupported", observedLiveness: "unsupported" },
437
+ receipt: { ok: false, reason: "dormant-fire-forget-unsupported", observedLiveness: "dead" },
380
438
  };
381
- const mr = renderEntwurfV2Result(metaReject);
439
+ const dr = renderEntwurfV2Result(dormantReject);
382
440
  ok(
383
- "2: backend-liveness-unsupported reject → isError + actionable fire-and-forget/mailbox hint",
384
- mr.isError &&
385
- mr.text.includes("backend-liveness-unsupported") &&
386
- mr.text.includes("fire-and-forget") &&
387
- mr.text.includes("mailbox"),
441
+ "2: dormant-fire-forget-unsupported reject → isError + reason surfaced",
442
+ dr.isError && dr.text.includes("dormant-fire-forget-unsupported"),
388
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") ?? "";
389
448
  ok(
390
- "2B: actionableRejectHint guides meta-session owned fire-and-forget mailbox",
391
- (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),
392
451
  );
393
452
  ok(
394
- "2B: actionableRejectHint guides owned-live fire-and-forget",
395
- (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),
396
489
  );
397
490
  ok(
398
491
  "2B: actionableRejectHint returns undefined for a reject with no next step",
@@ -411,22 +504,11 @@ async function main(): Promise<void> {
411
504
  );
412
505
  }
413
506
 
414
- // ── 6: parseEntwurfPrefixRootsEnv (5d-4b operator-policy SSOT) ─────────────
415
- {
416
- const D = path.delimiter;
417
- ok("6: env name is ENTWURF_PREFIX_ROOTS", ENTWURF_PREFIX_ROOTS_ENV === "ENTWURF_PREFIX_ROOTS");
418
- ok("6: undefined [] (no prefix promotion)", parseEntwurfPrefixRootsEnv(undefined).length === 0);
419
- ok("6: empty string → []", parseEntwurfPrefixRootsEnv("").length === 0);
420
- ok("6: delimiters-only → []", parseEntwurfPrefixRootsEnv(`${D}${D}`).length === 0);
421
- const two = parseEntwurfPrefixRootsEnv(`/repos/gh${D}/repos/work`);
422
- ok("6: delimiter-separated → entries", two.length === 2 && two[0] === "/repos/gh" && two[1] === "/repos/work");
423
- const trimmed = parseEntwurfPrefixRootsEnv(` /a ${D} ${D} /b `);
424
- ok("6: trims + drops empty segments", trimmed.length === 2 && trimmed[0] === "/a" && trimmed[1] === "/b");
425
- // A nonexistent/typo path is KEPT verbatim (no throw, no validation) — preflight's
426
- // normalize handles it, and a typo must never broaden approve nor fail the dispatch.
427
- const typo = parseEntwurfPrefixRootsEnv("/this/does/not/exist");
428
- ok("6: nonexistent path kept verbatim (no throw)", typo.length === 1 && typo[0] === "/this/does/not/exist");
429
- }
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.
430
512
 
431
513
  // ── 3: surface source guard — ctx-free ────────────────────────────────────
432
514
  {
@@ -471,7 +553,7 @@ async function main(): Promise<void> {
471
553
  !/import[^;]*from\s*"\.\/lib\/entwurf-self-address\.(js|ts)"/.test(code),
472
554
  );
473
555
  // Every SHIPPED rail must appear in the model-facing text. Measured 2026-07-27: both
474
- // 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
475
557
  // `unsupported` citizen is reached "→ mailbox" — false for Antigravity, whose native-push
476
558
  // rail is intercepted BEFORE the mailbox mini-table and has no mailbox at all. A model
477
559
  // reading that would pick mailbox semantics for a citizen that has none.
@@ -516,27 +598,7 @@ async function main(): Promise<void> {
516
598
  ] as const) {
517
599
  ok(`4: pi-native — ${what} is isolated (non-vacuous)`, text.length > 120);
518
600
  ok(`4: pi-native — ${what} names the native-push rail`, /native-push/.test(text));
519
- // The two no-resume-authority rejects are DIFFERENT reasons: a native-push backend IS
520
- // probe-measured, so calling its reject `backend-liveness-unsupported` is the exact lie
521
- // `entwurf-v2-contract.ts:143` warns about in so many words.
522
- ok(
523
- `4: pi-native — ${what} separates the self-fetch and native-push owned rejects`,
524
- /backend-liveness-unsupported/.test(text) && /native-push-no-resume-authority/.test(text),
525
- );
526
- // Direct tripwire for the exact sentence that shipped: merging the two backends under
527
- // one reason. Presence-only pins cannot catch this — the merged claim can sit right
528
- // beside the correct literals.
529
- ok(
530
- `4: pi-native — ${what} never merges the two backends under one reject reason`,
531
- !/self-fetch and native-push alike/.test(text),
532
- );
533
- // Caller-intent steer (live-peer owned-outcome bug): owned-outcome is dormant-only and
534
- // NEVER auto-converted. Pinned PER STRING — the file-wide version of this check could
535
- // be satisfied by whichever description still carried it.
536
- ok(
537
- `4: pi-native — ${what} says owned-outcome is dormant-only + never auto-converted`,
538
- /DORMANT socket-domain citizen/.test(text) && /auto-converted/.test(text),
539
- );
601
+ assertDormantHonesty(`4: pi-native ${what}`, text);
540
602
  }
541
603
  ok("4: pi-native — long description denies native-push a mailbox", /NO mailbox/.test(piLongDesc));
542
604
  ok(
@@ -551,6 +613,21 @@ async function main(): Promise<void> {
551
613
  ]);
552
614
  const piModeDesc = sliceDescription(piV2Block, "mode: Type.Optional(", "wants_reply:");
553
615
  assertRailSemantics("4: pi-native", piLongDesc, piIntentDesc, piModeDesc);
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);
554
631
  }
555
632
 
556
633
  // ── 5: MCP bridge wiring guard ────────────────────────────────────────────
@@ -612,20 +689,7 @@ async function main(): Promise<void> {
612
689
  ] as const) {
613
690
  ok(`5: MCP — ${what} is isolated (non-vacuous)`, text.length > 120);
614
691
  ok(`5: MCP — ${what} names the native-push rail`, /native-push/.test(text));
615
- ok(
616
- `5: MCP — ${what} separates the self-fetch and native-push owned rejects [QK:V2SURF-MERGED-REJECT]`,
617
- /backend-liveness-unsupported/.test(text) && /native-push-no-resume-authority/.test(text),
618
- );
619
- ok(
620
- `5: MCP — ${what} never merges the two backends under one reject reason`,
621
- !/self-fetch and native-push alike/.test(text),
622
- );
623
- // Same per-string caller-intent pin as block 4 (a sibling reaching in over MCP reads
624
- // THIS description, not the pi-native one).
625
- ok(
626
- `5: MCP — ${what} says owned-outcome is dormant-only + never auto-converted`,
627
- /DORMANT socket-domain citizen/.test(text) && /auto-converted/.test(text),
628
- );
692
+ assertDormantHonesty(`5: MCP — ${what}`, text);
629
693
  }
630
694
  ok("5: MCP — long description denies native-push a mailbox", /NO mailbox/.test(mcpLongDesc));
631
695
  ok(
@@ -635,6 +699,7 @@ async function main(): Promise<void> {
635
699
  assertLongDescExcludesParams("5: MCP", mcpLongDesc, ["target: z", "intent: z", "mode: z", "wants_reply: z"]);
636
700
  const mcpModeDesc = sliceDescription(v2Block, "mode: z", "wants_reply: z");
637
701
  assertRailSemantics("5: MCP", mcpLongDesc, mcpIntentDesc, mcpModeDesc);
702
+ assertDescriptionFitsHostCap("5: MCP", mcpLongDesc);
638
703
 
639
704
  // entwurf_peers is a FACT surface, but it still told the model what `unsupported` means —
640
705
  // and "does NOT mean unreachable" is false for a record whose backend has no adapter on
@@ -656,6 +721,11 @@ async function main(): Promise<void> {
656
721
  "5: MCP — entwurf_peers no longer claims `unsupported` does NOT mean unreachable",
657
722
  !/does NOT mean unreachable/.test(peersBlock),
658
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);
659
729
 
660
730
  // F-7: entwurf_inbox_read described itself as draining "your own" inbox while the handler
661
731
  // passes the CALLER-SUPPLIED gardenId straight to readMetaInbox with no comparison against