@junghanacs/entwurf 0.12.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 (183) hide show
  1. package/AGENTS.md +240 -0
  2. package/BASELINE.md +227 -0
  3. package/CHANGELOG.md +1210 -0
  4. package/CONTRIBUTING.md +63 -0
  5. package/DELIVERY.md +209 -0
  6. package/LICENSE +21 -0
  7. package/README.md +504 -0
  8. package/VERIFY.md +260 -0
  9. package/demo/README.md +188 -0
  10. package/demo/demo-baseline.sh +156 -0
  11. package/demo/demo.sh +183 -0
  12. package/docs/assets/entwurf-demo.gif +0 -0
  13. package/docs/assets/entwurf-doomemacs.gif +0 -0
  14. package/docs/assets/entwurf-entwurf.gif +0 -0
  15. package/docs/assets/entwurf-hero.jpg +0 -0
  16. package/docs/setup-clean-host.md +305 -0
  17. package/mcp/entwurf-bridge/src/index.ts +513 -0
  18. package/mcp/entwurf-bridge/start.sh +25 -0
  19. package/mcp/entwurf-bridge/test.sh +54 -0
  20. package/mcp/tsconfig.json +29 -0
  21. package/package.json +130 -0
  22. package/pi/entwurf-capabilities.json +9 -0
  23. package/pi/entwurf-targets.json +20 -0
  24. package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
  25. package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
  26. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
  27. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
  28. package/pi/settings.reference.json +43 -0
  29. package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
  30. package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
  31. package/pi-extensions/acp-provider.ts +63 -0
  32. package/pi-extensions/entwurf-control.ts +1692 -0
  33. package/pi-extensions/lib/acp/acp-client.ts +90 -0
  34. package/pi-extensions/lib/acp/augment.ts +238 -0
  35. package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
  36. package/pi-extensions/lib/acp/backend.ts +873 -0
  37. package/pi-extensions/lib/acp/config.ts +552 -0
  38. package/pi-extensions/lib/acp/context.ts +177 -0
  39. package/pi-extensions/lib/acp/engraving.ts +123 -0
  40. package/pi-extensions/lib/acp/event-mapper.ts +339 -0
  41. package/pi-extensions/lib/acp/models.ts +102 -0
  42. package/pi-extensions/lib/acp/overlay.ts +220 -0
  43. package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
  44. package/pi-extensions/lib/acp/session-store.ts +418 -0
  45. package/pi-extensions/lib/acp/tool-surface.ts +184 -0
  46. package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
  47. package/pi-extensions/lib/entwurf-core.ts +2033 -0
  48. package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
  49. package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
  50. package/pi-extensions/lib/entwurf-facts.ts +251 -0
  51. package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
  52. package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
  53. package/pi-extensions/lib/entwurf-preflight.ts +247 -0
  54. package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
  55. package/pi-extensions/lib/entwurf-self-address.ts +117 -0
  56. package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
  57. package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
  58. package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
  59. package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
  60. package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
  61. package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
  62. package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
  63. package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
  64. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
  65. package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
  66. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
  67. package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
  68. package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
  69. package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
  70. package/pi-extensions/lib/meta-session.ts +1951 -0
  71. package/pi-extensions/lib/project-trust-handler.ts +154 -0
  72. package/pi-extensions/lib/session-id.js +57 -0
  73. package/pi-extensions/lib/socket-discovery.ts +346 -0
  74. package/pi-extensions/lib/socket-probe.ts +92 -0
  75. package/pi-extensions/meta-bridge-hook.ts +250 -0
  76. package/pi-extensions/model-lock.ts +235 -0
  77. package/prompts/engraving.md +27 -0
  78. package/protocol.js +31 -0
  79. package/run.sh +2832 -0
  80. package/scripts/check-acp-backend-preflight.ts +130 -0
  81. package/scripts/check-acp-carrier-augment.ts +297 -0
  82. package/scripts/check-acp-config.ts +322 -0
  83. package/scripts/check-acp-event-mapper.ts +253 -0
  84. package/scripts/check-acp-overlay.ts +154 -0
  85. package/scripts/check-acp-prompt-builder.ts +191 -0
  86. package/scripts/check-acp-provider-surface.ts +159 -0
  87. package/scripts/check-acp-sdk-surface.ts +211 -0
  88. package/scripts/check-acp-session-reuse.ts +756 -0
  89. package/scripts/check-acp-session-store.ts +387 -0
  90. package/scripts/check-acp-tool-surface.ts +159 -0
  91. package/scripts/check-entwurf-bridge-boot.ts +199 -0
  92. package/scripts/check-entwurf-capabilities.ts +123 -0
  93. package/scripts/check-entwurf-control-rpc.ts +187 -0
  94. package/scripts/check-entwurf-deliverability.ts +158 -0
  95. package/scripts/check-entwurf-fact-provider.ts +316 -0
  96. package/scripts/check-entwurf-facts.ts +358 -0
  97. package/scripts/check-entwurf-mailbox-guard.ts +264 -0
  98. package/scripts/check-entwurf-peers-surface.ts +235 -0
  99. package/scripts/check-entwurf-resume-args.ts +149 -0
  100. package/scripts/check-entwurf-self-address.ts +209 -0
  101. package/scripts/check-entwurf-session-identity.ts +703 -0
  102. package/scripts/check-entwurf-v2-contract.ts +536 -0
  103. package/scripts/check-entwurf-v2-decider.ts +795 -0
  104. package/scripts/check-entwurf-v2-lock.ts +368 -0
  105. package/scripts/check-entwurf-v2-mailbox.ts +228 -0
  106. package/scripts/check-entwurf-v2-matrix.ts +437 -0
  107. package/scripts/check-entwurf-v2-production.ts +406 -0
  108. package/scripts/check-entwurf-v2-release.ts +245 -0
  109. package/scripts/check-entwurf-v2-runner.ts +452 -0
  110. package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
  111. package/scripts/check-entwurf-v2-send.ts +454 -0
  112. package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
  113. package/scripts/check-entwurf-v2-spawn.ts +398 -0
  114. package/scripts/check-entwurf-v2-surface.ts +352 -0
  115. package/scripts/check-keyset-overlap.py +120 -0
  116. package/scripts/check-mailbox-receipt-state.ts +170 -0
  117. package/scripts/check-meta-capability-source.ts +112 -0
  118. package/scripts/check-meta-dual-consumers.ts +154 -0
  119. package/scripts/check-meta-dual-read.ts +158 -0
  120. package/scripts/check-meta-listing.ts +138 -0
  121. package/scripts/check-meta-mailbox-state-write.ts +135 -0
  122. package/scripts/check-meta-migration.ts +212 -0
  123. package/scripts/check-meta-receiver-marker.ts +185 -0
  124. package/scripts/check-meta-record-v2.ts +191 -0
  125. package/scripts/check-meta-session.ts +673 -0
  126. package/scripts/check-model-lock.ts +408 -0
  127. package/scripts/check-package-source-routing.ts +253 -0
  128. package/scripts/check-pi-preflight.ts +304 -0
  129. package/scripts/check-project-trust-handler.ts +265 -0
  130. package/scripts/check-shell-quote.ts +121 -0
  131. package/scripts/check-socket-discovery.ts +428 -0
  132. package/scripts/check-socket-probe.ts +106 -0
  133. package/scripts/fixtures/probe-mcp-server.ts +33 -0
  134. package/scripts/gnew-rpc-drive.ts +211 -0
  135. package/scripts/lib/acp-child-cleanup.ts +116 -0
  136. package/scripts/meta-bridge-doctor.sh +315 -0
  137. package/scripts/meta-bridge-hook-log.sh +26 -0
  138. package/scripts/meta-bridge-install.sh +135 -0
  139. package/scripts/meta-bridge-prune.ts +199 -0
  140. package/scripts/meta-bridge-state.py +549 -0
  141. package/scripts/meta-bridge-statusline.sh +192 -0
  142. package/scripts/meta-bridge-store-doctor.ts +64 -0
  143. package/scripts/meta-bridge-uninstall.sh +39 -0
  144. package/scripts/new-session-id.ts +25 -0
  145. package/scripts/postinstall-chmod.cjs +58 -0
  146. package/scripts/raw-async-delivery/README.md +258 -0
  147. package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
  148. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
  149. package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
  150. package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
  151. package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
  152. package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
  153. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
  154. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
  155. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
  156. package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
  157. package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
  158. package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
  159. package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
  160. package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
  161. package/scripts/resolve-acp-bridge.ts +25 -0
  162. package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
  163. package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
  164. package/scripts/smoke-acp-mcp-live.ts +129 -0
  165. package/scripts/smoke-acp-memory-containment-live.ts +389 -0
  166. package/scripts/smoke-acp-overlay-live.ts +314 -0
  167. package/scripts/smoke-acp-provider-live.ts +162 -0
  168. package/scripts/smoke-acp-raw-turn-live.ts +261 -0
  169. package/scripts/smoke-acp-session-reuse-live.ts +172 -0
  170. package/scripts/smoke-acp-skill-live.ts +144 -0
  171. package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
  172. package/scripts/smoke-claude-native-resume-live.sh +198 -0
  173. package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
  174. package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
  175. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
  176. package/scripts/smoke-meta-async-drift.sh +171 -0
  177. package/scripts/smoke-meta-honesty.sh +147 -0
  178. package/scripts/smoke-meta-install-state.sh +403 -0
  179. package/scripts/smoke-meta-keyset-guard.sh +111 -0
  180. package/scripts/smoke-meta-prune.sh +174 -0
  181. package/scripts/smoke-resident-garden-guard.sh +433 -0
  182. package/scripts/smoke-session-id-name.ts +187 -0
  183. package/scripts/tsconfig.json +34 -0
