@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,368 @@
1
+ /**
2
+ * check-entwurf-v2-lock — deterministic gate for 0.11 Stage 0 step 5a (버킷 B F2):
3
+ * the per-gid dispatch lock primitive. The lockfile is the ONLY guard against a
4
+ * double-spawn of the same dormant target (pi self-guards CREATE but not RESUME,
5
+ * 검증원장 F2), so this gate pins every invariant the ledger froze.
6
+ *
7
+ * Real temp dir (not faked): the `openSync(lockPath,"wx")` OS-level atomicity is
8
+ * the thing under test, so the directory is real; only the CONTENT-shaping deps
9
+ * (clock / nonce / pid / hostname / kill) are injected for determinism.
10
+ *
11
+ * Proves:
12
+ * - acquire writes a well-formed claim; a second acquire (no release) is a
13
+ * target-locked conflict (mutual exclusion), carrying the holder evidence.
14
+ * - release unlinks ONLY when the on-disk nonce is still ours: a successor's
15
+ * re-acquire (different nonce) survives a late release (not-owned); a gone
16
+ * lock is absent.
17
+ * - stale reclaim ONLY for same host + ESRCH; EPERM (other user's live pid),
18
+ * a different hostname, and an alive pid all fail-closed to conflict (the
19
+ * ESRCH-only branch is easy to drop — F2-P2 pins EPERM explicitly).
20
+ * - an empty/corrupt lockfile surfaces as a conflict (holder=null), never
21
+ * auto-deleted.
22
+ * - F2-P1: a malformed gid throws before any path is built (no traversal sink).
23
+ * - no drift: LOCK_CONFLICT_REASON is exactly the contract's target-locked.
24
+ */
25
+
26
+ import assert from "node:assert/strict";
27
+ import * as fs from "node:fs";
28
+ import * as os from "node:os";
29
+ import * as path from "node:path";
30
+ import { ENTWURF_V2_REJECT_REASONS } from "../pi-extensions/lib/entwurf-v2-contract.ts";
31
+ import {
32
+ acquireLock,
33
+ classifyProcessLiveness,
34
+ LOCK_CONFLICT_REASON,
35
+ LOCK_OWNER,
36
+ type LockClaim,
37
+ lockPathFor,
38
+ releaseLock,
39
+ } from "../pi-extensions/lib/entwurf-v2-lock.ts";
40
+
41
+ let passed = 0;
42
+ function ok(label: string, cond: boolean): void {
43
+ assert.ok(cond, label);
44
+ console.log(` ok ${label}`);
45
+ passed++;
46
+ }
47
+ function eq(label: string, actual: unknown, expected: unknown): void {
48
+ assert.deepStrictEqual(actual, expected, label);
49
+ console.log(` ok ${label}`);
50
+ passed++;
51
+ }
52
+
53
+ const GID_A = "20260612T101010-aaaaaa";
54
+ const GID_B = "20260612T101010-bbbbbb";
55
+ const HOST = "test-host";
56
+
57
+ // Injected kill(pid,0) surfaces — the holder pid in these tests is a FAKE number,
58
+ // so the default `process.kill` must NOT be used (a fake pid usually reads ESRCH
59
+ // on the real host → would silently reclaim). Every acquire injects one of these.
60
+ const killers = {
61
+ alive: () => {
62
+ /* returns normally */
63
+ },
64
+ esrch: () => {
65
+ const e = new Error("no such process") as NodeJS.ErrnoException;
66
+ e.code = "ESRCH";
67
+ throw e;
68
+ },
69
+ eperm: () => {
70
+ const e = new Error("operation not permitted") as NodeJS.ErrnoException;
71
+ e.code = "EPERM";
72
+ throw e;
73
+ },
74
+ unknown: () => {
75
+ const e = new Error("???") as NodeJS.ErrnoException;
76
+ e.code = "EWHAT";
77
+ throw e;
78
+ },
79
+ };
80
+
81
+ let seq = 0;
82
+ function fixedDeps(over: Partial<Parameters<typeof acquireLock>[1]> = {}) {
83
+ // distinct nonce per acquire (vary by index — Date.now/Math.random unavailable),
84
+ // fixed clock + pid + hostname for content determinism. Default killFn = alive
85
+ // so an existing holder is NOT spuriously reclaimed (reclaim tests override).
86
+ seq += 1;
87
+ const n = seq;
88
+ return {
89
+ now: () => "2026-06-12T00:00:00.000Z",
90
+ nonce: () => `nonce-${n}`,
91
+ pid: 4242,
92
+ hostname: HOST,
93
+ killFn: killers.alive,
94
+ ...over,
95
+ };
96
+ }
97
+
98
+ function withTempDir(fn: (dir: string) => void): void {
99
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-v2-lock-"));
100
+ try {
101
+ fn(dir);
102
+ } finally {
103
+ fs.rmSync(dir, { recursive: true, force: true });
104
+ }
105
+ }
106
+
107
+ // ── no drift: the conflict reason IS the contract's target-locked ──────────
108
+ eq("LOCK_CONFLICT_REASON === 'target-locked' (contract member)", LOCK_CONFLICT_REASON, "target-locked");
109
+ ok(
110
+ "target-locked is in ENTWURF_V2_REJECT_REASONS (no drift)",
111
+ (ENTWURF_V2_REJECT_REASONS as readonly string[]).includes(LOCK_CONFLICT_REASON),
112
+ );
113
+
114
+ // ── F2-P1: malformed gid throws before building a path ─────────────────────
115
+ for (const bad of ["", "../escape", "not-a-gid", "20260612T101010-AAAAAA", "20260612T101010-aaaaa"]) {
116
+ assert.throws(() => lockPathFor(bad), /invalid garden id/, `lockPathFor('${bad}') throws`);
117
+ console.log(` ok F2-P1: lockPathFor('${bad}') refuses to build a path`);
118
+ passed++;
119
+ }
120
+ ok("F2-P1: a valid gid builds a <gid>.lock path", lockPathFor(GID_A, "/d").endsWith(`${GID_A}.lock`));
121
+
122
+ // ── classifyProcessLiveness: ESRCH=dead, EPERM=denied, ok=alive, unknown=alive ──
123
+ eq("classify: alive pid → alive", classifyProcessLiveness(1, killers.alive), "alive");
124
+ eq("classify: ESRCH → dead (reclaimable)", classifyProcessLiveness(1, killers.esrch), "dead");
125
+ eq("classify: EPERM → denied (NOT reclaimable, fail-closed)", classifyProcessLiveness(1, killers.eperm), "denied");
126
+ eq(
127
+ "classify: unknown error → alive (fail-closed, never reclaim)",
128
+ classifyProcessLiveness(1, killers.unknown),
129
+ "alive",
130
+ );
131
+
132
+ // ── acquire writes a well-formed claim ─────────────────────────────────────
133
+ withTempDir((dir) => {
134
+ const res = acquireLock(GID_A, fixedDeps({ dir }));
135
+ ok("acquire: first acquire succeeds", res.ok === true);
136
+ if (res.ok) {
137
+ eq("acquire: claim.gardenId", res.claim.gardenId, GID_A);
138
+ eq("acquire: claim.owner", res.claim.owner, LOCK_OWNER);
139
+ eq("acquire: claim.pid", res.claim.pid, 4242);
140
+ eq("acquire: claim.hostname", res.claim.hostname, HOST);
141
+ ok("acquire: lockfile exists on disk", fs.existsSync(res.claim.lockPath));
142
+ const onDisk = JSON.parse(fs.readFileSync(res.claim.lockPath, "utf8"));
143
+ eq("acquire: on-disk nonce matches claim", onDisk.nonce, res.claim.nonce);
144
+ }
145
+ });
146
+
147
+ // ── mutual exclusion: a second acquire without release = target-locked ─────
148
+ withTempDir((dir) => {
149
+ const first = acquireLock(GID_A, fixedDeps({ dir }));
150
+ ok("mutex: first acquire ok", first.ok === true);
151
+ const second = acquireLock(GID_A, fixedDeps({ dir }));
152
+ ok("mutex: second acquire (no release) = conflict", second.ok === false);
153
+ if (!second.ok) {
154
+ eq("mutex: conflict reason = target-locked", second.conflict.reason, "target-locked");
155
+ ok("mutex: conflict carries holder evidence (F2-P2 human cleanup)", second.conflict.holder !== null);
156
+ ok(
157
+ "mutex: conflict.detail names pid + host",
158
+ /pid 4242/.test(second.conflict.detail) && second.conflict.detail.includes(HOST),
159
+ );
160
+ if (first.ok && second.conflict.holder) {
161
+ eq("mutex: holder nonce === first claim nonce", second.conflict.holder.nonce, first.claim.nonce);
162
+ }
163
+ }
164
+ // a DIFFERENT gid is independent — no false conflict.
165
+ const other = acquireLock(GID_B, fixedDeps({ dir }));
166
+ ok("mutex: a different gid locks independently", other.ok === true);
167
+ });
168
+
169
+ // ── release: nonce-owned only ──────────────────────────────────────────────
170
+ withTempDir((dir) => {
171
+ const a = acquireLock(GID_A, fixedDeps({ dir }));
172
+ ok("release: acquired", a.ok === true);
173
+ if (!a.ok) return;
174
+ eq("release: nonce-owned unlink = released", releaseLock(a.claim), "released");
175
+ ok("release: lockfile gone after release", !fs.existsSync(a.claim.lockPath));
176
+ eq("release: releasing an already-gone lock = absent", releaseLock(a.claim), "absent");
177
+ // after release the gid is free again.
178
+ const b = acquireLock(GID_A, fixedDeps({ dir }));
179
+ ok("release: gid re-acquirable after release", b.ok === true);
180
+ });
181
+
182
+ // ── release: a successor's claim (different nonce) survives a late release ──
183
+ withTempDir((dir) => {
184
+ const stale = acquireLock(GID_A, fixedDeps({ dir }));
185
+ ok("late-release: holder acquired", stale.ok === true);
186
+ if (!stale.ok) return;
187
+ // simulate the holder dying (same host) and a successor reclaiming:
188
+ const successor = acquireLock(
189
+ GID_A,
190
+ fixedDeps({ dir, killFn: killers.esrch }), // the existing holder's pid reads ESRCH → reclaim
191
+ );
192
+ ok("late-release: successor reclaims a dead-pid lock", successor.ok === true);
193
+ if (!successor.ok) return;
194
+ ok("late-release: successor has a DIFFERENT nonce", successor.claim.nonce !== stale.claim.nonce);
195
+ // the original holder's late release must NOT delete the successor's lock.
196
+ eq("late-release: stale holder's release = not-owned (nonce mismatch)", releaseLock(stale.claim), "not-owned");
197
+ ok("late-release: successor lock still present", fs.existsSync(successor.claim.lockPath));
198
+ const onDisk: LockClaim | null = JSON.parse(fs.readFileSync(successor.claim.lockPath, "utf8"));
199
+ eq("late-release: on-disk nonce is the successor's", onDisk?.nonce, successor.claim.nonce);
200
+ });
201
+
202
+ // ── stale reclaim policy: same host + ESRCH only ───────────────────────────
203
+ withTempDir((dir) => {
204
+ // holder is on a DIFFERENT host → never reclaimed even if its pid looks dead.
205
+ const remote = acquireLock(GID_A, fixedDeps({ dir, hostname: "other-host" }));
206
+ ok("reclaim: remote-host holder acquired", remote.ok === true);
207
+ const sameHostDeadPid = acquireLock(GID_A, fixedDeps({ dir, hostname: HOST, killFn: killers.esrch }));
208
+ ok("reclaim: different hostname is NOT reclaimed (conflict) even with ESRCH", sameHostDeadPid.ok === false);
209
+ });
210
+ withTempDir((dir) => {
211
+ // same host, but the holder pid is EPERM (another user's live pid) → fail-closed.
212
+ const held = acquireLock(GID_A, fixedDeps({ dir }));
213
+ ok("reclaim: holder acquired", held.ok === true);
214
+ const epermContender = acquireLock(GID_A, fixedDeps({ dir, killFn: killers.eperm }));
215
+ ok("reclaim: EPERM holder is NOT reclaimed (fail-closed conflict)", epermContender.ok === false);
216
+ });
217
+ withTempDir((dir) => {
218
+ // same host, holder pid still ALIVE → not reclaimed.
219
+ const held = acquireLock(GID_A, fixedDeps({ dir }));
220
+ ok("reclaim: holder acquired", held.ok === true);
221
+ const aliveContender = acquireLock(GID_A, fixedDeps({ dir, killFn: killers.alive }));
222
+ ok("reclaim: live holder is NOT reclaimed (conflict)", aliveContender.ok === false);
223
+ });
224
+ withTempDir((dir) => {
225
+ // same host + ESRCH → reclaimed.
226
+ const dead = acquireLock(GID_A, fixedDeps({ dir }));
227
+ ok("reclaim: dead holder acquired", dead.ok === true);
228
+ const reclaimer = acquireLock(GID_A, fixedDeps({ dir, killFn: killers.esrch }));
229
+ ok("reclaim: same host + ESRCH IS reclaimed (success)", reclaimer.ok === true);
230
+ });
231
+
232
+ // ── empty/corrupt lockfile → conflict, never auto-deleted ──────────────────
233
+ withTempDir((dir) => {
234
+ const lockPath = lockPathFor(GID_A, dir);
235
+ fs.mkdirSync(dir, { recursive: true });
236
+ fs.writeFileSync(lockPath, ""); // crash between open-wx and write
237
+ const res = acquireLock(GID_A, fixedDeps({ dir, killFn: killers.esrch }));
238
+ ok("corrupt: empty lockfile → conflict (not reclaimed by pid heuristic)", res.ok === false);
239
+ if (!res.ok) {
240
+ eq("corrupt: holder is null (unparseable)", res.conflict.holder, null);
241
+ ok("corrupt: detail flags empty/corrupt", /empty, corrupt/.test(res.conflict.detail));
242
+ ok("corrupt: detail carries file mtime (only age signal when body unreadable)", /mtime/.test(res.conflict.detail));
243
+ }
244
+ ok("corrupt: lockfile NOT auto-deleted (could be mid-write)", fs.existsSync(lockPath));
245
+ });
246
+ withTempDir((dir) => {
247
+ const lockPath = lockPathFor(GID_A, dir);
248
+ fs.mkdirSync(dir, { recursive: true });
249
+ fs.writeFileSync(lockPath, "{ not json"); // garbage
250
+ const res = acquireLock(GID_A, fixedDeps({ dir }));
251
+ ok("corrupt: garbage lockfile → conflict", res.ok === false);
252
+ ok("corrupt: garbage lockfile preserved", fs.existsSync(lockPath));
253
+ });
254
+
255
+ // ── L3: gardenId mismatch (path authority = gid) → conflict, not reclaimed ──
256
+ withTempDir((dir) => {
257
+ const lockPath = lockPathFor(GID_A, dir);
258
+ fs.mkdirSync(dir, { recursive: true });
259
+ // a <GID_A>.lock whose body claims gardenId GID_B — a corrupt address.
260
+ const wrong = {
261
+ gardenId: GID_B,
262
+ pid: 4242,
263
+ hostname: HOST,
264
+ createdAt: "x",
265
+ nonce: "n",
266
+ owner: "entwurf_v2",
267
+ lockPath,
268
+ };
269
+ fs.writeFileSync(lockPath, JSON.stringify(wrong));
270
+ // even with a dead-pid heuristic, a mismatched gid must NOT be reclaimed.
271
+ const res = acquireLock(GID_A, fixedDeps({ dir, killFn: killers.esrch }));
272
+ ok("gid-mismatch: <A>.lock holding gardenId B → conflict", res.ok === false);
273
+ if (!res.ok) eq("gid-mismatch: treated as corrupt (holder null)", res.conflict.holder, null);
274
+ ok("gid-mismatch: lockfile preserved (not reclaimed)", fs.existsSync(lockPath));
275
+ });
276
+
277
+ // ── L1: reclaim under a wx mutex — closes the F2 two-reclaimer race ─────────
278
+ // (A) a pre-existing reclaim marker = a concurrent reclaimer mid-reclaim (or a
279
+ // stale marker) → the second reclaimer is excluded, fail-closed to conflict.
280
+ withTempDir((dir) => {
281
+ const lockPath = lockPathFor(GID_A, dir);
282
+ const held = acquireLock(GID_A, fixedDeps({ dir }));
283
+ ok("reclaim-mutex: holder acquired", held.ok === true);
284
+ fs.writeFileSync(`${lockPath}.reclaim`, ""); // a reclaimer already holds the mutex
285
+ const second = acquireLock(GID_A, fixedDeps({ dir, killFn: killers.esrch }));
286
+ ok("reclaim-mutex: marker present → second reclaimer excluded (conflict)", second.ok === false);
287
+ if (!second.ok)
288
+ ok("reclaim-mutex: detail flags reclaim-in-progress", /reclaim already in progress/.test(second.conflict.detail));
289
+ });
290
+ // (B) the dead lock CHANGES under the mutex (re-read nonce mismatch) → abort.
291
+ withTempDir((dir) => {
292
+ const stale = acquireLock(GID_A, fixedDeps({ dir }));
293
+ if (!stale.ok) return;
294
+ const lockPath = stale.claim.lockPath;
295
+ const competitor = { ...stale.claim, nonce: "competitor-nonce" };
296
+ const swap = () => fs.writeFileSync(lockPath, JSON.stringify(competitor));
297
+ const res = acquireLock(GID_A, fixedDeps({ dir, killFn: killers.esrch, _test_beforeReread: swap }));
298
+ ok("reclaim-mutex: lock changed under mutex (re-read nonce mismatch) → abort conflict", res.ok === false);
299
+ });
300
+ // (C) a FRESH acquirer wins the unlink→create gap → we conflict, never clobber.
301
+ withTempDir((dir) => {
302
+ const stale = acquireLock(GID_A, fixedDeps({ dir }));
303
+ if (!stale.ok) return;
304
+ const lockPath = stale.claim.lockPath;
305
+ const fresh = {
306
+ gardenId: GID_A,
307
+ pid: 9,
308
+ hostname: HOST,
309
+ createdAt: "x",
310
+ nonce: "fresh-F",
311
+ owner: "entwurf_v2",
312
+ lockPath,
313
+ };
314
+ const winGap = () => fs.writeFileSync(lockPath, JSON.stringify(fresh)); // F creates after our unlink
315
+ const res = acquireLock(GID_A, fixedDeps({ dir, killFn: killers.esrch, _test_beforeRecreate: winGap }));
316
+ ok("reclaim-mutex: gap-winner present → we conflict (no double-hold)", res.ok === false);
317
+ const onDisk = JSON.parse(fs.readFileSync(lockPath, "utf8"));
318
+ eq("reclaim-mutex: on-disk is the gap-winner's lock, never overwritten", onDisk.nonce, "fresh-F");
319
+ });
320
+ // (D) a clean reclaim leaves NO leftover marker (finally cleanup).
321
+ withTempDir((dir) => {
322
+ const lockPath = lockPathFor(GID_A, dir);
323
+ const stale = acquireLock(GID_A, fixedDeps({ dir }));
324
+ ok("reclaim-mutex: stale holder acquired", stale.ok === true);
325
+ const reclaimer = acquireLock(GID_A, fixedDeps({ dir, killFn: killers.esrch }));
326
+ ok("reclaim-mutex: clean reclaim succeeds (same host + ESRCH)", reclaimer.ok === true);
327
+ ok("reclaim-mutex: no leftover .reclaim marker after success", !fs.existsSync(`${lockPath}.reclaim`));
328
+ });
329
+ // (O2) a marker-EEXIST conflict detail carries the marker mtime (so a human can
330
+ // tell a 1-second in-progress reclaim from a 3-day orphan marker).
331
+ withTempDir((dir) => {
332
+ const lockPath = lockPathFor(GID_A, dir);
333
+ acquireLock(GID_A, fixedDeps({ dir }));
334
+ fs.writeFileSync(`${lockPath}.reclaim`, "");
335
+ const blocked = acquireLock(GID_A, fixedDeps({ dir, killFn: killers.esrch }));
336
+ if (!blocked.ok)
337
+ ok("reclaim-mutex: marker conflict detail carries marker mtime (O2)", /marker mtime/.test(blocked.conflict.detail));
338
+ });
339
+
340
+ // ── L3 release: gardenId mismatch (path authority) → not-owned, preserved ──
341
+ withTempDir((dir) => {
342
+ const lockPath = lockPathFor(GID_A, dir);
343
+ fs.mkdirSync(dir, { recursive: true });
344
+ // a <GID_A>.lock whose body claims gardenId GID_B but shares our nonce.
345
+ const foreign = {
346
+ gardenId: GID_B,
347
+ pid: 4242,
348
+ hostname: HOST,
349
+ createdAt: "x",
350
+ nonce: "shared-nonce",
351
+ owner: "entwurf_v2",
352
+ lockPath,
353
+ };
354
+ fs.writeFileSync(lockPath, JSON.stringify(foreign));
355
+ const aClaim: LockClaim = {
356
+ gardenId: GID_A,
357
+ pid: 4242,
358
+ hostname: HOST,
359
+ createdAt: "x",
360
+ nonce: "shared-nonce",
361
+ owner: "entwurf_v2",
362
+ lockPath,
363
+ };
364
+ eq("release: foreign-gid lock (same nonce) = not-owned (path authority = gid)", releaseLock(aClaim), "not-owned");
365
+ ok("release: foreign-gid lock preserved (not freed)", fs.existsSync(lockPath));
366
+ });
367
+
368
+ console.log(`\ncheck-entwurf-v2-lock: ${passed} assertions passed`);
@@ -0,0 +1,228 @@
1
+ /**
2
+ * check-entwurf-v2-mailbox — deterministic gate for the 5c-4 meta-mailbox SEND body
3
+ * (`executeMetaMailboxSend` + the production `makeProductionSendViaMailbox` adapter). It
4
+ * proves the ENQUEUE-ONLY wiring over an injected fake enqueue, with NO filesystem:
5
+ *
6
+ * 1. sender present + wantsReply=true → body rendered via formatMetaMailboxBody with
7
+ * `wants reply: yes`; enqueue called EXACTLY once.
8
+ * 2. sender present + wantsReply=false → body shows `wants reply: no` (plan.wantsReply
9
+ * is threaded — the deliberate divergence from legacy's hard-coded false).
10
+ * 3. sender undefined → the RAW `plan.message` is enqueued (envelope-less fallback).
11
+ * 4. enqueue opts are EXACTLY {gardenId: plan.targetGardenId, body, sessionsDir,
12
+ * mailboxDir} — the routing target is the plan's, never re-derived.
13
+ * 5. enqueue throw PROPAGATES (it is NOT folded into {success:false}).
14
+ * 6. a successful enqueue returns {success:true}.
15
+ * 7. production adapter: returns Promise<{success:true}>, calls senderProvider, and
16
+ * NEVER touches `lock` — a poison LockClaim whose every access throws still resolves.
17
+ * 8. production adapter threads the plan straight through to enqueue ONCE.
18
+ * 9. source guard: the lib has NO release seam and NO routing seam (no releaseLock /
19
+ * inspect / probe / resolve) — a lock leak / re-route is structurally impossible.
20
+ *
21
+ * No real IO — the enqueue fake records its args so "enqueue once, with these exact
22
+ * arguments, no routing, no release" is asserted structurally.
23
+ */
24
+
25
+ import assert from "node:assert/strict";
26
+ import { readFileSync } from "node:fs";
27
+ import { fileURLToPath } from "node:url";
28
+ import type { LockClaim } from "../pi-extensions/lib/entwurf-v2-lock.ts";
29
+ import { executeMetaMailboxSend, makeProductionSendViaMailbox } from "../pi-extensions/lib/entwurf-v2-mailbox.ts";
30
+ import type { MetaMailboxPlan } from "../pi-extensions/lib/entwurf-v2-send.ts";
31
+ import type { MailboxSenderEnvelope } from "../pi-extensions/lib/meta-mailbox-body.ts";
32
+ import type { EnqueueMetaMessageOptions, EnqueueMetaMessageResult } from "../pi-extensions/lib/meta-session.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 = "20260613T100000-aaaaaa";
42
+
43
+ const SENDER: MailboxSenderEnvelope = {
44
+ sessionId: "20260613T120000-sender",
45
+ agentId: "openai-codex/gpt-5.5",
46
+ cwd: "/home/junghan/repos/gh/entwurf",
47
+ timestamp: "2026-06-13T04:00:00.000Z",
48
+ origin: "pi-session",
49
+ replyable: true,
50
+ };
51
+
52
+ function mailboxPlan(over: Partial<MetaMailboxPlan> = {}): MetaMailboxPlan {
53
+ return {
54
+ transport: "meta-mailbox",
55
+ action: "send",
56
+ targetGardenId: GID,
57
+ mailboxDir: "/fake/mailbox",
58
+ sessionsDir: "/fake/sessions",
59
+ wantsReply: false,
60
+ message: "hello world",
61
+ ...over,
62
+ };
63
+ }
64
+
65
+ // A fake enqueue that records every call. The result is a plausible EnqueueMetaMessageResult.
66
+ function recordingEnqueue(): {
67
+ calls: EnqueueMetaMessageOptions[];
68
+ fn: (opts: EnqueueMetaMessageOptions) => EnqueueMetaMessageResult;
69
+ } {
70
+ const calls: EnqueueMetaMessageOptions[] = [];
71
+ return {
72
+ calls,
73
+ fn: (opts: EnqueueMetaMessageOptions): EnqueueMetaMessageResult => {
74
+ calls.push(opts);
75
+ return {
76
+ gardenId: opts.gardenId,
77
+ recordPath: `/fake/records/${opts.gardenId}.json`,
78
+ messagePath: `/fake/mailbox/${opts.gardenId}/m.msg`,
79
+ signalPath: `/fake/mailbox/${opts.gardenId}/inbox.signal`,
80
+ };
81
+ },
82
+ };
83
+ }
84
+
85
+ // ── 1. sender present + wantsReply=true → rendered body, enqueue once ────────
86
+ {
87
+ const enq = recordingEnqueue();
88
+ const plan = mailboxPlan({ wantsReply: true, message: "ping" });
89
+ const res = executeMetaMailboxSend(plan, SENDER, { enqueue: enq.fn });
90
+ ok("1: enqueue called exactly once", enq.calls.length === 1);
91
+ ok("1: returns {success:true}", res.success === true);
92
+ const body = enq.calls[0].body;
93
+ ok(
94
+ "1: body is the rendered envelope, not raw message",
95
+ body !== plan.message && body.includes("[entwurf received ⟵]"),
96
+ );
97
+ ok("1: wantsReply=true → 'wants reply: yes' in body", body.includes("wants reply: yes"));
98
+ ok("1: body carries the message", body.includes("ping"));
99
+ ok("1: body carries the sender sessionId (replyable)", body.includes(SENDER.sessionId));
100
+ ok(
101
+ "1: replyable body points to the v2 reply surface (entwurf_v2), not the retired entwurf_send",
102
+ body.includes("entwurf_v2") && !body.includes("entwurf_send"),
103
+ );
104
+ }
105
+
106
+ // ── 2. sender present + wantsReply=false → 'wants reply: no' ──────────────────
107
+ {
108
+ const enq = recordingEnqueue();
109
+ const res = executeMetaMailboxSend(mailboxPlan({ wantsReply: false }), SENDER, { enqueue: enq.fn });
110
+ ok("2: returns {success:true}", res.success === true);
111
+ ok("2: wantsReply=false → 'wants reply: no' in body", enq.calls[0].body.includes("wants reply: no"));
112
+ }
113
+
114
+ // ── 3. sender undefined → raw plan.message enqueued ──────────────────────────
115
+ {
116
+ const enq = recordingEnqueue();
117
+ const plan = mailboxPlan({ message: "raw body, no envelope" });
118
+ executeMetaMailboxSend(plan, undefined, { enqueue: enq.fn });
119
+ ok("3: envelope-less → raw plan.message is the body", enq.calls[0].body === plan.message);
120
+ ok("3: no envelope header when sender absent", !enq.calls[0].body.includes("[entwurf received ⟵]"));
121
+ }
122
+
123
+ // ── 4. enqueue opts are EXACTLY the plan's fields (no re-derivation) ──────────
124
+ {
125
+ const enq = recordingEnqueue();
126
+ const plan = mailboxPlan({ targetGardenId: GID, mailboxDir: "/m/dir", sessionsDir: "/s/dir" });
127
+ executeMetaMailboxSend(plan, SENDER, { enqueue: enq.fn });
128
+ const opts = enq.calls[0];
129
+ ok("4: gardenId === plan.targetGardenId", opts.gardenId === GID);
130
+ ok("4: mailboxDir === plan.mailboxDir", opts.mailboxDir === "/m/dir");
131
+ ok("4: sessionsDir === plan.sessionsDir", opts.sessionsDir === "/s/dir");
132
+ ok(
133
+ "4: opts keys are exactly {gardenId, body, sessionsDir, mailboxDir}",
134
+ JSON.stringify(Object.keys(opts).sort()) === JSON.stringify(["body", "gardenId", "mailboxDir", "sessionsDir"]),
135
+ );
136
+ }
137
+
138
+ // ── 5. enqueue throw PROPAGATES, not folded into success:false ───────────────
139
+ {
140
+ const boom = new Error("citizen record gone");
141
+ let thrown: unknown;
142
+ try {
143
+ executeMetaMailboxSend(mailboxPlan(), SENDER, {
144
+ enqueue: () => {
145
+ throw boom;
146
+ },
147
+ });
148
+ } catch (e) {
149
+ thrown = e;
150
+ }
151
+ ok("5: enqueue throw propagates (no success:false fold)", thrown === boom);
152
+ }
153
+
154
+ // ── 6. (covered by 1/2) success → {success:true} — explicit ──────────────────
155
+ {
156
+ const enq = recordingEnqueue();
157
+ const res = executeMetaMailboxSend(mailboxPlan(), SENDER, { enqueue: enq.fn });
158
+ ok("6: successful enqueue → {success:true}", res.success === true && res.error === undefined);
159
+ }
160
+
161
+ // ── 7. production adapter: ignores lock entirely (poison LockClaim) ───────────
162
+ {
163
+ const enq = recordingEnqueue();
164
+ let senderProviderCalls = 0;
165
+ const sendViaMailbox = makeProductionSendViaMailbox({
166
+ senderProvider: () => {
167
+ senderProviderCalls++;
168
+ return SENDER;
169
+ },
170
+ enqueue: enq.fn,
171
+ });
172
+ // Any property access on this lock throws — proving the adapter never reads it.
173
+ const poisonLock = new Proxy({} as LockClaim, {
174
+ get() {
175
+ throw new Error("mailbox enqueue must NOT touch the lock");
176
+ },
177
+ });
178
+ const res = await sendViaMailbox(mailboxPlan(), poisonLock);
179
+ ok("7: production adapter resolves {success:true}", res.success === true);
180
+ ok("7: senderProvider consulted exactly once", senderProviderCalls === 1);
181
+ ok("7: enqueue called once via adapter", enq.calls.length === 1);
182
+ ok("7: lock never touched (poison getter never fired)", true);
183
+ }
184
+
185
+ // ── 8. production adapter threads the plan straight through ───────────────────
186
+ {
187
+ const enq = recordingEnqueue();
188
+ const sendViaMailbox = makeProductionSendViaMailbox({ senderProvider: () => undefined, enqueue: enq.fn });
189
+ const plan = mailboxPlan({ targetGardenId: "20260613T200000-cccccc", message: "thread me" });
190
+ await sendViaMailbox(plan, undefined as unknown as LockClaim);
191
+ ok(
192
+ "8: adapter enqueues the plan's target once",
193
+ enq.calls.length === 1 && enq.calls[0].gardenId === plan.targetGardenId,
194
+ );
195
+ ok("8: envelope-less adapter sends raw message", enq.calls[0].body === plan.message);
196
+ }
197
+
198
+ // ── 8b. production adapter: an enqueue throw surfaces as a REJECTED promise ───
199
+ // The adapter is `async`, so a synchronous enqueue throw must become a rejection (not a
200
+ // sync throw) — the honest async-dep shape the send hand awaits.
201
+ {
202
+ const boom = new Error("enqueue exploded");
203
+ const sendViaMailbox = makeProductionSendViaMailbox({
204
+ senderProvider: () => SENDER,
205
+ enqueue: () => {
206
+ throw boom;
207
+ },
208
+ });
209
+ let rejected: unknown;
210
+ await sendViaMailbox(mailboxPlan(), undefined as unknown as LockClaim).catch((e) => {
211
+ rejected = e;
212
+ });
213
+ ok("8b: enqueue throw → rejected promise (not a sync throw)", rejected === boom);
214
+ }
215
+
216
+ // ── 9. source guard: NO release seam, NO routing seam ────────────────────────
217
+ {
218
+ const libPath = fileURLToPath(new URL("../pi-extensions/lib/entwurf-v2-mailbox.ts", import.meta.url));
219
+ const src = readFileSync(libPath, "utf8");
220
+ // Strip block comments so the doc-prose ("release stays the hand's …") does not trip
221
+ // the structural guard — we assert about CODE, not the rationale we wrote about it.
222
+ const code = src.replace(/\/\*[\s\S]*?\*\//g, "");
223
+ for (const forbidden of ["releaseLock", "inspectSocket", "probeSocket", "resolveDispatch", "resolveTarget"]) {
224
+ ok(`9: lib code has no '${forbidden}' (no release / no routing seam)`, !code.includes(forbidden));
225
+ }
226
+ }
227
+
228
+ console.log(`\ncheck-entwurf-v2-mailbox: ${passed} checks passed`);