@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,406 @@
1
+ /**
2
+ * check-entwurf-v2-production — deterministic gate for the 5d-2b PRODUCTION deps assembly
3
+ * (`makeProductionEntwurfV2Deps`). It proves the WIRING — which closure/instance/dir reaches
4
+ * which hand — over fake leaf-IO spies, with NO real socket/lock/spawn/meta-record. Real
5
+ * transport behaviour is each hand's own gate + the 5d-5 matrix; this gate's job is the
6
+ * three production invariants the factory exists to guarantee:
7
+ *
8
+ * A. decide wraps decideDispatch, and the decider ACQUIRES under the wired `lockDir`
9
+ * (a pi-alive citizen → control-socket execute; acquireLock spy saw {dir: lockDir}).
10
+ * B. control `sendOverSocket` builds the RpcSendCommand (type/message/mode/wants_reply/
11
+ * sender) and maps response.success→outcome; the hand releases under `lockDir`.
12
+ * C. QB3 split-brain — the spawn watcher releases via the SHARED lockDir-bound `release`,
13
+ * NOT the spawn factory's default releaseFn (which would hit the DEFAULT lock dir). A
14
+ * throwing spawnChild → spawn-start-failed (released) → releaseLock spy saw {dir:lockDir}.
15
+ * D. the meta-mailbox hand enqueues onto the wired sessionsDir/mailboxDir.
16
+ * E. Q3 + Q5 — a dead control send re-resolves (claude-code citizen) to the mailbox and
17
+ * enqueues through the SAME sendViaMailbox instance (same enqueue spy) on the SAME dirs
18
+ * the direct hand used — direct send and fallback send never drift.
19
+ *
20
+ * No real IO — every seam is a spy; the factory's COMPOSITION is what is under test.
21
+ */
22
+
23
+ import assert from "node:assert/strict";
24
+ import type { DispatchInput } from "../pi-extensions/lib/entwurf-v2-decider.ts";
25
+ import type { AcquireLockResult, LockClaim } from "../pi-extensions/lib/entwurf-v2-lock.ts";
26
+ import {
27
+ makeProductionEntwurfV2Deps,
28
+ type ProductionEntwurfV2Opts,
29
+ } from "../pi-extensions/lib/entwurf-v2-production.ts";
30
+ import type { ControlSocketPlan, MetaMailboxPlan } from "../pi-extensions/lib/entwurf-v2-send.ts";
31
+ import type { SpawnBgPlan } from "../pi-extensions/lib/entwurf-v2-spawn.ts";
32
+ import type { MetaIdentity, MetaReceiverMarker } from "../pi-extensions/lib/meta-session.ts";
33
+ import type { TargetSocketInspection } from "../pi-extensions/lib/socket-discovery.ts";
34
+
35
+ let passed = 0;
36
+ function ok(label: string, cond: boolean): void {
37
+ assert.ok(cond, label);
38
+ console.log(` ok ${label}`);
39
+ passed++;
40
+ }
41
+
42
+ const GID = "20260613T100000-aaaaaa";
43
+ const LOCK_DIR = "/fake/locks";
44
+ const SESSIONS_DIR = "/fake/sessions";
45
+ const MAILBOX_DIR = "/fake/mailbox";
46
+ const CONTROL_DIR = "/fake/ctl";
47
+
48
+ function identity(backend: MetaIdentity["backend"], gardenId = GID): MetaIdentity {
49
+ return {
50
+ schemaVersion: 2,
51
+ gardenId,
52
+ backend,
53
+ nativeSessionId: "n",
54
+ cwd: "/home/junghan/repos/gh/entwurf",
55
+ model: null,
56
+ transcriptPath: null,
57
+ parentGardenId: null,
58
+ isEntwurf: false,
59
+ createdAt: "2026-06-13T01:00:00.000Z",
60
+ recordUpdatedAt: "2026-06-13T01:00:00.000Z",
61
+ };
62
+ }
63
+
64
+ /** A receiver presence marker. Matches the record identity (gardenId/backend/nativeSessionId)
65
+ * unless `nativeSessionId` is overridden to simulate an identity-drifted/foreign marker. */
66
+ function receiverMarker(gid: string, backend: string, nativeSessionId = "n"): MetaReceiverMarker {
67
+ return {
68
+ gardenId: gid,
69
+ backend: backend as MetaReceiverMarker["backend"],
70
+ nativeSessionId,
71
+ ownerPid: 1,
72
+ ownerStartKey: "x",
73
+ ownerKind: "claude-code-cli",
74
+ armProvenance: "session-start",
75
+ updatedAt: "t",
76
+ };
77
+ }
78
+
79
+ function lockClaim(gardenId = GID): LockClaim {
80
+ return {
81
+ gardenId,
82
+ pid: 4242,
83
+ hostname: "test-host",
84
+ createdAt: "2026-06-13T01:00:00.000Z",
85
+ nonce: "deadbeefcafef00d",
86
+ owner: "entwurf_v2",
87
+ lockPath: `${LOCK_DIR}/${gardenId}.lock`,
88
+ };
89
+ }
90
+
91
+ const CONTROL_PLAN: ControlSocketPlan = {
92
+ transport: "control-socket",
93
+ action: "send",
94
+ targetGardenId: GID,
95
+ socketPath: `${CONTROL_DIR}/${GID}.sock`,
96
+ mode: "follow_up",
97
+ wantsReply: true,
98
+ message: "hello",
99
+ };
100
+ const SPAWN_PLAN: SpawnBgPlan = {
101
+ transport: "spawn-bg",
102
+ action: "resume",
103
+ targetGardenId: GID,
104
+ sessionId: GID,
105
+ cwd: "/home/junghan/repos/gh/entwurf",
106
+ prompt: "p",
107
+ launchArgs: [],
108
+ expectedSocketPath: `${CONTROL_DIR}/${GID}.sock`,
109
+ observeTimeoutMs: 30_000,
110
+ releaseWhen: "socket-alive-or-child-exited",
111
+ };
112
+ const MAILBOX_PLAN: MetaMailboxPlan = {
113
+ transport: "meta-mailbox",
114
+ action: "send",
115
+ targetGardenId: GID,
116
+ mailboxDir: MAILBOX_DIR,
117
+ sessionsDir: SESSIONS_DIR,
118
+ wantsReply: false,
119
+ message: "m",
120
+ };
121
+
122
+ interface Spies {
123
+ acquire: { gid: string; dir: string | undefined }[];
124
+ release: { lockPath: string; dir: string | undefined }[];
125
+ enqueue: { gardenId: string; sessionsDir?: string; mailboxDir?: string }[];
126
+ rpc: { socketPath: string; command: Record<string, unknown> }[];
127
+ inspectPath: { socketPath: string }[];
128
+ }
129
+
130
+ /** Build a factory whose every leaf IO is a spy. `over` lets a case shape the decision
131
+ * (resolveTarget identity / socket inspection / rpc behaviour). */
132
+ function makeSpiedFactory(over: {
133
+ backend?: MetaIdentity["backend"];
134
+ recordExists?: boolean;
135
+ inspectKind?: TargetSocketInspection["kind"];
136
+ probe?: "alive" | "dead" | "indeterminate";
137
+ rpc?: "success" | "dead-throw";
138
+ classifyDead?: boolean;
139
+ spawnChildThrows?: boolean;
140
+ /** SE-2 2d-3 — the target's receiver presence marker: "active" (matches identity,
141
+ * default), "absent" (terminated/never-armed), or "mismatch" (drifted native id). */
142
+ receiverMarker?: "active" | "absent" | "mismatch";
143
+ }) {
144
+ const spies: Spies = { acquire: [], release: [], enqueue: [], rpc: [], inspectPath: [] };
145
+ const opts: ProductionEntwurfV2Opts = {
146
+ senderProvider: () => ({ sessionId: "self", agentId: "pi/x", cwd: "/cwd", timestamp: "2026-06-13T00:00:00.000Z" }),
147
+ lockDir: LOCK_DIR,
148
+ sessionsDir: SESSIONS_DIR,
149
+ mailboxDir: MAILBOX_DIR,
150
+ controlSocketDir: CONTROL_DIR,
151
+ seams: {
152
+ metaRecordExists: () => over.recordExists ?? true,
153
+ readIdentity: (gid) => identity(over.backend ?? "pi", gid),
154
+ readReceiverMarker: (gid) => {
155
+ if (over.receiverMarker === "absent") return null;
156
+ const nsid = over.receiverMarker === "mismatch" ? "DRIFT" : "n";
157
+ return receiverMarker(gid, over.backend ?? "pi", nsid);
158
+ },
159
+ inspectPath: async (socketPath) => {
160
+ spies.inspectPath.push({ socketPath });
161
+ if (over.inspectKind === "indeterminate") {
162
+ return { kind: "indeterminate", socketPath, error: "EACCES" };
163
+ }
164
+ if (over.inspectKind === "address-conflict") {
165
+ return { kind: "address-conflict", socketPath, reason: "symlink" };
166
+ }
167
+ return { kind: over.inspectKind === "socket-file" ? "socket-file" : "absent", socketPath };
168
+ },
169
+ acquireLock: (gid, deps): AcquireLockResult => {
170
+ spies.acquire.push({ gid, dir: deps.dir });
171
+ return { ok: true, claim: lockClaim(gid) };
172
+ },
173
+ releaseLock: (claim, deps) => {
174
+ spies.release.push({ lockPath: claim.lockPath, dir: deps.dir });
175
+ },
176
+ inspectSocket: async (_gid, _dir) =>
177
+ ({
178
+ kind: over.inspectKind ?? "socket-file",
179
+ socketPath: `${CONTROL_DIR}/${GID}.sock`,
180
+ }) as TargetSocketInspection,
181
+ probeSocket: async () => over.probe ?? "alive",
182
+ preflight: () => ({ kind: "approve" }) as never,
183
+ classifyConnect: () => (over.classifyDead ? "dead" : "indeterminate"),
184
+ sendRpc: async (socketPath, command) => {
185
+ spies.rpc.push({ socketPath, command: command as unknown as Record<string, unknown> });
186
+ if (over.rpc === "dead-throw") {
187
+ const e = new Error("refused") as NodeJS.ErrnoException;
188
+ e.code = "ECONNREFUSED";
189
+ throw e;
190
+ }
191
+ return { response: { type: "response", command: command.type, success: true } };
192
+ },
193
+ enqueue: (o) => {
194
+ spies.enqueue.push({ gardenId: o.gardenId, sessionsDir: o.sessionsDir, mailboxDir: o.mailboxDir });
195
+ return { gardenId: o.gardenId, recordPath: "r", messagePath: "m", signalPath: "s" };
196
+ },
197
+ spawnOverrides: {
198
+ spawnChild: over.spawnChildThrows
199
+ ? () => {
200
+ throw new Error("spawn boom");
201
+ }
202
+ : undefined,
203
+ },
204
+ },
205
+ };
206
+ return { deps: makeProductionEntwurfV2Deps(opts), spies };
207
+ }
208
+
209
+ async function main(): Promise<void> {
210
+ // ── A: decide wraps decideDispatch + acquires under lockDir ───────────────
211
+ {
212
+ const { deps, spies } = makeSpiedFactory({ backend: "pi", inspectKind: "socket-file", probe: "alive" });
213
+ const input: DispatchInput = { target: GID, intent: "fire-and-forget", message: "m" };
214
+ const decision = await deps.decide(input);
215
+ ok(
216
+ "A: decide → an alive pi citizen routes to control-socket execute",
217
+ decision.kind === "execute" && decision.plan.transport === "control-socket",
218
+ );
219
+ ok("A: decider ACQUIRED under the wired lockDir", spies.acquire.length === 1 && spies.acquire[0].dir === LOCK_DIR);
220
+ ok("A: B1 — a pi target does NO pre-lock lstat (inspectPath unused)", spies.inspectPath.length === 0);
221
+ }
222
+
223
+ // ── A2: B1 — a NON-pi target DOES record-side lstat the conflict (pre-lock) ─
224
+ {
225
+ // claude-code (unsupported) + a symlink at the canonical path → record-side conflict →
226
+ // the decider rejects target-address-conflict BEFORE acquiring (no lock for a quarantined
227
+ // address). The lstat-only inspectPath ran exactly once; acquire never did.
228
+ const { deps, spies } = makeSpiedFactory({ backend: "claude-code", inspectKind: "address-conflict" });
229
+ const decision = await deps.decide({ target: GID, intent: "fire-and-forget", message: "m" });
230
+ ok("A2: non-pi conflict → reject", decision.kind === "reject");
231
+ ok("A2: non-pi target lstat'd the conflict exactly once", spies.inspectPath.length === 1);
232
+ ok("A2: a quarantined target is never lock-acquired", spies.acquire.length === 0);
233
+ }
234
+
235
+ // ── A3: QB2 — a non-pi indeterminate lstat FAILS LOUD (never "no conflict") ─
236
+ {
237
+ const { deps } = makeSpiedFactory({ backend: "claude-code", inspectKind: "indeterminate" });
238
+ let threw = false;
239
+ try {
240
+ await deps.decide({ target: GID, intent: "fire-and-forget", message: "m" });
241
+ } catch {
242
+ threw = true;
243
+ }
244
+ ok("A3: non-pi indeterminate lstat → decide throws (QB2 fail-loud)", threw);
245
+ }
246
+
247
+ // ── B: control sendOverSocket builds RpcSendCommand + maps + lockDir release ─
248
+ {
249
+ const { deps, spies } = makeSpiedFactory({ rpc: "success" });
250
+ const res = await deps.executor.sendControl(CONTROL_PLAN, lockClaim());
251
+ ok("B: sendOverSocket called once", spies.rpc.length === 1);
252
+ const cmd = spies.rpc[0]?.command;
253
+ ok(
254
+ "B: RpcSendCommand carries type/message/mode/wants_reply/sender",
255
+ cmd?.type === "send" &&
256
+ cmd?.message === "hello" &&
257
+ cmd?.mode === "follow_up" &&
258
+ cmd?.wants_reply === true &&
259
+ typeof cmd?.sender === "object",
260
+ );
261
+ ok("B: response.success → outcome 'sent'", res.outcome === "sent");
262
+ ok(
263
+ "B: control hand released under the wired lockDir",
264
+ spies.release.length === 1 && spies.release[0].dir === LOCK_DIR,
265
+ );
266
+ }
267
+
268
+ // ── C: QB3 — spawn release uses the SHARED lockDir release, not the default ─
269
+ {
270
+ const { deps, spies } = makeSpiedFactory({ spawnChildThrows: true });
271
+ const res = await deps.executor.resumeSpawnBg(SPAWN_PLAN, lockClaim());
272
+ ok(
273
+ "C: throwing spawnChild → spawn-start-failed (released)",
274
+ res.kind === "spawn-start-failed" && res.released === true,
275
+ );
276
+ ok(
277
+ "C: QB3 — spawn watcher released under the wired lockDir (not the default)",
278
+ spies.release.length === 1 && spies.release[0].dir === LOCK_DIR,
279
+ );
280
+ }
281
+
282
+ // ── D: meta-mailbox hand enqueues onto the wired dirs ─────────────────────
283
+ {
284
+ const { deps, spies } = makeSpiedFactory({});
285
+ const res = await deps.executor.sendMailbox(MAILBOX_PLAN, null);
286
+ ok("D: mailbox send → success", res.success === true);
287
+ ok(
288
+ "D: enqueue onto the wired sessionsDir/mailboxDir",
289
+ spies.enqueue.length === 1 &&
290
+ spies.enqueue[0].sessionsDir === SESSIONS_DIR &&
291
+ spies.enqueue[0].mailboxDir === MAILBOX_DIR &&
292
+ spies.enqueue[0].gardenId === GID,
293
+ );
294
+ }
295
+
296
+ // ── E: Q3/Q5 — dead control send re-resolves to the SAME mailbox instance ──
297
+ {
298
+ // claude-code citizen (unsupported liveness, self-fetch deliverable) + a dead connect
299
+ // → the dead-fallback resolver routes to meta-mailbox → the control hand enqueues via
300
+ // the SAME sendViaMailbox instance the direct hand uses, on the SAME dirs.
301
+ const { deps, spies } = makeSpiedFactory({ backend: "claude-code", rpc: "dead-throw", classifyDead: true });
302
+ const res = await deps.executor.sendControl(CONTROL_PLAN, lockClaim());
303
+ ok("E: dead control send → fallback-sent via mailbox", res.outcome === "fallback-sent");
304
+ ok(
305
+ "E: shared sendViaMailbox — fallback enqueued through the SAME spy on the SAME dirs",
306
+ spies.enqueue.length === 1 &&
307
+ spies.enqueue[0].sessionsDir === SESSIONS_DIR &&
308
+ spies.enqueue[0].mailboxDir === MAILBOX_DIR &&
309
+ spies.enqueue[0].gardenId === GID,
310
+ );
311
+ ok(
312
+ "E: dead-path control hand released under the wired lockDir",
313
+ spies.release.length === 1 && spies.release[0].dir === LOCK_DIR,
314
+ );
315
+ }
316
+
317
+ // ── E2: SE-2 2d-3 — dead control send to a claude-code citizen whose receiver is
318
+ // INACTIVE (no presence marker) → the fallback re-resolves to mailbox-undeliverable,
319
+ // so the SHARED sendViaMailbox enqueue is NEVER called: no garbage in a terminated
320
+ // session's mailbox. The lock is still released under the wired lockDir. This is the v2
321
+ // closure of SE-2 — production's mailboxDeliverabilityFor seam gates the fallback the
322
+ // same way slice 2d-2 gates the v1 path. ───────────────────────────────────────────
323
+ {
324
+ const { deps, spies } = makeSpiedFactory({
325
+ backend: "claude-code",
326
+ rpc: "dead-throw",
327
+ classifyDead: true,
328
+ receiverMarker: "absent",
329
+ });
330
+ const res = await deps.executor.sendControl(CONTROL_PLAN, lockClaim());
331
+ ok(
332
+ "E2: inactive citizen → rejected (mailbox-undeliverable), not fallback-sent",
333
+ res.outcome === "rejected" && res.rejectReason === "mailbox-undeliverable",
334
+ );
335
+ ok("E2: SE-2 — shared enqueue NEVER called (no mailbox garbage)", spies.enqueue.length === 0);
336
+ ok(
337
+ "E2: lock still released under the wired lockDir",
338
+ spies.release.length === 1 && spies.release[0].dir === LOCK_DIR,
339
+ );
340
+ }
341
+
342
+ // ── E3: same gate via an identity-MISMATCHED marker (drifted native id) — a present
343
+ // marker that is not THIS receiver must not raise it to active. Proves the seam checks
344
+ // identity match, not mere marker presence. ─────────────────────────────────────────
345
+ {
346
+ const { deps, spies } = makeSpiedFactory({
347
+ backend: "claude-code",
348
+ rpc: "dead-throw",
349
+ classifyDead: true,
350
+ receiverMarker: "mismatch",
351
+ });
352
+ const res = await deps.executor.sendControl(CONTROL_PLAN, lockClaim());
353
+ ok("E3: drifted marker → rejected (not active)", res.outcome === "rejected");
354
+ ok("E3: drifted marker → enqueue NEVER called (presence ≠ identity match)", spies.enqueue.length === 0);
355
+ }
356
+
357
+ // ── F: A1 narrow (0.11.0) — record-LESS live pi control socket → socket-only target ──
358
+ // resolveTarget finds no meta-record, does ONE record-side lstat (inspectPath), sees a
359
+ // non-symlink socket → socketOnlyPi. fire-and-forget then routes to control-socket execute
360
+ // (in-domain: acquired under the wired lockDir). The same presence hint with a symlink /
361
+ // absent socket stays bad-target; owned-outcome on a LIVE socket-only target rejects
362
+ // owned-live-no-autosend POST-probe (lock acquired), never the pre-lock bad-target lie.
363
+ {
364
+ const { deps, spies } = makeSpiedFactory({ recordExists: false, inspectKind: "socket-file", probe: "alive" });
365
+ const decision = await deps.decide({ target: GID, intent: "fire-and-forget", message: "m" });
366
+ ok(
367
+ "F: recordless + live socket → control-socket execute (socket-only pi)",
368
+ decision.kind === "execute" && decision.plan.transport === "control-socket",
369
+ );
370
+ ok("F: resolveTarget did ONE record-side lstat (presence hint)", spies.inspectPath.length === 1);
371
+ ok(
372
+ "F: acquired under the wired lockDir (in-domain)",
373
+ spies.acquire.length === 1 && spies.acquire[0].dir === LOCK_DIR,
374
+ );
375
+ }
376
+ {
377
+ // record absent + a SYMLINKED socket → NOT promoted (never trust a symlink) → bad-target.
378
+ const { deps, spies } = makeSpiedFactory({ recordExists: false, inspectKind: "address-conflict" });
379
+ const decision = await deps.decide({ target: GID, intent: "fire-and-forget", message: "m" });
380
+ ok("F: recordless + symlinked socket → reject bad-target", decision.kind === "reject");
381
+ ok("F: symlinked socket-only is never lock-acquired", spies.acquire.length === 0);
382
+ }
383
+ {
384
+ // record absent + NO socket at all → plain bad-target.
385
+ const { deps, spies } = makeSpiedFactory({ recordExists: false, inspectKind: "absent" });
386
+ const decision = await deps.decide({ target: GID, intent: "fire-and-forget", message: "m" });
387
+ ok("F: recordless + no socket → reject bad-target", decision.kind === "reject");
388
+ ok("F: no-socket target is never lock-acquired", spies.acquire.length === 0);
389
+ }
390
+ {
391
+ // owned-outcome on a record-less LIVE socket → owned-live-no-autosend (honest table
392
+ // verdict), NOT the `bad-target` lie. It runs the in-domain path (lock acquired + probed),
393
+ // then the table rejects owned×live — a live citizen is never reported absent.
394
+ const { deps, spies } = makeSpiedFactory({ recordExists: false, inspectKind: "socket-file", probe: "alive" });
395
+ const decision = await deps.decide({ target: GID, intent: "owned-outcome", message: "do X" });
396
+ ok(
397
+ "F: socket-only + owned-outcome(live) → reject owned-live-no-autosend (NOT bad-target)",
398
+ decision.kind === "reject" && decision.receipt.reason === "owned-live-no-autosend",
399
+ );
400
+ ok("F: socket-only owned-outcome(live) acquires a lock (in-domain, no pre-lock lie)", spies.acquire.length === 1);
401
+ }
402
+
403
+ console.log(`\ncheck-entwurf-v2-production: ${passed} checks passed`);
404
+ }
405
+
406
+ await main();
@@ -0,0 +1,245 @@
1
+ /**
2
+ * check-entwurf-v2-release — deterministic gate for the PURE release-policy reducer
3
+ * (0.11 Stage 0 step 5c-1). Proves the Fable-3 "release-after-observation" timing as
4
+ * a pure state machine BEFORE any spawn/send IO exists:
5
+ *
6
+ * 1. decideReleasePolicy maps each transport correctly and ENFORCES the lock
7
+ * invariants (meta-mailbox ⇒ null else throw; in-domain ⇒ non-null AND
8
+ * lock.gardenId === plan.targetGardenId, else throw).
9
+ * 2. no-lock policy NEVER releases — on any event.
10
+ * 3. control-socket holds before send-final, releases EXACTLY ONCE on send-final
11
+ * (every terminal outcome), holds after.
12
+ * 4. spawn-bg: spawn-started ALONE does NOT release (the load-bearing rule).
13
+ * 5. spawn-bg: first socket-alive releases.
14
+ * 6. spawn-bg: first child-exited (any code: 0 / non-zero / null-signal) releases.
15
+ * 7. spawn-bg: socket↔exit race is idempotent — one release regardless of order.
16
+ * 8. spawn-bg: spawn-start-failed finalizes (no child to watch) with one release.
17
+ * 9. single-release: after a release, no later event releases again.
18
+ *
19
+ * No IO — the reducer is pure; the gate folds event sequences and checks the
20
+ * shouldRelease transitions.
21
+ */
22
+
23
+ import assert from "node:assert/strict";
24
+ import type { ExecutionPlan } from "../pi-extensions/lib/entwurf-v2-decider.ts";
25
+ import type { LockClaim } from "../pi-extensions/lib/entwurf-v2-lock.ts";
26
+ import {
27
+ decideReleasePolicy,
28
+ initialReleaseState,
29
+ type ReleaseEvent,
30
+ type ReleasePolicy,
31
+ reduceRelease,
32
+ } from "../pi-extensions/lib/entwurf-v2-release.ts";
33
+
34
+ let passed = 0;
35
+ function ok(label: string, cond: boolean): void {
36
+ assert.ok(cond, label);
37
+ console.log(` ok ${label}`);
38
+ passed++;
39
+ }
40
+
41
+ const GID = "20260612T100000-aaaaaa";
42
+
43
+ function lockClaim(): LockClaim {
44
+ return {
45
+ gardenId: GID,
46
+ pid: 4242,
47
+ hostname: "test-host",
48
+ createdAt: "2026-06-12T01:00:00.000Z",
49
+ nonce: "deadbeefcafef00d",
50
+ owner: "entwurf_v2",
51
+ lockPath: `/fake/locks/${GID}.lock`,
52
+ };
53
+ }
54
+
55
+ const MAILBOX_PLAN: ExecutionPlan = {
56
+ transport: "meta-mailbox",
57
+ action: "send",
58
+ targetGardenId: GID,
59
+ mailboxDir: "/fake/mailbox",
60
+ sessionsDir: "/fake/sessions",
61
+ wantsReply: false,
62
+ message: "m",
63
+ };
64
+ const CONTROL_PLAN: ExecutionPlan = {
65
+ transport: "control-socket",
66
+ action: "send",
67
+ targetGardenId: GID,
68
+ socketPath: "/fake/ctl/s.sock",
69
+ mode: "follow_up",
70
+ wantsReply: false,
71
+ message: "m",
72
+ };
73
+ const SPAWN_PLAN: ExecutionPlan = {
74
+ transport: "spawn-bg",
75
+ action: "resume",
76
+ targetGardenId: GID,
77
+ sessionId: GID,
78
+ cwd: "/home/junghan/repos/gh/entwurf",
79
+ prompt: "p",
80
+ launchArgs: ["--approve"],
81
+ expectedSocketPath: "/fake/ctl/s.sock",
82
+ observeTimeoutMs: 30_000,
83
+ releaseWhen: "socket-alive-or-child-exited",
84
+ };
85
+
86
+ // Fold a sequence of events; return the shouldRelease flag emitted per event.
87
+ function fold(policy: ReleasePolicy, events: ReleaseEvent[]): boolean[] {
88
+ let state = initialReleaseState();
89
+ const flags: boolean[] = [];
90
+ for (const ev of events) {
91
+ const r = reduceRelease(policy, state, ev);
92
+ state = r.state;
93
+ flags.push(r.shouldRelease);
94
+ }
95
+ return flags;
96
+ }
97
+
98
+ function throws(fn: () => unknown): boolean {
99
+ try {
100
+ fn();
101
+ return false;
102
+ } catch {
103
+ return true;
104
+ }
105
+ }
106
+
107
+ // ── 1: decideReleasePolicy mapping + lock-nullness enforcement ───────────────
108
+ ok("policy: meta-mailbox + null → no-lock", decideReleasePolicy(MAILBOX_PLAN, null).kind === "no-lock");
109
+ ok(
110
+ "policy: control-socket + lock → release-after-send-final",
111
+ decideReleasePolicy(CONTROL_PLAN, lockClaim()).kind === "release-after-send-final",
112
+ );
113
+ ok(
114
+ "policy: spawn-bg + lock → release-after-spawn-observation",
115
+ decideReleasePolicy(SPAWN_PLAN, lockClaim()).kind === "release-after-spawn-observation",
116
+ );
117
+ ok(
118
+ "policy: meta-mailbox + lock → throws (?7 violated)",
119
+ throws(() => decideReleasePolicy(MAILBOX_PLAN, lockClaim())),
120
+ );
121
+ ok(
122
+ "policy: control-socket + null → throws (must hold lock)",
123
+ throws(() => decideReleasePolicy(CONTROL_PLAN, null)),
124
+ );
125
+ ok(
126
+ "policy: spawn-bg + null → throws (must hold lock)",
127
+ throws(() => decideReleasePolicy(SPAWN_PLAN, null)),
128
+ );
129
+ // in-domain lock whose gardenId ≠ plan target = mis-paired plan/lock (same grade as
130
+ // a null lock — a later release would free a DIFFERENT gid). Fail loud.
131
+ const WRONG_GID = "20260612T999999-bbbbbb";
132
+ ok(
133
+ "policy: control-socket + mismatched lock gid → throws",
134
+ throws(() => decideReleasePolicy(CONTROL_PLAN, { ...lockClaim(), gardenId: WRONG_GID })),
135
+ );
136
+ ok(
137
+ "policy: spawn-bg + mismatched lock gid → throws",
138
+ throws(() => decideReleasePolicy(SPAWN_PLAN, { ...lockClaim(), gardenId: WRONG_GID })),
139
+ );
140
+
141
+ // ── 2: no-lock NEVER releases ────────────────────────────────────────────────
142
+ {
143
+ const policy: ReleasePolicy = { kind: "no-lock" };
144
+ const flags = fold(policy, [
145
+ { kind: "mailbox-enqueued" },
146
+ { kind: "send-final", outcome: "sent" },
147
+ { kind: "socket-alive" },
148
+ { kind: "child-exited", code: 0 },
149
+ { kind: "spawn-started", pid: 1 },
150
+ ]);
151
+ ok(
152
+ "no-lock: never releases on any event",
153
+ flags.every((f) => f === false),
154
+ );
155
+ }
156
+
157
+ // ── 3: control-socket holds before send-final, releases once, holds after ────
158
+ {
159
+ const policy: ReleasePolicy = { kind: "release-after-send-final" };
160
+ ok("control: spawn-started before final → hold", fold(policy, [{ kind: "spawn-started", pid: 1 }])[0] === false);
161
+ ok("control: socket-alive before final → hold", fold(policy, [{ kind: "socket-alive" }])[0] === false);
162
+ for (const outcome of ["sent", "fallback-sent", "rejected", "failed"] as const) {
163
+ const flags = fold(policy, [
164
+ { kind: "send-final", outcome },
165
+ { kind: "send-final", outcome },
166
+ ]);
167
+ ok(`control: send-final(${outcome}) releases exactly once`, flags[0] === true && flags[1] === false);
168
+ }
169
+ }
170
+
171
+ // ── 4: spawn-bg — spawn-started ALONE does NOT release (load-bearing) ─────────
172
+ {
173
+ const policy: ReleasePolicy = { kind: "release-after-spawn-observation" };
174
+ ok("spawn: spawn-started alone → NO release", fold(policy, [{ kind: "spawn-started", pid: 99 }])[0] === false);
175
+ ok(
176
+ "spawn: spawn-started then socket-alive → release only on observation",
177
+ (() => {
178
+ const f = fold(policy, [{ kind: "spawn-started", pid: 99 }, { kind: "socket-alive" }]);
179
+ return f[0] === false && f[1] === true;
180
+ })(),
181
+ );
182
+ }
183
+
184
+ // ── 5: spawn-bg — first socket-alive releases ────────────────────────────────
185
+ {
186
+ const policy: ReleasePolicy = { kind: "release-after-spawn-observation" };
187
+ ok("spawn: socket-alive releases", fold(policy, [{ kind: "socket-alive" }])[0] === true);
188
+ }
189
+
190
+ // ── 6: spawn-bg — first child-exited (any code) releases ─────────────────────
191
+ {
192
+ const policy: ReleasePolicy = { kind: "release-after-spawn-observation" };
193
+ for (const code of [0, 1, 137, null] as const) {
194
+ ok(`spawn: child-exited(code=${code}) releases`, fold(policy, [{ kind: "child-exited", code }])[0] === true);
195
+ }
196
+ }
197
+
198
+ // ── 7: spawn-bg — socket↔exit race idempotent (one release, either order) ────
199
+ {
200
+ const policy: ReleasePolicy = { kind: "release-after-spawn-observation" };
201
+ const socketThenExit = fold(policy, [{ kind: "socket-alive" }, { kind: "child-exited", code: 0 }]);
202
+ ok(
203
+ "spawn: socket-alive then child-exited → release only on first",
204
+ socketThenExit[0] === true && socketThenExit[1] === false,
205
+ );
206
+ const exitThenSocket = fold(policy, [{ kind: "child-exited", code: 1 }, { kind: "socket-alive" }]);
207
+ ok(
208
+ "spawn: child-exited then socket-alive → release only on first",
209
+ exitThenSocket[0] === true && exitThenSocket[1] === false,
210
+ );
211
+ }
212
+
213
+ // ── 8: spawn-bg — spawn-start-failed finalizes (no child to watch) ───────────
214
+ {
215
+ const policy: ReleasePolicy = { kind: "release-after-spawn-observation" };
216
+ ok(
217
+ "spawn: spawn-start-failed releases (frees the gid, no child)",
218
+ fold(policy, [{ kind: "spawn-start-failed", error: "ENOENT" }])[0] === true,
219
+ );
220
+ const startedThenFailedImpossibleButSafe = fold(policy, [
221
+ { kind: "spawn-started", pid: 1 },
222
+ { kind: "spawn-start-failed", error: "late" },
223
+ ]);
224
+ ok(
225
+ "spawn: started(hold) then start-failed(release) — still one release",
226
+ startedThenFailedImpossibleButSafe[0] === false && startedThenFailedImpossibleButSafe[1] === true,
227
+ );
228
+ }
229
+
230
+ // ── 9: single-release — after release, no later event releases again ─────────
231
+ {
232
+ const policy: ReleasePolicy = { kind: "release-after-spawn-observation" };
233
+ const flags = fold(policy, [
234
+ { kind: "socket-alive" },
235
+ { kind: "child-exited", code: 0 },
236
+ { kind: "child-exited", code: 1 },
237
+ { kind: "socket-alive" },
238
+ ]);
239
+ ok(
240
+ "single-release: exactly one release across many triggers",
241
+ flags.filter((f) => f).length === 1 && flags[0] === true,
242
+ );
243
+ }
244
+
245
+ console.log(`\n[check-entwurf-v2-release] ${passed} assertions ok`);