@@ -0,0 +1,795 @@
1
+ /**
2
+ * check-entwurf-v2-decider — deterministic gate for the PURE dispatch decider
3
+ * (0.11 Stage 0 step 5b). Drives `decideDispatch` over INJECTED fakes (no IO) and
4
+ * proves the frozen invariants (design §5):
5
+ *
6
+ * 1. reject ⇒ NO plan AND NO lock retained (any acquired lock was released).
7
+ * 2. execute ⇒ plan present ∧ receipt.transport === plan.transport ∧
8
+ * (in-domain ⇒ lock non-null, mailbox ⇒ lock null).
9
+ * 3. pre-probe rejects (bad-target / target-locked / target-address-conflict)
10
+ * carry observedLiveness=null; untrusted-fail-fast carries the measured
11
+ * `dead` (non-null), and the deny path releases the lock (nonce-owned).
12
+ * 4. resume plan has NO mode/wantsReply, HAS expectedSocketPath/observeTimeoutMs/
13
+ * releaseWhen; meta-mailbox plan has NO mode.
14
+ * 5. control-socket execute lock non-null; meta-mailbox execute lock null (?7).
15
+ * 6. the unsupported (mailbox) path acquires NO lock (?7).
16
+ * 7. a pre-probe address conflict rejects WITHOUT probing (inspectSocket unused).
17
+ * 8. unsupported + fire-and-forget: deliverability comes from the REQUIRED
18
+ * mailboxDeliverabilityFor seam (active-receiver), NOT wake-mode alone. seam
19
+ * deliverable=true ⇒ meta-mailbox execute; false ⇒ mailbox-undeliverable reject —
20
+ * a self-fetch citizen with an INACTIVE receiver is refused (SE-2 2d-3), no plan,
21
+ * no lock, no probe; the seam is consulted exactly once on the resolved identity.
22
+ * 9. an invalid garden id throws BEFORE any path/lock is built (F2-P1).
23
+ * 10. no spawn-bg plan carries provider/model (D4: 5c-owned launch identity).
24
+ * 11. (B2) a throw AFTER the lock is acquired (inspect/probe/preflight) releases
25
+ * the held lock before the error propagates — no leak. A reject-path
26
+ * releaseLock that itself throws is RETRIED (the unlink never happened, so the
27
+ * lock is still ours) and the ORIGINAL error propagates — retry-pinned so a
28
+ * refactor cannot drop it.
29
+ * 12. (B3) a target-locked reject carries the lock's holder evidence as a
30
+ * diagnostic (pid/lockPath/detail), including the corrupt null-holder case;
31
+ * no other reject carries one.
32
+ *
33
+ * No IO — the target lookup, lock, socket inspection/probe, and preflight are all
34
+ * REQUIRED injected deps (the decider keeps NO live IO default); lock
35
+ * acquire/release calls are tracked so "no-lock-retained" is proven, not assumed.
36
+ */
37
+
38
+ import assert from "node:assert/strict";
39
+ import type { PreflightOutcome } from "../pi-extensions/lib/entwurf-preflight.ts";
40
+ import {
41
+ type DispatchDeciderDeps,
42
+ type DispatchDecision,
43
+ decideDispatch,
44
+ ENTWURF_V2_OBSERVE_TIMEOUT_MS,
45
+ type ExecutionPlan,
46
+ resolveMailboxWakeModeCapability,
47
+ type TargetResolution,
48
+ } from "../pi-extensions/lib/entwurf-v2-decider.ts";
49
+ import type { AcquireLockResult, LockClaim } from "../pi-extensions/lib/entwurf-v2-lock.ts";
50
+ import type { MetaBackendV2, MetaCapability, MetaIdentity } from "../pi-extensions/lib/meta-session.ts";
51
+ import { controlSocketPath, type TargetSocketInspection } from "../pi-extensions/lib/socket-discovery.ts";
52
+ import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
53
+
54
+ let passed = 0;
55
+ function ok(label: string, cond: boolean): void {
56
+ assert.ok(cond, label);
57
+ console.log(` ok ${label}`);
58
+ passed++;
59
+ }
60
+
61
+ const GID = "20260612T100000-aaaaaa";
62
+ const CWD = "/home/junghan/repos/gh/entwurf";
63
+
64
+ function identity(backend: MetaBackendV2): MetaIdentity {
65
+ return {
66
+ schemaVersion: 2,
67
+ gardenId: GID,
68
+ backend,
69
+ nativeSessionId: `native-${GID}`,
70
+ cwd: CWD,
71
+ model: null,
72
+ transcriptPath: null,
73
+ parentGardenId: null,
74
+ isEntwurf: false,
75
+ createdAt: "2026-06-12T01:00:00.000Z",
76
+ recordUpdatedAt: "2026-06-12T01:00:00.000Z",
77
+ };
78
+ }
79
+
80
+ function lockClaim(gardenId: string): LockClaim {
81
+ return {
82
+ gardenId,
83
+ pid: 4242,
84
+ hostname: "test-host",
85
+ createdAt: "2026-06-12T01:00:00.000Z",
86
+ nonce: "deadbeefcafef00d",
87
+ owner: "entwurf_v2",
88
+ lockPath: `/fake/locks/${gardenId}.lock`,
89
+ };
90
+ }
91
+
92
+ const APPROVE: PreflightOutcome = {
93
+ kind: "approve",
94
+ reason: "saved-true",
95
+ launchArgs: ["--approve"],
96
+ trustStoreDecision: true,
97
+ trustStoreInherited: false,
98
+ hasTrustInputs: true,
99
+ canonicalCwd: CWD,
100
+ };
101
+ const TRUSTED_NO_ARG: PreflightOutcome = {
102
+ kind: "trusted-no-arg",
103
+ reason: "no-trust-inputs",
104
+ launchArgs: [],
105
+ trustStoreDecision: null,
106
+ trustStoreInherited: false,
107
+ hasTrustInputs: false,
108
+ canonicalCwd: CWD,
109
+ };
110
+ const DENY: PreflightOutcome = {
111
+ kind: "deny",
112
+ reason: "fail-fast",
113
+ launchArgs: [],
114
+ trustStoreDecision: null,
115
+ trustStoreInherited: false,
116
+ hasTrustInputs: true,
117
+ canonicalCwd: CWD,
118
+ };
119
+
120
+ function capability(wakeMode: "self-fetch" | "direct-inject"): MetaCapability {
121
+ return { wakeMode, deliveryLevel: "D6", nativeIdLabel: "session" };
122
+ }
123
+
124
+ interface ScenarioOpts {
125
+ resolution: TargetResolution;
126
+ lock?: "ok" | "conflict";
127
+ inspection?: TargetSocketInspection;
128
+ probe?: SocketLiveness;
129
+ preflight?: PreflightOutcome;
130
+ /** SE-2 2d-3: the verdict the injected mailboxDeliverabilityFor seam returns on the
131
+ * unsupported path. Default false (fail-closed) — the decider trusts the seam, never
132
+ * wake-mode alone, so this is how a test asserts both the deliverable and inactive cells. */
133
+ mailboxDeliverable?: boolean;
134
+ }
135
+
136
+ interface Tracked {
137
+ deps: DispatchDeciderDeps;
138
+ acquireCalls: string[];
139
+ releaseCalls: LockClaim[];
140
+ inspectCalls: string[];
141
+ mailboxCalls: MetaIdentity[];
142
+ }
143
+
144
+ // Build injected deps with call tracking. A lock "conflict" returns a failed
145
+ // acquire; "ok" returns a fresh claim. If the unsupported path is exercised the
146
+ // acquireLock fake STILL records its (non-)use so the no-lock invariant is provable.
147
+ function mkDeps(opts: ScenarioOpts): Tracked {
148
+ const acquireCalls: string[] = [];
149
+ const releaseCalls: LockClaim[] = [];
150
+ const inspectCalls: string[] = [];
151
+ const mailboxCalls: MetaIdentity[] = [];
152
+ const deps: DispatchDeciderDeps = {
153
+ resolveTarget: () => opts.resolution,
154
+ acquireLock: (gardenId: string): AcquireLockResult => {
155
+ acquireCalls.push(gardenId);
156
+ if (opts.lock === "conflict") {
157
+ return {
158
+ ok: false,
159
+ conflict: {
160
+ reason: "target-locked",
161
+ lockPath: `/fake/locks/${gardenId}.lock`,
162
+ holder: lockClaim(gardenId),
163
+ detail: "held by a live dispatcher",
164
+ },
165
+ };
166
+ }
167
+ return { ok: true, claim: lockClaim(gardenId) };
168
+ },
169
+ releaseLock: (claim: LockClaim) => {
170
+ releaseCalls.push(claim);
171
+ },
172
+ inspectSocket: async (gardenId: string): Promise<TargetSocketInspection> => {
173
+ inspectCalls.push(gardenId);
174
+ return opts.inspection ?? { kind: "absent", socketPath: controlSocketPath(gardenId, "/fake/ctl") };
175
+ },
176
+ probeSocket: async (): Promise<SocketLiveness> => opts.probe ?? "dead",
177
+ preflightForCwd: () => opts.preflight ?? APPROVE,
178
+ mailboxDeliverabilityFor: (identity: MetaIdentity) => {
179
+ mailboxCalls.push(identity);
180
+ return { deliverable: opts.mailboxDeliverable ?? false, reason: "fake-deliverability" };
181
+ },
182
+ mailboxDir: "/fake/mailbox",
183
+ sessionsDir: "/fake/sessions",
184
+ };
185
+ return { deps, acquireCalls, releaseCalls, inspectCalls, mailboxCalls };
186
+ }
187
+
188
+ function isExecute(d: DispatchDecision): d is Extract<DispatchDecision, { kind: "execute" }> {
189
+ return d.kind === "execute";
190
+ }
191
+ function planKeys(plan: ExecutionPlan): string[] {
192
+ return Object.keys(plan).sort();
193
+ }
194
+
195
+ async function main(): Promise<void> {
196
+ // ── 1+3: bad-target — no citizen → reject, observedLiveness null, NO lock ────
197
+ {
198
+ const t = mkDeps({ resolution: { identity: null, preProbeAddressConflict: false } });
199
+ const d = await decideDispatch({ target: GID, intent: "owned-outcome", message: "hi" }, t.deps);
200
+ ok("bad-target: reject", d.kind === "reject");
201
+ ok("bad-target: no plan field", !("plan" in d));
202
+ ok("bad-target: observedLiveness null (pre-probe)", d.kind === "reject" && d.receipt.observedLiveness === null);
203
+ ok("bad-target: reason", d.kind === "reject" && d.receipt.reason === "bad-target");
204
+ ok("bad-target: no lock acquired", t.acquireCalls.length === 0);
205
+ ok("bad-target: no diagnostic (only target-locked carries one)", d.kind === "reject" && d.diagnostic === undefined);
206
+ }
207
+
208
+ // ── 7: pre-probe address conflict — reject WITHOUT probing or locking ────────
209
+ {
210
+ const t = mkDeps({ resolution: { identity: identity("claude-code"), preProbeAddressConflict: true } });
211
+ const d = await decideDispatch({ target: GID, intent: "fire-and-forget", message: "hi" }, t.deps);
212
+ ok(
213
+ "preprobe-conflict: reject target-address-conflict",
214
+ d.kind === "reject" && d.receipt.reason === "target-address-conflict",
215
+ );
216
+ ok("preprobe-conflict: observedLiveness null", d.kind === "reject" && d.receipt.observedLiveness === null);
217
+ ok("preprobe-conflict: NOT probed (inspectSocket unused)", t.inspectCalls.length === 0);
218
+ ok("preprobe-conflict: no lock acquired", t.acquireCalls.length === 0);
219
+ }
220
+
221
+ // ── target-locked: acquire conflict → reject, null, nothing to release ───────
222
+ {
223
+ const t = mkDeps({ resolution: { identity: identity("pi"), preProbeAddressConflict: false }, lock: "conflict" });
224
+ const d = await decideDispatch({ target: GID, intent: "owned-outcome", message: "hi" }, t.deps);
225
+ ok("target-locked: reject", d.kind === "reject" && d.receipt.reason === "target-locked");
226
+ ok("target-locked: observedLiveness null (pre-probe)", d.kind === "reject" && d.receipt.observedLiveness === null);
227
+ ok("target-locked: lock acquire attempted", t.acquireCalls.length === 1);
228
+ ok("target-locked: no release (acquire failed → nothing held)", t.releaseCalls.length === 0);
229
+ ok("target-locked: not probed (lock before probe)", t.inspectCalls.length === 0);
230
+ // B3: the holder evidence the lock produced must survive to the decision.
231
+ ok("target-locked: diagnostic carried", d.kind === "reject" && d.diagnostic?.kind === "target-locked");
232
+ ok(
233
+ "target-locked: diagnostic holder pid preserved",
234
+ d.kind === "reject" && d.diagnostic?.conflict.holder?.pid === 4242,
235
+ );
236
+ ok(
237
+ "target-locked: diagnostic lockPath preserved",
238
+ d.kind === "reject" && d.diagnostic?.conflict.lockPath === `/fake/locks/${GID}.lock`,
239
+ );
240
+ }
241
+
242
+ // ── B3: target-locked with a corrupt (null holder) lockfile still carries the ─
243
+ // diagnostic (lockPath + detail) — a human needs the path even when the body is
244
+ // empty/corrupt and there is no pid to show.
245
+ {
246
+ const d = await decideDispatch(
247
+ { target: GID, intent: "owned-outcome", message: "hi" },
248
+ {
249
+ resolveTarget: () => ({ identity: identity("pi"), preProbeAddressConflict: false }),
250
+ acquireLock: () => ({
251
+ ok: false,
252
+ conflict: {
253
+ reason: "target-locked",
254
+ lockPath: `/fake/locks/${GID}.lock`,
255
+ holder: null,
256
+ detail: "lockfile is empty, corrupt, or holds a different garden id",
257
+ },
258
+ }),
259
+ releaseLock: () => {},
260
+ inspectSocket: async () => {
261
+ throw new Error("target-locked(corrupt): must not probe after a lock conflict");
262
+ },
263
+ probeSocket: async () => "dead",
264
+ preflightForCwd: () => APPROVE,
265
+ mailboxDeliverabilityFor: () => {
266
+ throw new Error("target-locked(corrupt): in-domain pi never consults the mailbox seam");
267
+ },
268
+ },
269
+ );
270
+ ok("target-locked(corrupt): reject", d.kind === "reject" && d.receipt.reason === "target-locked");
271
+ ok(
272
+ "target-locked(corrupt): diagnostic carried with null holder",
273
+ d.kind === "reject" && d.diagnostic?.kind === "target-locked" && d.diagnostic.conflict.holder === null,
274
+ );
275
+ ok(
276
+ "target-locked(corrupt): diagnostic detail preserved",
277
+ d.kind === "reject" &&
278
+ d.diagnostic?.conflict.detail === "lockfile is empty, corrupt, or holds a different garden id",
279
+ );
280
+ }
281
+
282
+ // ── post-lock address conflict (pi + symlinked own socket) → release+reject ──
283
+ {
284
+ const t = mkDeps({
285
+ resolution: { identity: identity("pi"), preProbeAddressConflict: false },
286
+ lock: "ok",
287
+ inspection: { kind: "address-conflict", socketPath: "/fake/ctl/s.sock", reason: "symlink" },
288
+ });
289
+ const d = await decideDispatch({ target: GID, intent: "owned-outcome", message: "hi" }, t.deps);
290
+ ok(
291
+ "postlock-conflict: reject target-address-conflict",
292
+ d.kind === "reject" && d.receipt.reason === "target-address-conflict",
293
+ );
294
+ ok(
295
+ "postlock-conflict: observedLiveness null (pre-probe reason)",
296
+ d.kind === "reject" && d.receipt.observedLiveness === null,
297
+ );
298
+ ok("postlock-conflict: lock acquired", t.acquireCalls.length === 1);
299
+ ok("postlock-conflict: lock RELEASED (no-lock-retained)", t.releaseCalls.length === 1);
300
+ }
301
+
302
+ // ── owned-outcome + LIVE → owned-live-no-autosend, release ───────────────────
303
+ {
304
+ const t = mkDeps({
305
+ resolution: { identity: identity("pi"), preProbeAddressConflict: false },
306
+ lock: "ok",
307
+ inspection: { kind: "socket-file", socketPath: "/fake/ctl/s.sock" },
308
+ probe: "alive",
309
+ });
310
+ const d = await decideDispatch({ target: GID, intent: "owned-outcome", message: "hi" }, t.deps);
311
+ ok(
312
+ "owned-live: reject owned-live-no-autosend",
313
+ d.kind === "reject" && d.receipt.reason === "owned-live-no-autosend",
314
+ );
315
+ ok(
316
+ "owned-live: observedLiveness alive (post-probe, non-null)",
317
+ d.kind === "reject" && d.receipt.observedLiveness === "alive",
318
+ );
319
+ ok("owned-live: lock released", t.releaseCalls.length === 1);
320
+ ok("owned-live: no plan", !("plan" in d));
321
+ }
322
+
323
+ // ── indeterminate → indeterminate-no-spawn, release ──────────────────────────
324
+ {
325
+ const t = mkDeps({
326
+ resolution: { identity: identity("pi"), preProbeAddressConflict: false },
327
+ lock: "ok",
328
+ inspection: { kind: "indeterminate", socketPath: "/fake/ctl/s.sock", error: "EACCES" },
329
+ });
330
+ const d = await decideDispatch({ target: GID, intent: "owned-outcome", message: "hi" }, t.deps);
331
+ ok(
332
+ "indeterminate: reject indeterminate-no-spawn",
333
+ d.kind === "reject" && d.receipt.reason === "indeterminate-no-spawn",
334
+ );
335
+ ok(
336
+ "indeterminate: observedLiveness indeterminate (non-null)",
337
+ d.kind === "reject" && d.receipt.observedLiveness === "indeterminate",
338
+ );
339
+ ok("indeterminate: lock released", t.releaseCalls.length === 1);
340
+ }
341
+
342
+ // ── fire-and-forget + DORMANT pi → dormant-fire-forget-unsupported, release ──
343
+ {
344
+ const t = mkDeps({
345
+ resolution: { identity: identity("pi"), preProbeAddressConflict: false },
346
+ lock: "ok",
347
+ inspection: { kind: "absent", socketPath: "/fake/ctl/s.sock" },
348
+ });
349
+ const d = await decideDispatch({ target: GID, intent: "fire-and-forget", message: "hi" }, t.deps);
350
+ ok(
351
+ "ff-dormant: reject dormant-fire-forget-unsupported",
352
+ d.kind === "reject" && d.receipt.reason === "dormant-fire-forget-unsupported",
353
+ );
354
+ ok("ff-dormant: observedLiveness dead (non-null)", d.kind === "reject" && d.receipt.observedLiveness === "dead");
355
+ ok("ff-dormant: lock released", t.releaseCalls.length === 1);
356
+ }
357
+
358
+ // ── 2+4+5: control-socket SEND execute (ff + live) — lock RETAINED ───────────
359
+ {
360
+ const t = mkDeps({
361
+ resolution: { identity: identity("pi"), preProbeAddressConflict: false },
362
+ lock: "ok",
363
+ inspection: { kind: "socket-file", socketPath: "/fake/ctl/s.sock" },
364
+ probe: "alive",
365
+ });
366
+ const d = await decideDispatch(
367
+ { target: GID, intent: "fire-and-forget", mode: "steer", wantsReply: true, message: "ping" },
368
+ t.deps,
369
+ );
370
+ ok("send-execute: kind execute", isExecute(d));
371
+ if (isExecute(d)) {
372
+ ok("send-execute: plan transport control-socket", d.plan.transport === "control-socket");
373
+ ok("send-execute: receipt.transport === plan.transport", d.receipt.transport === d.plan.transport);
374
+ ok("send-execute: lock RETAINED (non-null)", d.lock !== null);
375
+ ok("send-execute: NOT released", t.releaseCalls.length === 0);
376
+ ok("send-execute: observedLiveness alive", d.receipt.observedLiveness === "alive");
377
+ if (d.plan.transport === "control-socket") {
378
+ ok("send-execute: socketPath planted", d.plan.socketPath === "/fake/ctl/s.sock");
379
+ ok("send-execute: mode carried", d.plan.mode === "steer");
380
+ ok("send-execute: wantsReply carried", d.plan.wantsReply === true);
381
+ assert.deepStrictEqual(
382
+ planKeys(d.plan),
383
+ ["action", "message", "mode", "socketPath", "targetGardenId", "transport", "wantsReply"],
384
+ `control-socket plan keyset drift: ${planKeys(d.plan).join(",")}`,
385
+ );
386
+ ok("send-execute: plan keyset exact (no provider/model)", true);
387
+ }
388
+ }
389
+ }
390
+
391
+ // ── 2+4+10: spawn-bg RESUME execute (owned + dormant + preflight allow) ──────
392
+ for (const pf of [APPROVE, TRUSTED_NO_ARG]) {
393
+ const t = mkDeps({
394
+ resolution: { identity: identity("pi"), preProbeAddressConflict: false },
395
+ lock: "ok",
396
+ inspection: { kind: "absent", socketPath: "/fake/ctl/expected.sock" },
397
+ preflight: pf,
398
+ });
399
+ const d = await decideDispatch(
400
+ { target: GID, intent: "owned-outcome", mode: "steer", wantsReply: true, message: "do X" },
401
+ t.deps,
402
+ );
403
+ ok(`resume-execute(${pf.kind}): kind execute`, isExecute(d));
404
+ if (isExecute(d) && d.plan.transport === "spawn-bg") {
405
+ ok(`resume-execute(${pf.kind}): receipt.transport === plan.transport`, d.receipt.transport === "spawn-bg");
406
+ ok(`resume-execute(${pf.kind}): lock RETAINED`, d.lock !== null);
407
+ ok(`resume-execute(${pf.kind}): NOT released`, t.releaseCalls.length === 0);
408
+ ok(`resume-execute(${pf.kind}): sessionId === gardenId (D3)`, d.plan.sessionId === GID);
409
+ ok(`resume-execute(${pf.kind}): prompt = message`, d.plan.prompt === "do X");
410
+ ok(`resume-execute(${pf.kind}): launchArgs from preflight`, d.plan.launchArgs === pf.launchArgs);
411
+ ok(
412
+ `resume-execute(${pf.kind}): expectedSocketPath planted`,
413
+ d.plan.expectedSocketPath === "/fake/ctl/expected.sock",
414
+ );
415
+ ok(
416
+ `resume-execute(${pf.kind}): observeTimeoutMs default`,
417
+ d.plan.observeTimeoutMs === ENTWURF_V2_OBSERVE_TIMEOUT_MS,
418
+ );
419
+ ok(`resume-execute(${pf.kind}): releaseWhen A2 predicate`, d.plan.releaseWhen === "socket-alive-or-child-exited");
420
+ assert.deepStrictEqual(
421
+ planKeys(d.plan),
422
+ [
423
+ "action",
424
+ "cwd",
425
+ "expectedSocketPath",
426
+ "launchArgs",
427
+ "observeTimeoutMs",
428
+ "prompt",
429
+ "releaseWhen",
430
+ "sessionId",
431
+ "targetGardenId",
432
+ "transport",
433
+ ],
434
+ `spawn-bg plan keyset drift: ${planKeys(d.plan).join(",")}`,
435
+ );
436
+ ok(`resume-execute(${pf.kind}): NO mode/wantsReply/provider/model in plan`, true);
437
+ }
438
+ }
439
+
440
+ // ── 1+3: untrusted-fail-fast — resume verdict + preflight DENY → release ─────
441
+ {
442
+ const t = mkDeps({
443
+ resolution: { identity: identity("pi"), preProbeAddressConflict: false },
444
+ lock: "ok",
445
+ inspection: { kind: "absent", socketPath: "/fake/ctl/s.sock" },
446
+ preflight: DENY,
447
+ });
448
+ const d = await decideDispatch({ target: GID, intent: "owned-outcome", message: "do X" }, t.deps);
449
+ ok("untrusted: reject untrusted-fail-fast", d.kind === "reject" && d.receipt.reason === "untrusted-fail-fast");
450
+ ok(
451
+ "untrusted: observedLiveness dead (measured, non-null)",
452
+ d.kind === "reject" && d.receipt.observedLiveness === "dead",
453
+ );
454
+ ok("untrusted: lock RELEASED (nonce-owned, no-lock-retained)", t.releaseCalls.length === 1);
455
+ ok("untrusted: no plan", !("plan" in d));
456
+ }
457
+
458
+ // ── 2+5+6: meta-mailbox SEND execute (unsupported claude + ff + deliverable) ─
459
+ // Deliverability comes from the required seam (active receiver), NOT wake-mode alone.
460
+ {
461
+ const t = mkDeps({
462
+ resolution: { identity: identity("claude-code"), preProbeAddressConflict: false },
463
+ mailboxDeliverable: true,
464
+ });
465
+ const d = await decideDispatch(
466
+ { target: GID, intent: "fire-and-forget", mode: "steer", wantsReply: true, message: "mail" },
467
+ t.deps,
468
+ );
469
+ ok("mailbox-execute: kind execute", isExecute(d));
470
+ ok("mailbox-execute: deliverability seam consulted exactly once", t.mailboxCalls.length === 1);
471
+ ok("mailbox-execute: seam asked about the resolved identity", t.mailboxCalls[0]?.gardenId === GID);
472
+ if (isExecute(d) && d.plan.transport === "meta-mailbox") {
473
+ ok("mailbox-execute: receipt.transport === plan.transport", d.receipt.transport === "meta-mailbox");
474
+ ok("mailbox-execute: lock NULL (?7 no lock)", d.lock === null);
475
+ ok("mailbox-execute: acquireLock NOT called", t.acquireCalls.length === 0);
476
+ ok("mailbox-execute: observedLiveness unsupported", d.receipt.observedLiveness === "unsupported");
477
+ ok("mailbox-execute: mailboxDir planted", d.plan.mailboxDir === "/fake/mailbox");
478
+ ok("mailbox-execute: sessionsDir planted (D2)", d.plan.sessionsDir === "/fake/sessions");
479
+ assert.deepStrictEqual(
480
+ planKeys(d.plan),
481
+ ["action", "mailboxDir", "message", "sessionsDir", "targetGardenId", "transport", "wantsReply"],
482
+ `meta-mailbox plan keyset drift: ${planKeys(d.plan).join(",")}`,
483
+ );
484
+ ok("mailbox-execute: NO mode in plan (?2)", !("mode" in d.plan));
485
+ }
486
+ }
487
+
488
+ // ── 8: unsupported + ff + seam says undeliverable → reject, no plan, no lock ─
489
+ {
490
+ const t = mkDeps({
491
+ resolution: { identity: identity("codex"), preProbeAddressConflict: false },
492
+ mailboxDeliverable: false,
493
+ });
494
+ const d = await decideDispatch({ target: GID, intent: "fire-and-forget", message: "mail" }, t.deps);
495
+ ok("mailbox-undeliverable: reject", d.kind === "reject" && d.receipt.reason === "mailbox-undeliverable");
496
+ ok(
497
+ "mailbox-undeliverable: observedLiveness unsupported (non-null)",
498
+ d.kind === "reject" && d.receipt.observedLiveness === "unsupported",
499
+ );
500
+ ok("mailbox-undeliverable: no plan", !("plan" in d));
501
+ ok("mailbox-undeliverable: acquireLock NOT called", t.acquireCalls.length === 0);
502
+ }
503
+
504
+ // ── SE-2 2d-3 KEY ROW: a self-fetch CITIZEN whose receiver is INACTIVE (the seam
505
+ // says not deliverable — terminated session / drifted marker) → mailbox-undeliverable
506
+ // reject, NO plan, NO lock, NO inspect/probe. This is the v2 closure of the gap slice
507
+ // 2d-2 closed for v1: the decider no longer trusts wake-mode alone; the required seam's
508
+ // active-receiver verdict governs, so a reply to a dead claude-code is refused, not
509
+ // enqueued as mailbox garbage. (Indistinguishable here from the codex case at the
510
+ // receipt level — that is the point: the seam, not the backend, decides.) ────────────
511
+ {
512
+ const t = mkDeps({
513
+ resolution: { identity: identity("claude-code"), preProbeAddressConflict: false },
514
+ mailboxDeliverable: false,
515
+ });
516
+ const d = await decideDispatch({ target: GID, intent: "fire-and-forget", message: "mail" }, t.deps);
517
+ ok(
518
+ "se2-inactive: reject mailbox-undeliverable",
519
+ d.kind === "reject" && d.receipt.reason === "mailbox-undeliverable",
520
+ );
521
+ ok("se2-inactive: no plan (no enqueue plan minted)", !("plan" in d));
522
+ ok("se2-inactive: acquireLock NOT called (?7)", t.acquireCalls.length === 0);
523
+ ok("se2-inactive: never inspect/probe (unsupported axis)", t.inspectCalls.length === 0);
524
+ ok("se2-inactive: deliverability seam consulted exactly once", t.mailboxCalls.length === 1);
525
+ }
526
+
527
+ // ── unsupported + owned-outcome → backend-liveness-unsupported, no lock ──────
528
+ {
529
+ const t = mkDeps({
530
+ resolution: { identity: identity("claude-code"), preProbeAddressConflict: false },
531
+ mailboxDeliverable: true,
532
+ });
533
+ const d = await decideDispatch({ target: GID, intent: "owned-outcome", message: "x" }, t.deps);
534
+ ok(
535
+ "unsupported-owned: reject backend-liveness-unsupported",
536
+ d.kind === "reject" && d.receipt.reason === "backend-liveness-unsupported",
537
+ );
538
+ ok("unsupported-owned: acquireLock NOT called", t.acquireCalls.length === 0);
539
+ }
540
+
541
+ // ── A1 narrow (0.11.0): socket-only pi endpoint (identity null + socketOnlyPi) ──
542
+ // A record-LESS live pi control socket is a REAL, addressable citizen — every intent runs
543
+ // the SAME in-domain probe table under allowResume:false (NO pre-probe short-circuit).
544
+ // fire-and-forget + alive → control-socket execute (lock acquired + inspected, retained);
545
+ // owned-outcome + alive → owned-live-no-autosend (honest table verdict, NOT the `bad-target`
546
+ // lie — a live citizen is never "absent"); owned-outcome + dormant →
547
+ // socket-only-no-resume-authority (the allowResume:false guard, post-probe, never a spawn);
548
+ // fire-and-forget + dormant/indeterminate → the existing honest reject, lock released.
549
+ const socketOnly: TargetResolution = { identity: null, preProbeAddressConflict: false, socketOnlyPi: true };
550
+ {
551
+ const t = mkDeps({
552
+ resolution: socketOnly,
553
+ lock: "ok",
554
+ inspection: { kind: "socket-file", socketPath: "/fake/ctl/s.sock" },
555
+ probe: "alive",
556
+ });
557
+ const d = await decideDispatch(
558
+ { target: GID, intent: "fire-and-forget", mode: "follow_up", wantsReply: false, message: "ping" },
559
+ t.deps,
560
+ );
561
+ ok("socketOnly ff+alive: kind execute", isExecute(d));
562
+ ok("socketOnly ff+alive: plan transport control-socket", isExecute(d) && d.plan.transport === "control-socket");
563
+ ok("socketOnly ff+alive: lock RETAINED", isExecute(d) && d.lock !== null);
564
+ ok("socketOnly ff+alive: NOT released", t.releaseCalls.length === 0);
565
+ ok("socketOnly ff+alive: observedLiveness alive", isExecute(d) && d.receipt.observedLiveness === "alive");
566
+ ok("socketOnly ff+alive: lock acquired (in-domain)", t.acquireCalls.length === 1);
567
+ ok("socketOnly ff+alive: socket inspected under lock", t.inspectCalls.length === 1);
568
+ ok("socketOnly ff+alive: mailbox seam NEVER consulted (pi is in-domain)", t.mailboxCalls.length === 0);
569
+ }
570
+ {
571
+ // (a) owned-outcome + ALIVE → owned-live-no-autosend (the honest table verdict), NOT
572
+ // the `bad-target` lie. The live socket-only citizen runs the SAME in-domain path as a
573
+ // record-backed pi: lock acquired, socket probed, then the table rejects owned×live and
574
+ // the lock is released. observedLiveness is the measured `alive` (post-probe, non-null).
575
+ const t = mkDeps({
576
+ resolution: socketOnly,
577
+ lock: "ok",
578
+ inspection: { kind: "socket-file", socketPath: "/fake/ctl/s.sock" },
579
+ probe: "alive",
580
+ });
581
+ const d = await decideDispatch({ target: GID, intent: "owned-outcome", message: "do X" }, t.deps);
582
+ ok(
583
+ "socketOnly owned+alive: reject owned-live-no-autosend (NOT bad-target)",
584
+ d.kind === "reject" && d.receipt.reason === "owned-live-no-autosend",
585
+ );
586
+ ok(
587
+ "socketOnly owned+alive: observedLiveness alive (post-probe, non-null)",
588
+ d.kind === "reject" && d.receipt.observedLiveness === "alive",
589
+ );
590
+ ok("socketOnly owned+alive: lock acquired (in-domain, no pre-lock lie)", t.acquireCalls.length === 1);
591
+ ok("socketOnly owned+alive: socket probed under lock", t.inspectCalls.length === 1);
592
+ ok("socketOnly owned+alive: lock released", t.releaseCalls.length === 1);
593
+ ok("socketOnly owned+alive: no plan (no execute)", !("plan" in d));
594
+ }
595
+ {
596
+ // (c) owned-outcome + DORMANT → socket-only-no-resume-authority. The probe ran
597
+ // (post-probe), the table yields a resume verdict, and the allowResume:false guard
598
+ // refuses it — a record-less endpoint has no trusted cwd/resume authority, so spawn-bg
599
+ // never opens. observedLiveness is the measured `dead`, NOT the pre-probe `bad-target`
600
+ // null lie. Lock acquired then released; no spawn plan.
601
+ const t = mkDeps({
602
+ resolution: socketOnly,
603
+ lock: "ok",
604
+ inspection: { kind: "absent", socketPath: "/fake/ctl/s.sock" },
605
+ });
606
+ const d = await decideDispatch({ target: GID, intent: "owned-outcome", message: "do X" }, t.deps);
607
+ ok(
608
+ "socketOnly owned+dormant: reject socket-only-no-resume-authority",
609
+ d.kind === "reject" && d.receipt.reason === "socket-only-no-resume-authority",
610
+ );
611
+ ok(
612
+ "socketOnly owned+dormant: observedLiveness dead (post-probe, non-null)",
613
+ d.kind === "reject" && d.receipt.observedLiveness === "dead",
614
+ );
615
+ ok("socketOnly owned+dormant: lock acquired (in-domain)", t.acquireCalls.length === 1);
616
+ ok("socketOnly owned+dormant: socket probed under lock", t.inspectCalls.length === 1);
617
+ ok("socketOnly owned+dormant: lock released", t.releaseCalls.length === 1);
618
+ ok("socketOnly owned+dormant: NO spawn plan", !("plan" in d));
619
+ }
620
+ {
621
+ // fire-and-forget + dormant (socket vanished between presence-hint and the under-lock
622
+ // probe) → the existing honest reject, lock released, NEVER promoted to a resume/spawn.
623
+ const t = mkDeps({
624
+ resolution: socketOnly,
625
+ lock: "ok",
626
+ inspection: { kind: "absent", socketPath: "/fake/ctl/s.sock" },
627
+ });
628
+ const d = await decideDispatch({ target: GID, intent: "fire-and-forget", message: "ping" }, t.deps);
629
+ ok(
630
+ "socketOnly ff+dormant: reject dormant-fire-forget-unsupported",
631
+ d.kind === "reject" && d.receipt.reason === "dormant-fire-forget-unsupported",
632
+ );
633
+ ok("socketOnly ff+dormant: lock released", t.releaseCalls.length === 1);
634
+ ok("socketOnly ff+dormant: no plan (no spawn)", !("plan" in d));
635
+ }
636
+ {
637
+ // fire-and-forget + indeterminate → indeterminate-no-spawn, lock released.
638
+ const t = mkDeps({
639
+ resolution: socketOnly,
640
+ lock: "ok",
641
+ inspection: { kind: "indeterminate", socketPath: "/fake/ctl/s.sock", error: "EACCES" },
642
+ });
643
+ const d = await decideDispatch({ target: GID, intent: "fire-and-forget", message: "ping" }, t.deps);
644
+ ok(
645
+ "socketOnly ff+indeterminate: reject indeterminate-no-spawn",
646
+ d.kind === "reject" && d.receipt.reason === "indeterminate-no-spawn",
647
+ );
648
+ ok("socketOnly ff+indeterminate: lock released", t.releaseCalls.length === 1);
649
+ }
650
+
651
+ // ── B2: a throw AFTER the lock is acquired RELEASES it before rethrowing ─────
652
+ // inspectSocket / probeSocket / preflightForCwd are the three post-lock IO sites
653
+ // that can throw. Each must leave NO held lock (else a long-lived MCP bridge pins
654
+ // the gid forever). The decision still propagates the error (the decider does not
655
+ // swallow it); only the lock is cleaned up.
656
+ {
657
+ const runThrowing = async (label: string, over: Partial<DispatchDeciderDeps>): Promise<void> => {
658
+ const released: LockClaim[] = [];
659
+ let threw = false;
660
+ try {
661
+ await decideDispatch(
662
+ { target: GID, intent: "owned-outcome", message: "x" },
663
+ {
664
+ resolveTarget: () => ({ identity: identity("pi"), preProbeAddressConflict: false }),
665
+ acquireLock: () => ({ ok: true, claim: lockClaim(GID) }),
666
+ releaseLock: (c: LockClaim) => {
667
+ released.push(c);
668
+ },
669
+ inspectSocket: async () => ({ kind: "absent", socketPath: "/fake/ctl/s.sock" }),
670
+ probeSocket: async () => "dead",
671
+ preflightForCwd: () => APPROVE,
672
+ mailboxDeliverabilityFor: () => ({ deliverable: false, reason: "in-domain pi: seam unused" }),
673
+ ...over,
674
+ },
675
+ );
676
+ } catch {
677
+ threw = true;
678
+ }
679
+ ok(`lock-leak(${label}): error rethrown`, threw);
680
+ ok(`lock-leak(${label}): lock RELEASED before rethrow`, released.length === 1);
681
+ ok(`lock-leak(${label}): released the held claim`, released[0]?.gardenId === GID);
682
+ };
683
+
684
+ await runThrowing("inspect throw", {
685
+ inspectSocket: async () => {
686
+ throw new Error("inspect boom");
687
+ },
688
+ });
689
+ await runThrowing("probe throw", {
690
+ inspectSocket: async () => ({ kind: "socket-file", socketPath: "/fake/ctl/s.sock" }),
691
+ probeSocket: async () => {
692
+ throw new Error("probe boom");
693
+ },
694
+ });
695
+ await runThrowing("preflight throw", {
696
+ // absent → dead → owned-outcome resume verdict → preflight is reached
697
+ preflightForCwd: () => {
698
+ throw new Error("preflight boom");
699
+ },
700
+ });
701
+ }
702
+
703
+ // ── B2 retry-pin (Fable 2차 권고): a reject-path releaseLock that THROWS is ──
704
+ // retried by the catch. releaseLock can only throw when the unlink did NOT happen
705
+ // (a successful unlink returns "released"), so at catch time the lock is still
706
+ // ours → the 2nd attempt is a legitimate retry, not a double-free. The ORIGINAL
707
+ // error (not the retry's) propagates, and releaseLock is called exactly twice.
708
+ // This pins the retry so a future refactor cannot silently drop it.
709
+ {
710
+ const released: LockClaim[] = [];
711
+ let caught: unknown = null;
712
+ try {
713
+ await decideDispatch(
714
+ { target: GID, intent: "owned-outcome", message: "x" },
715
+ {
716
+ resolveTarget: () => ({ identity: identity("pi"), preProbeAddressConflict: false }),
717
+ acquireLock: () => ({ ok: true, claim: lockClaim(GID) }),
718
+ releaseLock: (c: LockClaim) => {
719
+ released.push(c);
720
+ throw new Error(`release boom ${released.length}`);
721
+ },
722
+ // owned-outcome + LIVE → owned-live-no-autosend reject → rejectAfterRelease
723
+ inspectSocket: async () => ({ kind: "socket-file", socketPath: "/fake/ctl/s.sock" }),
724
+ probeSocket: async () => "alive",
725
+ preflightForCwd: () => APPROVE,
726
+ mailboxDeliverabilityFor: () => ({ deliverable: false, reason: "in-domain pi: seam unused" }),
727
+ },
728
+ );
729
+ } catch (e) {
730
+ caught = e;
731
+ }
732
+ ok(
733
+ "reject-release-throw: ORIGINAL error propagates (not the retry's)",
734
+ caught instanceof Error && caught.message === "release boom 1",
735
+ );
736
+ ok("reject-release-throw: releaseLock retried exactly twice", released.length === 2);
737
+ }
738
+
739
+ // ── 9: invalid garden id throws BEFORE resolveTarget (F2-P1) ─────────────────
740
+ {
741
+ let threw = false;
742
+ let resolveCalled = false;
743
+ const deps: DispatchDeciderDeps = {
744
+ resolveTarget: () => {
745
+ resolveCalled = true;
746
+ return { identity: null, preProbeAddressConflict: false };
747
+ },
748
+ // Required IO seams: the throw at requireGardenId must precede every one of
749
+ // them, so they are wired as "must not be reached" tripwires.
750
+ acquireLock: () => {
751
+ throw new Error("invalid-gid: acquireLock must not be reached");
752
+ },
753
+ releaseLock: () => {
754
+ throw new Error("invalid-gid: releaseLock must not be reached");
755
+ },
756
+ inspectSocket: async () => {
757
+ throw new Error("invalid-gid: inspectSocket must not be reached");
758
+ },
759
+ probeSocket: async () => {
760
+ throw new Error("invalid-gid: probeSocket must not be reached");
761
+ },
762
+ preflightForCwd: () => APPROVE,
763
+ mailboxDeliverabilityFor: () => {
764
+ throw new Error("invalid-gid: mailboxDeliverabilityFor must not be reached");
765
+ },
766
+ };
767
+ try {
768
+ await decideDispatch({ target: "../etc/passwd", intent: "owned-outcome", message: "x" }, deps);
769
+ } catch {
770
+ threw = true;
771
+ }
772
+ ok("invalid-gid: throws", threw);
773
+ ok("invalid-gid: throws BEFORE resolveTarget (no path/lookup built)", !resolveCalled);
774
+ }
775
+ // The wake-mode capability HELPER stays gate-pinned (renamed; the decider no longer
776
+ // calls it directly — deliverability flows through the required seam). It answers the
777
+ // capability HALF only; the active-receiver half lives in the production seam.
778
+ ok(
779
+ "wakeMode-capability: claude-code (self-fetch) → capability-deliverable",
780
+ resolveMailboxWakeModeCapability(identity("claude-code"), () => capability("self-fetch")),
781
+ );
782
+ ok(
783
+ "wakeMode-capability: codex (direct-inject) → NOT capability-deliverable (fail-closed)",
784
+ !resolveMailboxWakeModeCapability(identity("codex"), () => capability("direct-inject")),
785
+ );
786
+ }
787
+
788
+ main()
789
+ .then(() => {
790
+ console.log(`\n[check-entwurf-v2-decider] ${passed} assertions ok`);
791
+ })
792
+ .catch((err) => {
793
+ console.error(err);
794
+ process.exit(1);
795
+ });