@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,441 @@
1
+ /**
2
+ * entwurf-v2-decider — the PURE dispatch decider for the unified `entwurf_v2` verb
3
+ * (0.11 Stage 0 step 5b). It turns a caller's (target, intent, …) request plus
4
+ * already-resolvable facts into a single `DispatchDecision`: either a reject
5
+ * receipt, or an execute receipt + an `ExecutionPlan` + (for in-domain backends) a
6
+ * held `LockClaim`. It performs NO transport: no send, no enqueue, no spawn, no
7
+ * release-watcher. Step 5c executes the chosen plan; step 5d registers the MCP
8
+ * surface. The decider only DECIDES.
9
+ *
10
+ * Why a separate pure module (step 4 discipline = gate-first → pure-before-IO →
11
+ * wire): every IO surface the decision needs — the target lookup, the per-gid lock,
12
+ * the lstat/connect socket inspection, the trust preflight, the mailbox-deliverability
13
+ * seam — is INJECTED via `DispatchDeciderDeps`, so the gate drives every branch with
14
+ * fakes and the live wrappers wire the real fns. The plan is shaped so 5c's
15
+ * transport hand consumes it WITHOUT re-deriving any path/arg (socketPath,
16
+ * mailboxDir, sessionsDir, launchArgs are all planted here once — 4c "재유도 금지"):
17
+ * the hand is a plan-keyed dispatcher, never a second brain. The two deliberate
18
+ * exceptions (GPT힣 1차 검수): the spawned child's runtime pid is NOT in the plan
19
+ * (it is born during 5c execution → it is the watcher's release-context, not a plan
20
+ * input), and the launch identity (provider/model) is read by the 5c launcher from
21
+ * the saved session JSONL (its existing authority) — putting that read in the
22
+ * decider would make it impure. So the plan carries no provider/model.
23
+ *
24
+ * The frozen 7-step order (NEXT.md "통합 decider 순서"):
25
+ * 1. requireGardenId — runtime guard BEFORE any path is built (F2-P1; closes the
26
+ * MCP-schema bypass for pi-native/internal callers).
27
+ * 2. resolveTarget — no citizen → bad-target; quarantined (non-pi gid sharing
28
+ * a socket/symlink) → target-address-conflict. PROBE-FREE.
29
+ * 3. backend → isLivenessSupported.
30
+ * 4. acquireLock — IN-DOMAIN ONLY (?7), BEFORE lstat/connect, so the probe
31
+ * happens under the lock (the TOCTOU 5a's lock closes).
32
+ * 5. in-domain — inspectTargetControlSocket (lstat-then-connect, ?2) →
33
+ * resolveDispatch → on a resume verdict, preflight the target cwd (1B: deny →
34
+ * nonce-owned release → untrusted-fail-fast) → plan.
35
+ * 6. unsupported — NO lock; deps.mailboxDeliverabilityFor (REQUIRED seam: wake-mode
36
+ * capability AND a live active-receiver, fail-closed) → resolveDispatch → meta-mailbox
37
+ * plan or reject. SE-2 2d-3: a terminated/drifted self-fetch citizen is refused, never
38
+ * enqueued as mailbox garbage.
39
+ * 7. send-fail fallback is 5c's job (the decider decides ONCE; the held lock nonce
40
+ * is what lets 5c re-resolve at most once under the same claim).
41
+ *
42
+ * Every reject is minted through `makeRejectReceipt` (?6 chokepoint) — the decider
43
+ * never hand-assembles a `{ok:false, …}` literal, so the pre-probe-null rule cannot
44
+ * be bypassed.
45
+ */
46
+
47
+ import type { MailboxDeliverabilityResult } from "./entwurf-deliverability.ts";
48
+ import { isNonPiGardenIdSocketConflict } from "./entwurf-facts.ts";
49
+ import type { PreflightOutcome } from "./entwurf-preflight.ts";
50
+ import {
51
+ type EntwurfIntent,
52
+ type EntwurfV2Receipt,
53
+ isLivenessSupported,
54
+ makeRejectReceipt,
55
+ resolveDispatch,
56
+ } from "./entwurf-v2-contract.ts";
57
+ import type { AcquireLockResult, LockClaim, LockConflict } from "./entwurf-v2-lock.ts";
58
+ import {
59
+ defaultMetaMailboxDir,
60
+ defaultMetaSessionsDir,
61
+ type MetaBackendV2,
62
+ type MetaCapability,
63
+ type MetaIdentity,
64
+ metaCapabilityFor,
65
+ } from "./meta-session.ts";
66
+ import { isValidSessionId } from "./session-id.js";
67
+ import { controlSocketPath, mapInspectionToLiveness, type TargetSocketInspection } from "./socket-discovery.ts";
68
+ import type { SocketLiveness } from "./socket-probe.ts";
69
+
70
+ // Re-export the shared conflict predicate so producers of a TargetResolution have a
71
+ // single import site for it (it is the SAME fn the fact-provider listing uses).
72
+ export { isNonPiGardenIdSocketConflict };
73
+
74
+ // ── observe timeout (?3) ───────────────────────────────────────────────────
75
+ // The bounded wait 5c's release-watcher gives a spawned child to surface its
76
+ // control socket / exit. Planted into the resume plan so the hand does not invent
77
+ // a timeout. A standalone constant + env override (NOT a multiple of the probe
78
+ // timeout — a different concern); 30s initial, tunable to 45s after live data.
79
+ export const ENTWURF_V2_OBSERVE_TIMEOUT_MS = ((): number => {
80
+ const raw = process.env.ENTWURF_V2_OBSERVE_TIMEOUT_MS;
81
+ const n = raw !== undefined && raw !== "" ? Number(raw) : Number.NaN;
82
+ return Number.isFinite(n) && n > 0 ? n : 30_000;
83
+ })();
84
+
85
+ export const ENTWURF_V2_MODE_DEFAULT = "follow_up" as const;
86
+
87
+ // ── receipt branch aliases ──────────────────────────────────────────────────
88
+ export type SuccessReceipt = Extract<EntwurfV2Receipt, { ok: true }>;
89
+ export type RejectReceipt = Extract<EntwurfV2Receipt, { ok: false }>;
90
+ export type EntwurfV2Mode = "steer" | "follow_up";
91
+
92
+ // ── ExecutionPlan (5c-consumable, no re-derivation) ─────────────────────────
93
+ // Each plan kind carries every value 5c's transport fn needs, planted once by the
94
+ // decider. control-socket send and meta-mailbox send carry the message; spawn-bg
95
+ // resume carries the launch inputs MINUS provider/model (D4: 5c-owned identity
96
+ // read) and MINUS the child pid (born at execution, watcher's release-context).
97
+ export type ExecutionPlan =
98
+ | {
99
+ transport: "control-socket";
100
+ action: "send";
101
+ targetGardenId: string;
102
+ socketPath: string;
103
+ mode: EntwurfV2Mode;
104
+ wantsReply: boolean;
105
+ message: string;
106
+ }
107
+ | {
108
+ transport: "meta-mailbox";
109
+ action: "send";
110
+ targetGardenId: string;
111
+ mailboxDir: string;
112
+ sessionsDir: string;
113
+ wantsReply: boolean;
114
+ message: string;
115
+ }
116
+ | {
117
+ transport: "spawn-bg";
118
+ action: "resume";
119
+ targetGardenId: string;
120
+ sessionId: string;
121
+ cwd: string;
122
+ prompt: string;
123
+ launchArgs: readonly string[];
124
+ expectedSocketPath: string;
125
+ observeTimeoutMs: number;
126
+ releaseWhen: "socket-alive-or-child-exited";
127
+ };
128
+
129
+ // ── DispatchDecision (the decider's only output) ────────────────────────────
130
+ // reject ⇒ NO plan AND NO retained lock (any acquired lock was released before
131
+ // return). execute ⇒ a plan + a receipt whose transport matches plan.transport;
132
+ // `lock` is non-null for an in-domain execute (control-socket send OR spawn-bg
133
+ // resume — both keep the claim so 5c's at-most-once re-resolve runs under the same
134
+ // nonce) and null for the lock-free meta-mailbox path (?7).
135
+ //
136
+ // A reject's optional machine-readable diagnostic. Only `target-locked` carries one:
137
+ // the `LockConflict` (holder pid/host/createdAt, lockPath, human detail) the lock
138
+ // primitive produced on contention. It rides ALONGSIDE the receipt — the receipt
139
+ // schema is unchanged; 5d's surface renders it onto the reject. (B3: without this the
140
+ // holder evidence was dropped at the decider boundary, so a PID-reuse permanent lock
141
+ // could not be observed/cleared — F2-P2 "관측 가능해야 수용".)
142
+ export type RejectDiagnostic = { kind: "target-locked"; conflict: LockConflict };
143
+
144
+ export type DispatchDecision =
145
+ | { kind: "reject"; receipt: RejectReceipt; diagnostic?: RejectDiagnostic }
146
+ | { kind: "execute"; receipt: SuccessReceipt; plan: ExecutionPlan; lock: LockClaim | null };
147
+
148
+ // ── target resolution (E: single-target, not a whole-store scan) ────────────
149
+ // A non-null identity is an existing citizen. `preProbeAddressConflict` is the
150
+ // PROBE-FREE, record-side conflict (non-pi gid sharing a real/symlinked socket —
151
+ // isNonPiGardenIdSocketConflict). The production wrapper computes it with a single
152
+ // readMetaIdentityByGardenId + a target socket/symlink check; the gate injects it.
153
+ // Do NOT call listEntwurfFacts here to find the target — its socket probe would run
154
+ // before the lock (the 1C TOCTOU). The shared predicate is the only thing the
155
+ // listing and the dispatch share.
156
+ export interface TargetResolution {
157
+ identity: MetaIdentity | null;
158
+ preProbeAddressConflict: boolean;
159
+ /**
160
+ * A1 narrow (0.11.0): a record-LESS but live-control-socket-present pi endpoint.
161
+ * `identity` is null (there is NO meta-record citizen), yet a gid-shaped non-symlink
162
+ * control socket exists, so the gid is an addressable socket-only pi endpoint. The decider
163
+ * routes EVERY intent through the same in-domain probe table under `allowResume:false` (it
164
+ * does NOT pre-reject non-fire-and-forget — a live, addressable citizen must never be the
165
+ * `bad-target` "absent" lie). With no record there is no cwd/resume authority, so spawn-bg
166
+ * can never open: owned-outcome × live → owned-live-no-autosend (honest table verdict),
167
+ * owned-outcome × dormant → socket-only-no-resume-authority (the post-probe allowResume:false
168
+ * guard). PROBE-FREE presence hint — the decider still does the real under-lock
169
+ * `inspectSocket`. Only meaningful when `identity === null`; a record-backed citizen never sets it.
170
+ */
171
+ socketOnlyPi?: boolean;
172
+ }
173
+
174
+ export interface DispatchInput {
175
+ target: string;
176
+ intent: EntwurfIntent;
177
+ mode?: EntwurfV2Mode;
178
+ wantsReply?: boolean;
179
+ message: string;
180
+ }
181
+
182
+ // Every IO seam is a REQUIRED dep (no default): the decider performs ZERO IO of its
183
+ // own. The live wrappers (5c) wire the real fns; the gate injects fakes. This is what
184
+ // makes "pure decider" honest — there is no hidden default that touches `~/.pi`. (B1:
185
+ // the removed `acquireLock` default hardcoded `{ dir: undefined }` → it ignored any
186
+ // injected lock dir and leaked the per-gid lock to the real `~/.pi/entwurf-v2-locks`
187
+ // whenever a caller/test wired the other dirs but relied on the lock default.) The
188
+ // mailbox-deliverability seam is required too (SE-2 2d-3): it carries the active-receiver
189
+ // judgement, so leaving a wake-mode-only default would let a caller skip it and re-open the
190
+ // gap. Only pure config (plan-planted dirs, the observe timeout) keeps a default — it is
191
+ // data, not an IO seam.
192
+ export interface DispatchDeciderDeps {
193
+ resolveTarget: (gardenId: string) => TargetResolution | Promise<TargetResolution>;
194
+ acquireLock: (gardenId: string) => AcquireLockResult;
195
+ releaseLock: (claim: LockClaim) => unknown;
196
+ inspectSocket: (gardenId: string) => Promise<TargetSocketInspection>;
197
+ probeSocket: (socketPath: string) => Promise<SocketLiveness>;
198
+ preflightForCwd: (cwd: string) => PreflightOutcome;
199
+ /**
200
+ * SE-2 slice 2d-3: the REQUIRED mailbox-deliverability seam (no default). The decider
201
+ * does NOT judge deliverability itself — it asks this injected fn, which combines the
202
+ * backend wake-mode capability (only self-fetch has a drainable mailbox) with the LIVE
203
+ * active-receiver check (a presence marker that matches the target identity). Making it
204
+ * required is the whole point: every call site is forced by the compiler to wire the
205
+ * active-receiver axis, so no future caller can silently fall back to wake-mode-only and
206
+ * reopen the SE-2 "enqueue garbage into a terminated session's mailbox" gap.
207
+ */
208
+ mailboxDeliverabilityFor: (
209
+ identity: MetaIdentity,
210
+ ) => MailboxDeliverabilityResult | Promise<MailboxDeliverabilityResult>;
211
+ mailboxDir?: string;
212
+ sessionsDir?: string;
213
+ observeTimeoutMs?: number;
214
+ }
215
+
216
+ /**
217
+ * F2-P1 defense in depth: never build a lock/socket path from an unvalidated gid.
218
+ * The MCP TypeBox pattern guards that one surface; a pi-native / internal caller
219
+ * bypasses the schema, so the decider re-validates as its very first step.
220
+ */
221
+ function requireGardenId(target: string): string {
222
+ if (!isValidSessionId(target)) {
223
+ throw new Error(`entwurf_v2 decider: refusing to dispatch to an invalid garden id (${JSON.stringify(target)}).`);
224
+ }
225
+ return target;
226
+ }
227
+
228
+ /**
229
+ * Mailbox WAKE-MODE capability (?0 frozen): ONLY a self-fetch backend (claude-code) has a
230
+ * drainable meta-bridge mailbox. direct-inject backends (codex/agy/pi) are fail-closed —
231
+ * that is intended, not a gap (the 0.10.0 mailbox + doorbell is a self-fetch drain;
232
+ * direct-inject drain is an unproven capability). Do NOT widen by deliveryLevel — only by
233
+ * a real per-backend predicate.
234
+ *
235
+ * This is the CAPABILITY HALF of deliverability only. Full mailbox deliverability ALSO
236
+ * requires a live active-receiver (a presence marker matching the identity) — that
237
+ * conjunction lives in the required `mailboxDeliverabilityFor` seam (SE-2 slice 2d-3). The
238
+ * decider NEVER calls this helper directly: deliverability flows exclusively through the
239
+ * seam so the active-receiver axis can never be skipped. Kept as a named, gate-pinned
240
+ * helper for the production seam to compose and for capability-only call sites.
241
+ */
242
+ export function resolveMailboxWakeModeCapability(
243
+ identity: MetaIdentity,
244
+ capabilityFor: (backend: MetaBackendV2) => MetaCapability = metaCapabilityFor,
245
+ ): boolean {
246
+ return capabilityFor(identity.backend).wakeMode === "self-fetch";
247
+ }
248
+
249
+ /**
250
+ * The pure dispatch decider. See the module header for the 7-step contract. Async
251
+ * only because the socket inspection/probe are async; it touches the filesystem
252
+ * ONLY through injected deps.
253
+ */
254
+ export async function decideDispatch(input: DispatchInput, deps: DispatchDeciderDeps): Promise<DispatchDecision> {
255
+ const mailboxDir = deps.mailboxDir ?? defaultMetaMailboxDir();
256
+ const sessionsDir = deps.sessionsDir ?? defaultMetaSessionsDir();
257
+ const observeTimeoutMs = deps.observeTimeoutMs ?? ENTWURF_V2_OBSERVE_TIMEOUT_MS;
258
+ const mode: EntwurfV2Mode = input.mode ?? ENTWURF_V2_MODE_DEFAULT;
259
+ const wantsReply = input.wantsReply ?? false;
260
+ const ctx: InDomainCtx = { mode, wantsReply, observeTimeoutMs };
261
+
262
+ const reject = (receipt: RejectReceipt, diagnostic?: RejectDiagnostic): DispatchDecision =>
263
+ diagnostic ? { kind: "reject", receipt, diagnostic } : { kind: "reject", receipt };
264
+
265
+ // 1. requireGardenId — BEFORE any path is built.
266
+ const gardenId = requireGardenId(input.target);
267
+
268
+ // 2. resolveTarget — probe-free. no citizen → bad-target; quarantined → conflict.
269
+ const resolution = await deps.resolveTarget(gardenId);
270
+
271
+ // 2b. A1 narrow (0.11.0): a record-LESS live pi control socket — a socket-only pi
272
+ // endpoint (no citizen identity, but an addressable control socket). It is a REAL,
273
+ // addressable citizen, so it runs the SAME in-domain probe table as a record-backed pi
274
+ // (lock → inspect → table verdict) under `allowResume:false`. We DELIBERATELY do NOT
275
+ // short-circuit non-fire-and-forget here: rejecting owned-outcome pre-probe with
276
+ // `bad-target` would be a category lie (a LIVE, addressable peer classified as
277
+ // absent/typo). The frozen table is the honest authority instead —
278
+ // owned-outcome × live → `owned-live-no-autosend` (use fire-and-forget for a live peer)
279
+ // owned-outcome × dormant → resume verdict, but `allowResume:false` refuses it with
280
+ // `socket-only-no-resume-authority` (no trusted cwd to spawn-bg)
281
+ // fire-and-forget × live → control-socket send (unchanged)
282
+ // `allowResume:false` is what keeps spawn-bg from EVER opening into a record-less
283
+ // endpoint, regardless of intent — so routing every intent through is safe.
284
+ if (resolution.identity === null && resolution.socketOnlyPi === true) {
285
+ return decideInDomain(gardenId, input, deps, ctx, { allowResume: false });
286
+ }
287
+
288
+ // 2c. no citizen → bad-target; quarantined → conflict.
289
+ if (resolution.identity === null) {
290
+ return reject(makeRejectReceipt("bad-target", null));
291
+ }
292
+ if (resolution.preProbeAddressConflict) {
293
+ return reject(makeRejectReceipt("target-address-conflict", null));
294
+ }
295
+ const identity = resolution.identity;
296
+
297
+ // 3. backend.
298
+ if (!isLivenessSupported(identity.backend)) {
299
+ // 6. unsupported path — NO lock (?7). Deliverability comes from the REQUIRED seam
300
+ // (wake-mode capability AND a live active-receiver marker matching this identity),
301
+ // NOT a wake-mode-only helper — so a terminated self-fetch citizen's mailbox is
302
+ // fail-closed (SE-2 2d-3). resolveDispatch then routes intent × deliverable.
303
+ const deliverability = await deps.mailboxDeliverabilityFor(identity);
304
+ const receipt = resolveDispatch(input.intent, "unsupported", deliverability.deliverable);
305
+ if (!receipt.ok) return reject(receipt);
306
+ // the only allow cell here is fire-and-forget → meta-mailbox send.
307
+ const plan: ExecutionPlan = {
308
+ transport: "meta-mailbox",
309
+ action: "send",
310
+ targetGardenId: gardenId,
311
+ mailboxDir,
312
+ sessionsDir,
313
+ wantsReply,
314
+ message: input.message,
315
+ };
316
+ return { kind: "execute", receipt, plan, lock: null };
317
+ }
318
+
319
+ // 4-5. in-domain (record-backed pi): lock → inspect → route (resume allowed, cwd from record).
320
+ return decideInDomain(gardenId, input, deps, ctx, { allowResume: true, cwd: identity.cwd });
321
+ }
322
+
323
+ // ── in-domain probe (steps 4-5), shared by the record-backed pi path and the A1-narrow
324
+ // socket-only pi path ────────────────────────────────────────────────────────────────────
325
+ // The lock lifecycle (B2) lives here: acquire BEFORE lstat/connect, every reject path
326
+ // releases explicitly (rejectAfterRelease), every execute path that keeps the lock sets
327
+ // retainLock=true, and a thrown IO error releases the still-held lock before rethrowing so
328
+ // the long-lived MCP bridge never pins a gid. `resume.allowResume` gates the ONLY branch
329
+ // that reads a target cwd and launches a child: a socket-only endpoint passes `false`, so
330
+ // even though the resume verdict is structurally unreachable for it (fire-and-forget never
331
+ // yields `resume`), spawn-bg can never open into a record-less endpoint.
332
+ type InDomainCtx = { mode: EntwurfV2Mode; wantsReply: boolean; observeTimeoutMs: number };
333
+ type ResumePolicy = { allowResume: true; cwd: string } | { allowResume: false };
334
+
335
+ async function decideInDomain(
336
+ gardenId: string,
337
+ input: DispatchInput,
338
+ deps: DispatchDeciderDeps,
339
+ ctx: InDomainCtx,
340
+ resume: ResumePolicy,
341
+ ): Promise<DispatchDecision> {
342
+ const { acquireLock, releaseLock, inspectSocket, probeSocket } = deps;
343
+
344
+ // 4. acquire the per-gid lock BEFORE lstat/connect.
345
+ const acq = acquireLock(gardenId);
346
+ if (!acq.ok) {
347
+ // B3: carry the lock's holder evidence (pid/host/createdAt + lockPath) as a
348
+ // diagnostic so a permanently-held gid is observable/clearable. The receipt
349
+ // stays pre-probe-null; the conflict rides alongside it.
350
+ return {
351
+ kind: "reject",
352
+ receipt: makeRejectReceipt("target-locked", null),
353
+ diagnostic: { kind: "target-locked", conflict: acq.conflict },
354
+ };
355
+ }
356
+ const lock = acq.claim;
357
+
358
+ const rejectAfterRelease = (receipt: RejectReceipt): DispatchDecision => {
359
+ releaseLock(lock);
360
+ return { kind: "reject", receipt };
361
+ };
362
+
363
+ let retainLock = false;
364
+ try {
365
+ // 5. under the lock: inspect the socket (lstat-then-connect), then route.
366
+ const inspection = await inspectSocket(gardenId);
367
+ const mapped = await mapInspectionToLiveness(inspection, probeSocket);
368
+ if ("addressConflict" in mapped) {
369
+ return rejectAfterRelease(makeRejectReceipt("target-address-conflict", null));
370
+ }
371
+ const { liveness, socketPath } = mapped;
372
+
373
+ const receipt = resolveDispatch(input.intent, liveness, false);
374
+ if (!receipt.ok) {
375
+ // resolver reject (owned-live-no-autosend / indeterminate-no-spawn / …) — the
376
+ // lock was for an in-domain probe that yielded no execute, so release it.
377
+ return rejectAfterRelease(receipt);
378
+ }
379
+
380
+ if (receipt.action === "resume") {
381
+ if (!resume.allowResume) {
382
+ // A1 narrow guard: the resume verdict is owned-outcome × dormant ONLY. A
383
+ // socket-only pi endpoint now routes ALL intents through here under
384
+ // `allowResume:false` (2b no longer pre-rejects non-fire-and-forget), so this
385
+ // guard IS reachable — it is the honest home of "owned-outcome to a DORMANT
386
+ // record-less socket". REFUSE: a record-less endpoint has no trusted cwd/resume
387
+ // authority, so spawn-bg must never open into it. This is a POST-probe guard
388
+ // (we measured `liveness` above), so it carries the honest measured liveness —
389
+ // NOT the pre-probe `bad-target` lie that would mislabel the citizen absent.
390
+ return rejectAfterRelease(makeRejectReceipt("socket-only-no-resume-authority", liveness));
391
+ }
392
+ // 1B: preflight runs ONLY here (the sole branch that launches a child into a
393
+ // target cwd). deny → nonce-owned release → untrusted-fail-fast, with the
394
+ // honest measured liveness (dormant = the `dead` we just probed).
395
+ const outcome = deps.preflightForCwd(resume.cwd);
396
+ if (outcome.kind === "deny") {
397
+ return rejectAfterRelease(makeRejectReceipt("untrusted-fail-fast", liveness));
398
+ }
399
+ const plan: ExecutionPlan = {
400
+ transport: "spawn-bg",
401
+ action: "resume",
402
+ targetGardenId: gardenId,
403
+ sessionId: gardenId, // D3: gid is the pi resume authority, not nativeSessionId.
404
+ cwd: resume.cwd,
405
+ prompt: input.message,
406
+ launchArgs: outcome.launchArgs,
407
+ expectedSocketPath: socketPath,
408
+ observeTimeoutMs: ctx.observeTimeoutMs,
409
+ releaseWhen: "socket-alive-or-child-exited",
410
+ };
411
+ retainLock = true;
412
+ return { kind: "execute", receipt, plan, lock };
413
+ }
414
+
415
+ // receipt.action === "send" → control-socket send (lock kept for 5c re-resolve).
416
+ const plan: ExecutionPlan = {
417
+ transport: "control-socket",
418
+ action: "send",
419
+ targetGardenId: gardenId,
420
+ socketPath,
421
+ mode: ctx.mode,
422
+ wantsReply: ctx.wantsReply,
423
+ message: input.message,
424
+ };
425
+ retainLock = true;
426
+ return { kind: "execute", receipt, plan, lock };
427
+ } catch (err) {
428
+ if (!retainLock) {
429
+ try {
430
+ releaseLock(lock);
431
+ } catch {
432
+ // best-effort: a release failure must NOT mask the original throw.
433
+ }
434
+ }
435
+ throw err;
436
+ }
437
+ }
438
+
439
+ /** The canonical control-socket path for a target — re-exported so a production
440
+ * resolveTarget/wrapper plants the SAME path the decider/plan use (no drift). */
441
+ export { controlSocketPath };