@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,326 @@
1
+ /**
2
+ * entwurf-v2-production — 5d-2b: the ctx-free PRODUCTION assembly of `runEntwurfV2`'s deps.
3
+ * `makeProductionEntwurfV2Deps(opts)` wires the real 5b decider IO seams + the three 5c
4
+ * transport hands into one `EntwurfV2RunDeps` (`{decide, executor}`). It touches NO
5
+ * `ExtensionContext`/`ExtensionAPI` — the wiring SITE (5d-3, entwurf-control.ts) builds the
6
+ * `senderProvider` from `buildLocalSenderEnvelope(ctx)` and passes it in, so this module
7
+ * stays in `lib/` with the rest of the gate-/smoke-testable v2 core.
8
+ *
9
+ * Three invariants this factory exists to guarantee (each gate-proven):
10
+ * - ONE lock domain (Q2/QB3). A single `release` closure bound to `lockDir` is the lock
11
+ * authority for EVERY hand: the decider's `releaseLock`, the control-send hand's
12
+ * `releaseLock`, AND the spawn watcher's `releaseFn`. The spawn factory's default
13
+ * `releaseFn` ignores `lockDir` (it would release into the DEFAULT lock dir) — passing
14
+ * `release` explicitly is what closes that split-brain.
15
+ * - ONE mailbox sender (Q3). A single `makeProductionSendViaMailbox` instance is the
16
+ * top-level meta-mailbox hand AND the control-send dead-fallback's mailbox enqueue, so a
17
+ * direct send and a fallback send never drift in sender envelope / dirs.
18
+ * - ONE path set (Q5). `sessionsDir`/`mailboxDir`/`lockDir`/`controlSocketDir` are resolved
19
+ * ONCE and threaded to the decider, the dead-fallback resolver, and the enqueue, so the
20
+ * plan the decider plants and the plan the fallback plants address the same store.
21
+ *
22
+ * `resolveTarget` (QB1): a MISSING meta-record is a soft `bad-target` (identity:null); a
23
+ * PRESENT-but-corrupt record fails LOUD (the read throws drift) — never matched by string.
24
+ * `preProbeAddressConflict` (QB2): a single record-side `lstat` (NO connect) of the target's
25
+ * canonical socket path; `indeterminate` (EACCES/unknown) fails loud rather than silently
26
+ * claiming "no conflict". The decider's later `inspectSocket` probe is a SEPARATE step.
27
+ */
28
+
29
+ import {
30
+ type RpcClientOptions,
31
+ type RpcCommand,
32
+ type RpcResponse,
33
+ sendRpcCommand as realSendRpc,
34
+ type SenderEnvelope,
35
+ } from "./entwurf-control-rpc.ts";
36
+ import {
37
+ type MailboxDeliverabilityResult,
38
+ mailboxConversationalDeliverable,
39
+ receiverMarkerMatchesIdentity,
40
+ } from "./entwurf-deliverability.ts";
41
+ import { isNonPiGardenIdSocketConflict } from "./entwurf-facts.ts";
42
+ import { type PreflightInput, type PreflightOutcome, preflight as realPreflight } from "./entwurf-preflight.ts";
43
+ import { isLivenessSupported } from "./entwurf-v2-contract.ts";
44
+ import {
45
+ type DispatchDeciderDeps,
46
+ type DispatchInput,
47
+ decideDispatch,
48
+ type TargetResolution,
49
+ } from "./entwurf-v2-decider.ts";
50
+ import {
51
+ type AcquireLockResult,
52
+ ENTWURF_V2_LOCK_DIR,
53
+ type LockClaim,
54
+ acquireLock as realAcquireLock,
55
+ releaseLock as realReleaseLock,
56
+ } from "./entwurf-v2-lock.ts";
57
+ import { makeProductionSendViaMailbox } from "./entwurf-v2-mailbox.ts";
58
+ import type { DispatchExecutorDeps, EntwurfV2RunDeps } from "./entwurf-v2-runner.ts";
59
+ import {
60
+ type ControlSocketPlan,
61
+ type ControlSocketSendDeps,
62
+ executeControlSocketSend,
63
+ type MetaMailboxPlan,
64
+ type RpcSendResult,
65
+ } from "./entwurf-v2-send.ts";
66
+ import { resolveDeadControlSendFallback } from "./entwurf-v2-send-fallback.ts";
67
+ import type { SpawnBgPlan } from "./entwurf-v2-spawn.ts";
68
+ import { executeSpawnBgResume } from "./entwurf-v2-spawn.ts";
69
+ import { makeProductionSpawnBgResumeDeps, type ProductionSpawnOpts } from "./entwurf-v2-spawn-production.ts";
70
+ import {
71
+ defaultMetaMailboxDir,
72
+ defaultMetaSessionsDir,
73
+ type EnqueueMetaMessageOptions,
74
+ type EnqueueMetaMessageResult,
75
+ enqueueMetaMessage,
76
+ type MetaIdentity,
77
+ type MetaReceiverMarker,
78
+ metaCapabilityFor,
79
+ metaRecordExistsByGardenId,
80
+ readMetaIdentityByGardenId,
81
+ readMetaReceiverMarker,
82
+ } from "./meta-session.ts";
83
+ import {
84
+ CONTROL_SOCKET_DIR,
85
+ controlSocketPath,
86
+ inspectControlSocketPath,
87
+ inspectTargetControlSocket,
88
+ isSocketOnlyPiCandidate,
89
+ type TargetSocketInspection,
90
+ } from "./socket-discovery.ts";
91
+ import { classifyConnectError, probeSocketLiveness, type SocketLiveness } from "./socket-probe.ts";
92
+
93
+ /**
94
+ * The leaf IO the production factory calls. Every field defaults to the REAL fn; the 5d-2b
95
+ * gate overrides them with spies to prove the wiring (shared closures, lockDir binding,
96
+ * shared mailbox instance) WITHOUT a real socket/lock/meta-record. This is the only seam —
97
+ * the factory's COMPOSITION (which closure goes to which hand) is never overridable.
98
+ */
99
+ export interface ProductionEntwurfV2Seams {
100
+ metaRecordExists: (gid: string, sessionsDir: string) => boolean;
101
+ readIdentity: (gid: string, sessionsDir: string) => MetaIdentity;
102
+ /** Read the target's receiver presence marker (null = absent / dead owner / corrupt). The
103
+ * SE-2 2d-3 active-receiver source; the factory's `mailboxDeliverabilityFor` closure verifies
104
+ * its identity match. */
105
+ readReceiverMarker: (gardenId: string) => MetaReceiverMarker | null;
106
+ /** Record-side lstat of the EXACT target socket path (no connect) for the pre-probe conflict. */
107
+ inspectPath: (socketPath: string) => Promise<TargetSocketInspection>;
108
+ acquireLock: (gid: string, deps: { dir?: string }) => AcquireLockResult;
109
+ releaseLock: (claim: LockClaim, deps: { dir?: string }) => unknown;
110
+ inspectSocket: (gid: string, dir: string) => Promise<TargetSocketInspection>;
111
+ probeSocket: (socketPath: string) => Promise<SocketLiveness>;
112
+ preflight: (input: PreflightInput) => PreflightOutcome;
113
+ classifyConnect: (code: string | undefined) => "dead" | "indeterminate";
114
+ sendRpc: (socketPath: string, command: RpcCommand, options?: RpcClientOptions) => Promise<{ response: RpcResponse }>;
115
+ enqueue: (opts: EnqueueMetaMessageOptions) => EnqueueMetaMessageResult;
116
+ /** Extra spawn-factory overrides (timers/spawnChild/probe) for a deterministic spawn gate.
117
+ * `releaseFn` is NOT overridable here — the factory injects the shared `release` (QB3). */
118
+ spawnOverrides: Omit<ProductionSpawnOpts, "releaseFn">;
119
+ }
120
+
121
+ export interface ProductionEntwurfV2Opts {
122
+ /** Built at the wiring site from `buildLocalSenderEnvelope(ctx)`, decorated with its
123
+ * HONEST pi-session replyability (SE-1 2e-a: `replyable` reflects whether the canonical
124
+ * control socket actually exists, not a hardcoded true). ONE provider feeds the
125
+ * control-socket RPC sender AND the meta-mailbox body sender (they share the envelope). */
126
+ senderProvider: () => SenderEnvelope | undefined;
127
+ /** pi agent dir holding `trust.json` (preflight). Omit → preflight's own default. */
128
+ agentDir?: string;
129
+ /** Operator-policy auto-approve roots (preflight prefix promotion). No package default. */
130
+ prefixRoots?: readonly string[];
131
+ lockDir?: string;
132
+ sessionsDir?: string;
133
+ mailboxDir?: string;
134
+ controlSocketDir?: string;
135
+ observeTimeoutMs?: number;
136
+ killGraceMs?: number;
137
+ /** Gate/smoke seam overrides — defaults are the real IO. */
138
+ seams?: Partial<ProductionEntwurfV2Seams>;
139
+ }
140
+
141
+ /** Map a record-side socket inspection to the singleton (socketGids, symlinkedGids) the
142
+ * `isNonPiGardenIdSocketConflict` predicate consumes. `indeterminate` fails LOUD (QB2): an
143
+ * unprovable conflict must NOT be folded to "no conflict" — that would silently allow an
144
+ * unsupported-backend mailbox send onto a quarantined address. */
145
+ function conflictSetsFor(
146
+ gid: string,
147
+ inspection: TargetSocketInspection,
148
+ ): { socketGids: ReadonlySet<string>; symlinkedGids: ReadonlySet<string> } {
149
+ switch (inspection.kind) {
150
+ case "absent":
151
+ return { socketGids: new Set(), symlinkedGids: new Set() };
152
+ case "socket-file":
153
+ return { socketGids: new Set([gid]), symlinkedGids: new Set() };
154
+ case "address-conflict":
155
+ // symlink → the symlink axis; not-socket → a non-symlink `.sock` entry, same axis
156
+ // the fact-provider's `socketGids` carries (NOT only real sockets).
157
+ return inspection.reason === "symlink"
158
+ ? { socketGids: new Set(), symlinkedGids: new Set([gid]) }
159
+ : { socketGids: new Set([gid]), symlinkedGids: new Set() };
160
+ case "indeterminate":
161
+ throw new Error(
162
+ `entwurf-v2-production: cannot resolve target ${gid} — its control socket lstat is indeterminate (${inspection.error}); refusing to claim "no address conflict".`,
163
+ );
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Assemble the production `runEntwurfV2` deps. See the module header for the three wiring
169
+ * invariants. The returned `{ decide, executor }` is exactly the shape `runEntwurfV2` joins.
170
+ */
171
+ export function makeProductionEntwurfV2Deps(opts: ProductionEntwurfV2Opts): EntwurfV2RunDeps {
172
+ // ── ONE path set (Q5): resolve every dir ONCE ─────────────────────────────
173
+ const lockDir = opts.lockDir ?? ENTWURF_V2_LOCK_DIR;
174
+ const sessionsDir = opts.sessionsDir ?? defaultMetaSessionsDir();
175
+ const mailboxDir = opts.mailboxDir ?? defaultMetaMailboxDir();
176
+ const controlSocketDir = opts.controlSocketDir ?? CONTROL_SOCKET_DIR;
177
+
178
+ const s = opts.seams ?? {};
179
+ const io: ProductionEntwurfV2Seams = {
180
+ metaRecordExists: s.metaRecordExists ?? metaRecordExistsByGardenId,
181
+ readIdentity: s.readIdentity ?? readMetaIdentityByGardenId,
182
+ readReceiverMarker: s.readReceiverMarker ?? ((gid: string) => readMetaReceiverMarker({ gardenId: gid })),
183
+ inspectPath: s.inspectPath ?? inspectControlSocketPath,
184
+ acquireLock: s.acquireLock ?? realAcquireLock,
185
+ releaseLock: s.releaseLock ?? realReleaseLock,
186
+ inspectSocket: s.inspectSocket ?? inspectTargetControlSocket,
187
+ probeSocket: s.probeSocket ?? probeSocketLiveness,
188
+ preflight: s.preflight ?? realPreflight,
189
+ classifyConnect: s.classifyConnect ?? classifyConnectError,
190
+ sendRpc: s.sendRpc ?? realSendRpc,
191
+ enqueue: s.enqueue ?? enqueueMetaMessage,
192
+ spawnOverrides: s.spawnOverrides ?? {},
193
+ };
194
+
195
+ // ── ONE lock domain (Q2/QB3): a single lockDir-bound release for ALL hands ─
196
+ const acquire = (gid: string): AcquireLockResult => io.acquireLock(gid, { dir: lockDir });
197
+ const release = (claim: LockClaim): void => {
198
+ io.releaseLock(claim, { dir: lockDir });
199
+ };
200
+
201
+ // ── ONE mailbox sender (Q3): one instance for the hand AND the dead-fallback ─
202
+ const sendViaMailbox = makeProductionSendViaMailbox({
203
+ senderProvider: opts.senderProvider,
204
+ enqueue: io.enqueue,
205
+ });
206
+
207
+ // ── ONE deliverability seam (SE-2 2d-3): wake-mode capability AND a live active-
208
+ // receiver (a presence marker that matches THIS identity). The SAME closure is injected
209
+ // into the decider AND the dead-fallback, so a direct send and a re-resolved fallback
210
+ // send can never drift to different deliverability verdicts. recordBacked is true by
211
+ // construction — resolveTarget already proved the record exists before any unsupported-
212
+ // backend mailbox route, and the closure is only consulted on that route. A null /
213
+ // dead-owner / identity-mismatched marker is fail-closed to inactive (SE-2): a reply to a
214
+ // terminated self-fetch citizen is rejected, not enqueued as mailbox garbage. ──────────
215
+ const mailboxDeliverabilityFor = (identity: MetaIdentity): MailboxDeliverabilityResult => {
216
+ const wakeMode = metaCapabilityFor(identity.backend).wakeMode;
217
+ const marker = io.readReceiverMarker(identity.gardenId);
218
+ const matched = receiverMarkerMatchesIdentity(marker, identity);
219
+ return mailboxConversationalDeliverable({
220
+ wakeMode,
221
+ recordBacked: true,
222
+ ownerAlive: matched,
223
+ watchArmed: matched,
224
+ });
225
+ };
226
+
227
+ // ── target resolution (QB1 + QB2) ─────────────────────────────────────────
228
+ const resolveTarget = async (gid: string): Promise<TargetResolution> => {
229
+ // MISSING record → not a citizen. But a record-LESS, gid-shaped, NON-SYMLINK control
230
+ // socket means the gid is still an addressable socket-only pi endpoint (A1 narrow,
231
+ // 0.11.0): entwurf_v2 accepts it as a FIRE-AND-FORGET control-send target only. This is
232
+ // PROBE-FREE — a single `inspectPath` lstat (NO connect), the SAME seam the pre-probe
233
+ // conflict uses — and `isSocketOnlyPiCandidate` promotes ONLY a confirmed non-symlink
234
+ // socket (`socket-file`); symlink/absent/not-socket/indeterminate stay a plain
235
+ // bad-target. PRESENT record → read (drift/corrupt throws = fail-loud).
236
+ if (!io.metaRecordExists(gid, sessionsDir)) {
237
+ const inspection = await io.inspectPath(controlSocketPath(gid, controlSocketDir));
238
+ return { identity: null, preProbeAddressConflict: false, socketOnlyPi: isSocketOnlyPiCandidate(inspection) };
239
+ }
240
+ const identity = io.readIdentity(gid, sessionsDir);
241
+ // `preProbeAddressConflict` is the record-side NON-PI conflict ONLY (B1). An in-domain
242
+ // (pi) target's socket lstat/connect MUST run UNDER the lock in the decider's later
243
+ // `inspectSocket` step (1C: lock BEFORE lstat/connect) — so a pi target short-circuits
244
+ // here with NO pre-lock lstat. Doing the lstat here would (a) be a needless pre-lock IO
245
+ // and (b) turn a pi target's `indeterminate` lstat into a top-level throw, stealing it
246
+ // from the under-lock `inspectSocket → indeterminate → indeterminate-no-spawn` path.
247
+ if (isLivenessSupported(identity.backend)) {
248
+ return { identity, preProbeAddressConflict: false };
249
+ }
250
+ // Only an unsupported (non-pi) citizen reaches the record-side lstat: a single lstat (no
251
+ // connect) of the canonical path; `indeterminate` fails loud (QB2 — never "no conflict").
252
+ const inspection = await io.inspectPath(controlSocketPath(gid, controlSocketDir));
253
+ const { socketGids, symlinkedGids } = conflictSetsFor(gid, inspection);
254
+ const preProbeAddressConflict = isNonPiGardenIdSocketConflict(identity.backend, gid, socketGids, symlinkedGids);
255
+ return { identity, preProbeAddressConflict };
256
+ };
257
+
258
+ const inspectSocket = (gid: string): Promise<TargetSocketInspection> => io.inspectSocket(gid, controlSocketDir);
259
+ const probeSocket = (socketPath: string): Promise<SocketLiveness> => io.probeSocket(socketPath);
260
+
261
+ // ── decider deps (5b) ─────────────────────────────────────────────────────
262
+ const deciderDeps: DispatchDeciderDeps = {
263
+ resolveTarget,
264
+ acquireLock: acquire,
265
+ releaseLock: release,
266
+ inspectSocket,
267
+ probeSocket,
268
+ preflightForCwd: (cwd: string): PreflightOutcome =>
269
+ io.preflight({ cwd, agentDir: opts.agentDir, prefixRoots: opts.prefixRoots }),
270
+ mailboxDeliverabilityFor,
271
+ mailboxDir,
272
+ sessionsDir,
273
+ observeTimeoutMs: opts.observeTimeoutMs,
274
+ };
275
+
276
+ // ── control-send hand deps (5c-2): the dead-fallback shares resolveTarget /
277
+ // inspect / probe / dirs with the decider; the mailbox enqueue is the SAME
278
+ // `sendViaMailbox` instance; the release is the SAME `release` closure. ─────
279
+ const controlSendDeps: ControlSocketSendDeps = {
280
+ sendOverSocket: async (plan: ControlSocketPlan): Promise<RpcSendResult> => {
281
+ const { response } = await io.sendRpc(plan.socketPath, {
282
+ type: "send",
283
+ message: plan.message,
284
+ mode: plan.mode,
285
+ wants_reply: plan.wantsReply,
286
+ sender: opts.senderProvider(),
287
+ });
288
+ return { success: response.success, error: response.error };
289
+ },
290
+ classifyConnect: io.classifyConnect,
291
+ releaseLock: release,
292
+ deadFallback: (plan: ControlSocketPlan, lock: LockClaim) =>
293
+ resolveDeadControlSendFallback(plan, lock, {
294
+ resolveTarget,
295
+ inspectSocket,
296
+ probeSocket,
297
+ mailboxDeliverabilityFor,
298
+ mailboxDir,
299
+ sessionsDir,
300
+ }),
301
+ sendViaMailbox,
302
+ };
303
+
304
+ // ── executor: the three transport hands, each pre-bound ───────────────────
305
+ const executor: DispatchExecutorDeps = {
306
+ sendControl: (plan, lock) => executeControlSocketSend(plan as ControlSocketPlan, lock, controlSendDeps),
307
+ resumeSpawnBg: (plan, lock) =>
308
+ executeSpawnBgResume(
309
+ plan as SpawnBgPlan,
310
+ lock,
311
+ // QB3: inject the shared lockDir-bound `release` — never the spawn factory's
312
+ // default releaseFn (which would release into the DEFAULT lock dir).
313
+ makeProductionSpawnBgResumeDeps({
314
+ ...io.spawnOverrides,
315
+ killGraceMs: opts.killGraceMs ?? io.spawnOverrides.killGraceMs,
316
+ releaseFn: release,
317
+ }),
318
+ ),
319
+ sendMailbox: (plan, _lock) => sendViaMailbox(plan as MetaMailboxPlan, _lock as LockClaim),
320
+ };
321
+
322
+ return {
323
+ decide: (input: DispatchInput) => decideDispatch(input, deciderDeps),
324
+ executor,
325
+ };
326
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * entwurf-v2-release — the PURE release-policy reducer for the 5c transport hand
3
+ * (0.11 Stage 0 step 5c-1). It answers ONE question with zero IO: given the plan
4
+ * the 5b decider chose and the lock it handed over, on WHICH execution event may
5
+ * the transport hand release that lock — and it guarantees the release fires
6
+ * AT MOST ONCE.
7
+ *
8
+ * Why a pure reducer before any transport IO (5b discipline = gate-first →
9
+ * pure-before-IO → wire): the load-bearing risk of 5c is NOT the spawn/send IO, it
10
+ * is the RELEASE TIMING (Fable 3, "release-after-observation"). A spawn-bg dispatch
11
+ * that releases its lock too early — on spawn-started/ack instead of on an observed
12
+ * liveness transition — reopens the exact double-spawn window 5a's per-gid lock
13
+ * exists to close. So the timing logic is isolated here as a pure state machine the
14
+ * gate drives over every event order, and 5c-2/5c-3/5c-4 only WIRE the real
15
+ * send/spawn/mailbox IO onto it. The hand feeds events; this module decides release.
16
+ *
17
+ * The three policies (one per ExecutionPlan transport):
18
+ * - no-lock — meta-mailbox (?7: no lock was ever held).
19
+ * Nothing to release on any event.
20
+ * - release-after-send-final — control-socket send. The lock is held only for
21
+ * the at-most-once re-resolve; release once the send reaches a FINAL outcome.
22
+ * - release-after-spawn-observation — spawn-bg resume. The lock is held until the
23
+ * FIRST observable transition: socket-alive OR child-exited (any code). A
24
+ * spawn-started event is explicitly NOT a release trigger (that is the whole
25
+ * point). A spawn that never started (spawn-start-failed) has no child to watch,
26
+ * so it releases to free the gid — keeping the lock would pin it forever.
27
+ *
28
+ * The release authority is ALWAYS the LockClaim the decider handed over (5c wires it
29
+ * through). This module never re-derives a lock by gid, never re-resolves a path —
30
+ * it is pure routing logic over opaque events.
31
+ */
32
+
33
+ import type { ExecutionPlan } from "./entwurf-v2-decider.ts";
34
+ import type { LockClaim } from "./entwurf-v2-lock.ts";
35
+
36
+ // ── ReleasePolicy (derived once from the plan + held lock) ───────────────────
37
+ export type ReleasePolicy =
38
+ | { kind: "no-lock" }
39
+ | { kind: "release-after-send-final" }
40
+ | { kind: "release-after-spawn-observation" };
41
+
42
+ // ── ReleaseEvent (the transport hand feeds these as execution proceeds) ──────
43
+ // `send-final` carries the terminal send outcome (legacy parity: a fallback-sent is
44
+ // a real final outcome, not a hidden retry). `child-exited` carries the code (null =
45
+ // killed by signal) but the policy releases on ANY code. `mailbox-enqueued` is the
46
+ // terminal ack on the lock-free path. `spawn-started`/`spawn-start-failed` bracket
47
+ // the spawn attempt — only the FAILURE finalizes; a successful start must wait for an
48
+ // observed transition.
49
+ export type ReleaseEvent =
50
+ | { kind: "send-final"; outcome: "sent" | "fallback-sent" | "rejected" | "failed" }
51
+ | { kind: "spawn-started"; pid: number }
52
+ | { kind: "spawn-start-failed"; error: string }
53
+ | { kind: "socket-alive" }
54
+ | { kind: "child-exited"; code: number | null }
55
+ | { kind: "mailbox-enqueued" };
56
+
57
+ // ── ReleaseState (single-release accumulator) ───────────────────────────────
58
+ export interface ReleaseState {
59
+ released: boolean;
60
+ }
61
+
62
+ export function initialReleaseState(): ReleaseState {
63
+ return { released: false };
64
+ }
65
+
66
+ /**
67
+ * An in-domain execute (control-socket / spawn-bg) must hold a lock whose gardenId
68
+ * IS the plan's target. Both halves are 5b decider-contract invariants, not runtime
69
+ * conditions: a null lock or a lock paired with the WRONG plan are the same grade of
70
+ * mis-wiring (a later release would free nothing, or free a DIFFERENT gid's lock), so
71
+ * both fail loud here rather than silently mis-releasing. Asserts the lock non-null
72
+ * so the caller may use it as a `LockClaim`.
73
+ */
74
+ function assertInDomainLock(plan: ExecutionPlan, lock: LockClaim | null): asserts lock is LockClaim {
75
+ if (lock === null) {
76
+ throw new Error(`entwurf-v2-release: an in-domain (${plan.transport}) execute must hold a lock.`);
77
+ }
78
+ if (lock.gardenId !== plan.targetGardenId) {
79
+ throw new Error(
80
+ `entwurf-v2-release: lock gardenId (${lock.gardenId}) does not match plan target (${plan.targetGardenId}) — mis-paired plan/lock.`,
81
+ );
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Derive the release policy from the plan, cross-checking the lock invariants the 5b
87
+ * decider guarantees: meta-mailbox ⇒ lock null (?7); in-domain (control-socket /
88
+ * spawn-bg) ⇒ lock non-null AND lock.gardenId === plan.targetGardenId. A mismatch is
89
+ * a decider contract violation, not a runtime condition — it throws so a mis-wired
90
+ * hand fails loud instead of silently dropping or mis-releasing a lock.
91
+ */
92
+ export function decideReleasePolicy(plan: ExecutionPlan, lock: LockClaim | null): ReleasePolicy {
93
+ switch (plan.transport) {
94
+ case "meta-mailbox":
95
+ if (lock !== null) {
96
+ throw new Error("entwurf-v2-release: a meta-mailbox plan must carry no lock (?7 invariant violated).");
97
+ }
98
+ return { kind: "no-lock" };
99
+ case "control-socket":
100
+ assertInDomainLock(plan, lock);
101
+ return { kind: "release-after-send-final" };
102
+ case "spawn-bg":
103
+ assertInDomainLock(plan, lock);
104
+ return { kind: "release-after-spawn-observation" };
105
+ }
106
+ }
107
+
108
+ export interface ReduceReleaseResult {
109
+ state: ReleaseState;
110
+ shouldRelease: boolean;
111
+ }
112
+
113
+ /**
114
+ * Fold one execution event into the release decision. `shouldRelease` is true
115
+ * EXACTLY ONCE — on the first event that satisfies the policy's release condition;
116
+ * every later event (after `state.released`) returns false. The single-release
117
+ * guarantee is what lets the spawn watcher race socket-alive against child-exit
118
+ * without a double release: whichever fires first releases, the other is a no-op.
119
+ */
120
+ export function reduceRelease(policy: ReleasePolicy, state: ReleaseState, event: ReleaseEvent): ReduceReleaseResult {
121
+ if (state.released) {
122
+ return { state, shouldRelease: false };
123
+ }
124
+ const release = (): ReduceReleaseResult => ({ state: { released: true }, shouldRelease: true });
125
+ const hold = (): ReduceReleaseResult => ({ state, shouldRelease: false });
126
+
127
+ switch (policy.kind) {
128
+ case "no-lock":
129
+ // No lock was ever held → nothing to release on any event.
130
+ return hold();
131
+ case "release-after-send-final":
132
+ // Release once the send reaches a final outcome; hold before that.
133
+ return event.kind === "send-final" ? release() : hold();
134
+ case "release-after-spawn-observation":
135
+ // Fable 3: spawn-started is NOT a release event. Release on the first
136
+ // observed transition (socket-alive / child-exited, any code), or on a
137
+ // failed start (no child to watch).
138
+ switch (event.kind) {
139
+ case "socket-alive":
140
+ case "child-exited":
141
+ case "spawn-start-failed":
142
+ return release();
143
+ case "spawn-started":
144
+ case "send-final":
145
+ case "mailbox-enqueued":
146
+ return hold();
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * entwurf-v2-resume-marker — the single source of truth for the env var name that a v2
3
+ * spawn-bg resume plants on its resume child to authorize it as an Entwurf-child resident.
4
+ *
5
+ * Why its own leaf module (no imports): the PRODUCER is `entwurf-v2-spawn-production.ts` (a
6
+ * lib module that imports siblings with `.ts` extensions, compiled by `scripts/tsconfig.json`
7
+ * with `allowImportingTsExtensions`), but the CONSUMER is `entwurf-control.ts` (the extension
8
+ * entry, compiled by the ROOT tsconfig WITHOUT that flag, importing siblings as `.js`).
9
+ * Importing the const straight from `entwurf-v2-spawn-production.ts` into `entwurf-control.ts`
10
+ * would drag the whole `.ts`-importing v2 decider/spawn subtree into the root program and
11
+ * trip TS5097 on every `.ts` extension. A zero-import leaf const is root-safe from either side.
12
+ *
13
+ * Meaning: when this env var equals the child's own sessionId, the child is the resume citizen
14
+ * a v2 spawn-bg resume promoted from a dormant `entwurf`-tagged session to a live
15
+ * `--entwurf-control` resident (an AUTHORIZED Entwurf child resident — keeps its `entwurf` tag,
16
+ * stays re-resumable once it dies). A human hand-opening the same session with
17
+ * `--entwurf-control` carries no marker → still a "corrupt resident session name" crash. The
18
+ * marker is sessionId-bound (not a generic boolean) so it authorizes only the exact session it
19
+ * was minted for; it is a wrong-surface crash guard, not a security boundary. See
20
+ * `entwurf-control.ts` `maybeSetResidentName` (consumer) and `entwurf-v2-spawn-production.ts`
21
+ * `makeProductionSpawnBgResumeDeps` (producer).
22
+ */
23
+ export const V2_RESUME_RESIDENT_SESSION_ENV = "ENTWURF_V2_RESUME_RESIDENT_SESSION_ID";
24
+
25
+ /**
26
+ * True when this process is the resume child a v2 spawn-bg resume launched for EXACTLY
27
+ * `sessionId` — the marker equals the child's own id. The pure SSOT of the authorization
28
+ * check (`env` is injectable so the guard is gate-provable without touching `process.env`).
29
+ * The binding is to the exact id, not a generic boolean: a present-but-different marker is
30
+ * NOT authorized, so the env var cannot be reused to wave a different session past the guard.
31
+ */
32
+ export function isV2ResumeResidentAuthorized(sessionId: string, env: NodeJS.ProcessEnv = process.env): boolean {
33
+ return env[V2_RESUME_RESIDENT_SESSION_ENV] === sessionId;
34
+ }