@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,352 @@
1
+ /**
2
+ * check-entwurf-v2-surface — deterministic gate for the 5d-3a surface adapter
3
+ * (`entwurf-v2-surface.ts`) + the `entwurf-control.ts` wiring contract. It proves the PURE
4
+ * parts (the rest — production assembly — is check-entwurf-v2-production + the 5d-5 matrix):
5
+ *
6
+ * 1. toDispatchInput — `wants_reply`→`wantsReply` (snake→camel), `intent`/`message`/`target`
7
+ * pass through, absent `mode`/`wants_reply` stay undefined (decider defaults, no double).
8
+ * 2. renderEntwurfV2Result — each result kind → the right `{ text, isError }`, surfacing the
9
+ * carry-overs: reject reason + target-locked diagnostic / control N3 rejectReason /
10
+ * spawn lock-retained diagnostic / N1 delivered-but-lock-dirty.
11
+ * 3. surface source guard — `entwurf-v2-surface.ts` is ctx-free (no ExtensionContext/API).
12
+ * 4. control wiring guard — `entwurf-control.ts` registers `entwurf_v2`, reaches the fence
13
+ * ONLY via a NON-LITERAL dynamic import (a string-const specifier), NEVER a static import
14
+ * of the fence v2 modules (which would break the emit-capable root tsc with TS5097), and
15
+ * decorates the sender as `origin:"pi-session"` with HONEST replyability (SE-1 2e-a:
16
+ * computeSelfAddressability + socket existsSync, not a hardcoded `replyable:true`).
17
+ */
18
+
19
+ import assert from "node:assert/strict";
20
+ import * as fs from "node:fs/promises";
21
+ import * as path from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import type { EntwurfV2RunResult } from "../pi-extensions/lib/entwurf-v2-runner.ts";
24
+ import {
25
+ actionableRejectHint,
26
+ ENTWURF_PREFIX_ROOTS_ENV,
27
+ parseEntwurfPrefixRootsEnv,
28
+ renderEntwurfV2Result,
29
+ type SurfaceEntwurfV2Params,
30
+ toDispatchInput,
31
+ } from "../pi-extensions/lib/entwurf-v2-surface.ts";
32
+
33
+ let passed = 0;
34
+ function ok(label: string, cond: boolean): void {
35
+ assert.ok(cond, label);
36
+ console.log(` ok ${label}`);
37
+ passed++;
38
+ }
39
+
40
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
41
+ const REPO = path.join(HERE, "..");
42
+ const SURFACE_SRC = path.join(REPO, "pi-extensions/lib/entwurf-v2-surface.ts");
43
+ const CONTROL_SRC = path.join(REPO, "pi-extensions/entwurf-control.ts");
44
+ const MCP_SRC = path.join(REPO, "mcp/entwurf-bridge/src/index.ts");
45
+
46
+ const GID = "20260613T100000-aaaaaa";
47
+ const SUCCESS_RECEIPT = {
48
+ ok: true as const,
49
+ action: "send" as const,
50
+ transport: "control-socket" as const,
51
+ ownership: "ack-only" as const,
52
+ observedLiveness: "alive" as const,
53
+ };
54
+
55
+ async function main(): Promise<void> {
56
+ // ── 1: toDispatchInput mapping ────────────────────────────────────────────
57
+ {
58
+ const full: SurfaceEntwurfV2Params = {
59
+ target: GID,
60
+ intent: "owned-outcome",
61
+ message: "hi",
62
+ mode: "steer",
63
+ wants_reply: true,
64
+ };
65
+ const di = toDispatchInput(full);
66
+ ok(
67
+ "1: target/intent/message pass through",
68
+ di.target === GID && di.intent === "owned-outcome" && di.message === "hi",
69
+ );
70
+ ok("1: wants_reply → wantsReply (snake→camel)", di.wantsReply === true);
71
+ ok("1: mode passes through", di.mode === "steer");
72
+
73
+ const minimal: SurfaceEntwurfV2Params = { target: GID, intent: "fire-and-forget", message: "m" };
74
+ const dm = toDispatchInput(minimal);
75
+ ok("1: absent mode stays undefined (decider default)", dm.mode === undefined);
76
+ ok("1: absent wants_reply stays undefined (no double default)", dm.wantsReply === undefined);
77
+ }
78
+
79
+ // ── 2: renderEntwurfV2Result per kind ─────────────────────────────────────
80
+ {
81
+ // reject + target-locked diagnostic
82
+ const rejected: EntwurfV2RunResult = {
83
+ kind: "rejected",
84
+ receipt: { ok: false, reason: "target-locked", observedLiveness: null },
85
+ diagnostic: {
86
+ kind: "target-locked",
87
+ conflict: {
88
+ reason: "target-locked",
89
+ lockPath: "/locks/x.lock",
90
+ holder: {
91
+ gardenId: GID,
92
+ pid: 999,
93
+ hostname: "h",
94
+ createdAt: "2026-06-13T01:00:00.000Z",
95
+ nonce: "n",
96
+ owner: "entwurf_v2",
97
+ lockPath: "/locks/x.lock",
98
+ },
99
+ detail: "held by pid 999",
100
+ },
101
+ },
102
+ };
103
+ const rr = renderEntwurfV2Result(rejected);
104
+ ok(
105
+ "2: reject → isError + reason + diagnostic surfaced",
106
+ rr.isError && rr.text.includes("target-locked") && rr.text.includes("pid 999"),
107
+ );
108
+
109
+ // control sent → delivered
110
+ const sent: EntwurfV2RunResult = {
111
+ kind: "executed",
112
+ receipt: SUCCESS_RECEIPT,
113
+ transport: "control-socket",
114
+ outcome: { transport: "control-socket", outcome: "sent" },
115
+ };
116
+ ok(
117
+ "2: control sent → not error",
118
+ !renderEntwurfV2Result(sent).isError && renderEntwurfV2Result(sent).text.includes("sent"),
119
+ );
120
+
121
+ // control in-band rejected with N3 rejectReason → non-delivery
122
+ const ctlReject: EntwurfV2RunResult = {
123
+ kind: "executed",
124
+ receipt: SUCCESS_RECEIPT,
125
+ transport: "control-socket",
126
+ outcome: { transport: "control-socket", outcome: "rejected", rejectReason: "dormant-fire-forget-unsupported" },
127
+ };
128
+ const cr = renderEntwurfV2Result(ctlReject);
129
+ ok(
130
+ "2: control rejected → isError + N3 rejectReason surfaced",
131
+ cr.isError && cr.text.includes("dormant-fire-forget-unsupported"),
132
+ );
133
+
134
+ // spawn lock-retained → fail-closed diagnostic
135
+ const retained: EntwurfV2RunResult = {
136
+ kind: "executed",
137
+ receipt: { ...SUCCESS_RECEIPT, transport: "spawn-bg" },
138
+ transport: "spawn-bg",
139
+ outcome: {
140
+ transport: "spawn-bg",
141
+ result: {
142
+ kind: "lock-retained",
143
+ released: false,
144
+ reason: "observe-failed",
145
+ diagnostic: {
146
+ targetGardenId: GID,
147
+ lockPath: "/locks/x.lock",
148
+ expectedSocketPath: "/ctl/x.sock",
149
+ observeTimeoutMs: 30000,
150
+ killGraceMs: 5000,
151
+ },
152
+ },
153
+ },
154
+ };
155
+ const ret = renderEntwurfV2Result(retained);
156
+ ok(
157
+ "2: spawn lock-retained → isError + diagnostic surfaced",
158
+ ret.isError && ret.text.includes("LOCK RETAINED") && ret.text.includes("/locks/x.lock"),
159
+ );
160
+
161
+ // spawn socket-alive → delivered
162
+ const alive: EntwurfV2RunResult = {
163
+ kind: "executed",
164
+ receipt: { ...SUCCESS_RECEIPT, transport: "spawn-bg" },
165
+ transport: "spawn-bg",
166
+ outcome: { transport: "spawn-bg", result: { kind: "socket-alive", released: true, pid: 7 } },
167
+ };
168
+ ok("2: spawn socket-alive → not error", !renderEntwurfV2Result(alive).isError);
169
+
170
+ // meta-mailbox → enqueued
171
+ const mailbox: EntwurfV2RunResult = {
172
+ kind: "executed",
173
+ receipt: { ...SUCCESS_RECEIPT, transport: "meta-mailbox" },
174
+ transport: "meta-mailbox",
175
+ outcome: { transport: "meta-mailbox", success: true },
176
+ };
177
+ ok(
178
+ "2: meta-mailbox → not error + enqueued",
179
+ !renderEntwurfV2Result(mailbox).isError && renderEntwurfV2Result(mailbox).text.includes("enqueued"),
180
+ );
181
+
182
+ // N1: execution-failed with finalizedOutcome + releaseFailed → delivered-but-dirty
183
+ const n1: EntwurfV2RunResult = {
184
+ kind: "execution-failed",
185
+ receipt: SUCCESS_RECEIPT,
186
+ transport: "control-socket",
187
+ error: "release boom",
188
+ finalizedOutcome: "sent",
189
+ releaseFailed: true,
190
+ retrySafe: false,
191
+ };
192
+ const n1r = renderEntwurfV2Result(n1);
193
+ ok(
194
+ "2: N1 → isError + 'do NOT retry' + DIRTY surfaced",
195
+ n1r.isError && n1r.text.includes("DIRTY") && n1r.text.includes("do NOT retry"),
196
+ );
197
+
198
+ // plain execution-failed → error
199
+ const failed: EntwurfV2RunResult = {
200
+ kind: "execution-failed",
201
+ receipt: SUCCESS_RECEIPT,
202
+ transport: "control-socket",
203
+ error: "boom",
204
+ retrySafe: false,
205
+ };
206
+ ok("2: plain execution-failed → isError", renderEntwurfV2Result(failed).isError);
207
+
208
+ // Detour B (B-a): backend-liveness-unsupported reject → still a reject (isError),
209
+ // but the text carries the actionable "use fire-and-forget → mailbox" hint. The
210
+ // reject stays honest (reason unchanged, no auto-convert) — only the render guides.
211
+ const metaReject: EntwurfV2RunResult = {
212
+ kind: "rejected",
213
+ receipt: { ok: false, reason: "backend-liveness-unsupported", observedLiveness: "unsupported" },
214
+ };
215
+ const mr = renderEntwurfV2Result(metaReject);
216
+ ok(
217
+ "2: backend-liveness-unsupported reject → isError + actionable fire-and-forget/mailbox hint",
218
+ mr.isError &&
219
+ mr.text.includes("backend-liveness-unsupported") &&
220
+ mr.text.includes("fire-and-forget") &&
221
+ mr.text.includes("mailbox"),
222
+ );
223
+ ok(
224
+ "2B: actionableRejectHint guides meta-session owned → fire-and-forget mailbox",
225
+ (actionableRejectHint("backend-liveness-unsupported") ?? "").includes("fire-and-forget"),
226
+ );
227
+ ok(
228
+ "2B: actionableRejectHint guides owned-live → fire-and-forget",
229
+ (actionableRejectHint("owned-live-no-autosend") ?? "").includes("fire-and-forget"),
230
+ );
231
+ ok(
232
+ "2B: actionableRejectHint returns undefined for a reject with no next step",
233
+ actionableRejectHint("bad-target") === undefined,
234
+ );
235
+ }
236
+
237
+ // ── 6: parseEntwurfPrefixRootsEnv (5d-4b operator-policy SSOT) ─────────────
238
+ {
239
+ const D = path.delimiter;
240
+ ok("6: env name is ENTWURF_PREFIX_ROOTS", ENTWURF_PREFIX_ROOTS_ENV === "ENTWURF_PREFIX_ROOTS");
241
+ ok("6: undefined → [] (no prefix promotion)", parseEntwurfPrefixRootsEnv(undefined).length === 0);
242
+ ok("6: empty string → []", parseEntwurfPrefixRootsEnv("").length === 0);
243
+ ok("6: delimiters-only → []", parseEntwurfPrefixRootsEnv(`${D}${D}`).length === 0);
244
+ const two = parseEntwurfPrefixRootsEnv(`/repos/gh${D}/repos/work`);
245
+ ok("6: delimiter-separated → entries", two.length === 2 && two[0] === "/repos/gh" && two[1] === "/repos/work");
246
+ const trimmed = parseEntwurfPrefixRootsEnv(` /a ${D} ${D} /b `);
247
+ ok("6: trims + drops empty segments", trimmed.length === 2 && trimmed[0] === "/a" && trimmed[1] === "/b");
248
+ // A nonexistent/typo path is KEPT verbatim (no throw, no validation) — preflight's
249
+ // normalize handles it, and a typo must never broaden approve nor fail the dispatch.
250
+ const typo = parseEntwurfPrefixRootsEnv("/this/does/not/exist");
251
+ ok("6: nonexistent path kept verbatim (no throw)", typo.length === 1 && typo[0] === "/this/does/not/exist");
252
+ }
253
+
254
+ // ── 3: surface source guard — ctx-free ────────────────────────────────────
255
+ {
256
+ const src = await fs.readFile(SURFACE_SRC, "utf8");
257
+ const code = src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
258
+ ok("3: surface is ctx-free — no ExtensionContext", !code.includes("ExtensionContext"));
259
+ ok("3: surface is ctx-free — no ExtensionAPI", !code.includes("ExtensionAPI"));
260
+ }
261
+
262
+ // ── 4: pi-native control wiring guard ─────────────────────────────────────
263
+ {
264
+ const src = await fs.readFile(CONTROL_SRC, "utf8");
265
+ const code = src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
266
+ ok("4: pi-native — entwurf-control registers entwurf_v2", /name:\s*"entwurf_v2"/.test(src));
267
+ ok(
268
+ "4: pi-native — reaches the fence via a NON-LITERAL dynamic import (string-const specifier)",
269
+ /const ENTWURF_V2_SURFACE_MODULE\s*=/.test(code) && /await import\(ENTWURF_V2_SURFACE_MODULE\)/.test(code),
270
+ );
271
+ // The whole point of the dynamic import: NO static import of the fence v2 chain into the
272
+ // emit-capable root program (those literal `.ts` imports would be TS5097).
273
+ ok(
274
+ "4: pi-native — NO static import of the v2 fence (runner/production/surface) — TS5097 stays closed",
275
+ !/import[^;]*from\s*"\.\/lib\/entwurf-v2-(runner|production|surface)\.(js|ts)"/.test(code),
276
+ );
277
+ // SE-1 2e-a: senderProvider decorates origin:'pi-session' but `replyable` is now an
278
+ // HONEST fact (canonical socket existsSync via computeSelfAddressability), NOT a
279
+ // hardcoded true. The self-address fence lib is reached through the same non-literal
280
+ // dynamic import pattern as the v2 surface.
281
+ ok(
282
+ "4: pi-native — senderProvider decorates origin:'pi-session' (no hardcoded replyable:true)",
283
+ /origin:\s*"pi-session"/.test(code) && !/replyable:\s*true/.test(code),
284
+ );
285
+ ok(
286
+ "4: pi-native — replyability via computeSelfAddressability + existsSync, dynamic-imported",
287
+ /const ENTWURF_SELF_ADDRESS_MODULE\s*=/.test(code) &&
288
+ /await import\(ENTWURF_SELF_ADDRESS_MODULE\)/.test(code) &&
289
+ /computeSelfAddressability/.test(code) &&
290
+ /existsSync\(/.test(code),
291
+ );
292
+ ok(
293
+ "4: pi-native — NO static import of the self-address fence (TS5097 stays closed)",
294
+ !/import[^;]*from\s*"\.\/lib\/entwurf-self-address\.(js|ts)"/.test(code),
295
+ );
296
+ // Caller-intent steer (live-peer owned-outcome bug): the description must tell the model
297
+ // to use fire-and-forget for a LIVE/alive peer and that owned-outcome is dormant-only and
298
+ // NEVER auto-converted — the preventive fix (the decider must not auto-convert).
299
+ ok(
300
+ "4: pi-native — description steers live/alive peer → fire-and-forget",
301
+ /liveness=alive/.test(src) && /fire-and-forget/.test(src),
302
+ );
303
+ ok(
304
+ "4: pi-native — description says owned-outcome is dormant-only + never auto-converted",
305
+ /owned-outcome is ONLY for waking a DORMANT pi/.test(src) && /NEVER auto-converted/.test(src),
306
+ );
307
+ }
308
+
309
+ // ── 5: MCP bridge wiring guard ────────────────────────────────────────────
310
+ // The MCP bridge is a `.ts`-import fence consumer (mcp/tsconfig allowImportingTsExtensions),
311
+ // so it STATICALLY imports the surface adapter (no dynamic import). The v2 handler runs the
312
+ // production runner IN-PROCESS via runAndRenderEntwurfV2FromSurface — it does NOT route the
313
+ // v2 dispatch through legacy rpcCall/enqueueMetaMessage (those stay as the v2 runner's transport primitives).
314
+ {
315
+ const src = await fs.readFile(MCP_SRC, "utf8");
316
+ ok("5: MCP — registers entwurf_v2 server.tool", /server\.tool\(\s*"entwurf_v2"/.test(src));
317
+ ok(
318
+ "5: MCP — static imports runAndRenderEntwurfV2FromSurface from the surface fence module",
319
+ /import\s*\{[^}]*runAndRenderEntwurfV2FromSurface[^}]*\}\s*from\s*"[^"]*entwurf-v2-surface\.ts"/.test(src),
320
+ );
321
+ // Isolate the entwurf_v2 server.tool(...) block: from its name literal to the NEXT
322
+ // server.tool( registration. The v2 handler must build the sender + call the surface
323
+ // adapter, and must NOT itself reach for a legacy transport (the decider routes).
324
+ const v2Start = src.indexOf('"entwurf_v2"');
325
+ const after = src.indexOf("server.tool(", v2Start + 1);
326
+ const v2Block = src.slice(v2Start, after === -1 ? undefined : after);
327
+ ok("5: MCP — v2 handler builds buildSendSenderEnvelope()", /buildSendSenderEnvelope\(\)/.test(v2Block));
328
+ ok("5: MCP — v2 handler passes senderProvider: () => sender", /senderProvider:\s*\(\)\s*=>\s*sender/.test(v2Block));
329
+ ok(
330
+ "5: MCP — v2 handler calls runAndRenderEntwurfV2FromSurface",
331
+ /runAndRenderEntwurfV2FromSurface\(/.test(v2Block),
332
+ );
333
+ ok(
334
+ "5: MCP — v2 handler routes through the runner, NOT legacy rpcCall/enqueueMetaMessage",
335
+ !/\brpcCall\(/.test(v2Block) && !/\benqueueMetaMessage\(/.test(v2Block),
336
+ );
337
+ // Caller-intent steer — same preventive guidance on the MCP surface (a sibling reaching
338
+ // in over MCP reads this description, not the pi-native one).
339
+ ok(
340
+ "5: MCP — description steers live/alive peer → fire-and-forget",
341
+ /liveness=alive/.test(src) && /fire-and-forget/.test(src),
342
+ );
343
+ ok(
344
+ "5: MCP — description says owned-outcome is dormant-only + never auto-converted",
345
+ /owned-outcome is ONLY for waking a DORMANT pi/.test(src) && /NEVER auto-converted/.test(src),
346
+ );
347
+ }
348
+
349
+ console.log(`\ncheck-entwurf-v2-surface: ${passed} checks passed`);
350
+ }
351
+
352
+ await main();
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """check-keyset-overlap — guard the keyset-owner invariant across consumers.
3
+
4
+ entwurf's meta-bridge install OWNS a fixed set of `~/.claude/settings.json`
5
+ (+ `~/.claude.json`) keys (the SSOT is `meta-bridge-state.py managed-keys`).
6
+ agent-config (and any future consumer) merges its OWN fragment into the same
7
+ file. The invariant: each side sets only its own keys and never the other's —
8
+ otherwise a later `agent-config` merge (jq `.[0] * .[1]`, which REPLACES arrays
9
+ and overwrites scalars) silently clobbers pi-owned policy, or vice-versa.
10
+
11
+ This is the PREVENTIVE half of the guard (doctor's `state.py check` is the
12
+ after-the-fact survival half). It reads pi's owned key paths and one or more
13
+ consumer fragment JSON files, and fails loud if any consumer key collides with a
14
+ pi-owned key — exact match OR ancestor/descendant (a fragment that sets
15
+ `statusLine.command` collides with pi's whole-`statusLine` ownership).
16
+
17
+ Cross-repo and not hermetic (the fragment path is an argument), so it lives
18
+ OUTSIDE `pnpm check` / release-gate — it is for agent-config CI and manual
19
+ cross-checks. The synthetic-fixture regression of this script's own logic lives
20
+ in `smoke-meta-keyset-guard.sh`, which IS in `pnpm check`.
21
+
22
+ usage: check-keyset-overlap.py <consumer-fragment.json> [more.json ...]
23
+ exit 0 = disjoint (invariant holds); exit 1 = overlap (invariant violated);
24
+ exit 2 = usage / unreadable input.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import subprocess
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ HERE = Path(__file__).resolve().parent
35
+ STATE = HERE / "meta-bridge-state.py"
36
+
37
+
38
+ def die(msg: str, code: int = 2) -> "None":
39
+ print(f"check-keyset-overlap: {msg}", file=sys.stderr)
40
+ raise SystemExit(code)
41
+
42
+
43
+ def pi_owned_paths() -> list[str]:
44
+ """Flat dotted key paths entwurf owns, from the state-manager SSOT."""
45
+ try:
46
+ out = subprocess.run(
47
+ [sys.executable, str(STATE), "managed-keys"],
48
+ check=True,
49
+ capture_output=True,
50
+ text=True,
51
+ ).stdout
52
+ except subprocess.CalledProcessError as exc: # pragma: no cover - defensive
53
+ die(f"could not read pi managed-keys SSOT: {exc.stderr.strip() or exc}")
54
+ data = json.loads(out)
55
+ paths: list[str] = []
56
+ for scope in ("settings", "claudeRoot"):
57
+ for _kind, keys in data.get(scope, {}).items():
58
+ paths.extend(keys)
59
+ return paths
60
+
61
+
62
+ def flatten(obj: object, prefix: str = "") -> list[str]:
63
+ """Leaf dotted paths of a JSON object. Arrays/scalars are leaves."""
64
+ if isinstance(obj, dict) and obj:
65
+ out: list[str] = []
66
+ for key, value in obj.items():
67
+ path = f"{prefix}.{key}" if prefix else key
68
+ out.extend(flatten(value, path))
69
+ return out
70
+ return [prefix] if prefix else []
71
+
72
+
73
+ def collides(pi_path: str, frag_path: str) -> bool:
74
+ """True if the two key paths refer to the same key or a nested ancestor."""
75
+ return (
76
+ pi_path == frag_path
77
+ or frag_path.startswith(pi_path + ".")
78
+ or pi_path.startswith(frag_path + ".")
79
+ )
80
+
81
+
82
+ def main() -> int:
83
+ if len(sys.argv) < 2:
84
+ die("usage: check-keyset-overlap.py <consumer-fragment.json> [more.json ...]")
85
+
86
+ pi_paths = pi_owned_paths()
87
+ overlaps: list[str] = []
88
+ for raw in sys.argv[1:]:
89
+ path = Path(raw)
90
+ if not path.exists():
91
+ die(f"fragment not found: {path}")
92
+ try:
93
+ fragment = json.loads(path.read_text())
94
+ except (OSError, json.JSONDecodeError) as exc:
95
+ die(f"unreadable fragment {path}: {exc}")
96
+ if not isinstance(fragment, dict):
97
+ die(f"fragment {path} root must be a JSON object")
98
+ frag_paths = flatten(fragment)
99
+ for frag in frag_paths:
100
+ for pi in pi_paths:
101
+ if collides(pi, frag):
102
+ overlaps.append(f"{path.name}: consumer key '{frag}' collides with pi-owned '{pi}'")
103
+
104
+ if overlaps:
105
+ print("KEYSET OVERLAP — keyset-owner invariant violated:", file=sys.stderr)
106
+ for line in overlaps:
107
+ print(f" FAIL: {line}", file=sys.stderr)
108
+ print(
109
+ " Fix: remove the colliding key(s) from the consumer fragment; "
110
+ "entwurf owns them (see ./run.sh meta-bridge-managed-keys).",
111
+ file=sys.stderr,
112
+ )
113
+ return 1
114
+
115
+ print(f"keyset disjoint ok: {len(sys.argv) - 1} fragment(s) vs {len(pi_paths)} pi-owned key(s), no overlap")
116
+ return 0
117
+
118
+
119
+ if __name__ == "__main__":
120
+ raise SystemExit(main())
@@ -0,0 +1,170 @@
1
+ /**
2
+ * check-mailbox-receipt-state — deterministic gate for 0.11 Stage 0 step 3B:
3
+ * the mailbox receipt state schema + store. A real temp mailbox dir; no backend,
4
+ * no network, no hook. Safe in the `pnpm check` static floor.
5
+ *
6
+ * Proves the new receipt authority's home (`<mailbox>/<gardenId>/state.json`):
7
+ * pure schema round-trip + crash-on-malformed + strict keyset, then the fs store
8
+ * (stamp → persist → read-back) including the observable result GPT named for
9
+ * 3B — "read receipt가 record.delivery가 아니라 mailbox state로: stamp 후 state를
10
+ * 보면 lastReadAt가 남는다". ENTWURF_META_MAILBOX_DIR / explicit mailboxDir keep this
11
+ * off the operator's real ~/.pi/agent/meta-mailbox.
12
+ *
13
+ * Scope is 3B ONLY: schema + store. The live enqueue/read dual-write and the
14
+ * record.delivery removal land in step 3D (NEXT.md 끊을 지점 ②) — no live-path
15
+ * assertions here, and wakeMode/deliveryLevel (capability, step 3C) are out.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import {
23
+ emptyMailboxReceiptState,
24
+ type MailboxReceiptField,
25
+ MetaRecordError,
26
+ mailboxReceiptStatePath,
27
+ parseMailboxReceiptState,
28
+ readMailboxReceiptState,
29
+ serializeMailboxReceiptState,
30
+ stampMailboxReceipt,
31
+ } from "../pi-extensions/lib/meta-session.ts";
32
+
33
+ let passed = 0;
34
+ function ok(label: string, cond: boolean): void {
35
+ assert.ok(cond, label);
36
+ console.log(` ok ${label}`);
37
+ passed++;
38
+ }
39
+ function golden(label: string, actual: unknown, expected: unknown): void {
40
+ assert.deepStrictEqual(actual, expected, label);
41
+ console.log(` ok ${label}`);
42
+ passed++;
43
+ }
44
+ function throws(label: string, fn: () => unknown): void {
45
+ assert.throws(fn, MetaRecordError, label);
46
+ console.log(` ok ${label}`);
47
+ passed++;
48
+ }
49
+
50
+ const GID = "20260301T120000-abc123";
51
+ const tmpRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "psa-receipt-")));
52
+ const mailboxDir = path.join(tmpRoot, "meta-mailbox");
53
+
54
+ try {
55
+ // --- pure schema ---------------------------------------------------------
56
+ const empty = emptyMailboxReceiptState(GID);
57
+ golden("emptyMailboxReceiptState: schema v1, gardenId, all timestamps null", empty, {
58
+ schemaVersion: 1,
59
+ gardenId: GID,
60
+ lastEnqueuedAt: null,
61
+ lastDeliveredAt: null,
62
+ lastReadAt: null,
63
+ });
64
+
65
+ const seeded = { ...empty, lastEnqueuedAt: "2026-03-01T12:00:00.000Z", lastReadAt: "2026-03-01T12:05:00.000Z" };
66
+ golden(
67
+ "serialize → parse round-trips deep-equal",
68
+ parseMailboxReceiptState(serializeMailboxReceiptState(seeded)),
69
+ seeded,
70
+ );
71
+ ok(
72
+ "serialize: stable key order, 2-space indent, trailing newline",
73
+ serializeMailboxReceiptState(empty) ===
74
+ `{\n "schemaVersion": 1,\n "gardenId": "${GID}",\n "lastEnqueuedAt": null,\n "lastDeliveredAt": null,\n "lastReadAt": null\n}\n`,
75
+ );
76
+
77
+ // --- crash, don't warn ---------------------------------------------------
78
+ throws("parse: invalid JSON throws", () => parseMailboxReceiptState("{not json"));
79
+ throws("parse: array (non-object) throws", () => parseMailboxReceiptState("[]"));
80
+ throws("parse: wrong schemaVersion throws", () =>
81
+ parseMailboxReceiptState(JSON.stringify({ ...empty, schemaVersion: 2 })),
82
+ );
83
+ throws("parse: malformed gardenId throws", () =>
84
+ parseMailboxReceiptState(JSON.stringify({ ...empty, gardenId: "nope" })),
85
+ );
86
+ throws("parse: non-string/non-null timestamp throws", () =>
87
+ parseMailboxReceiptState(JSON.stringify({ ...empty, lastReadAt: 123 })),
88
+ );
89
+ // strict keyset — wakeMode is CAPABILITY (step 3C), never receipt state.
90
+ throws("parse: capability field `wakeMode` is rejected (not receipt state)", () =>
91
+ parseMailboxReceiptState(JSON.stringify({ ...empty, wakeMode: "self-fetch" })),
92
+ );
93
+
94
+ // --- path derivation -----------------------------------------------------
95
+ ok(
96
+ "mailboxReceiptStatePath = <mailbox>/<gardenId>/state.json",
97
+ mailboxReceiptStatePath(mailboxDir, GID) === path.join(mailboxDir, GID, "state.json"),
98
+ );
99
+
100
+ // --- fs store ------------------------------------------------------------
101
+ // absent → empty, and reading must NOT create the file (no side effect).
102
+ golden(
103
+ "readMailboxReceiptState: absent → empty state",
104
+ readMailboxReceiptState({ gardenId: GID, mailboxDir }),
105
+ empty,
106
+ );
107
+ ok(
108
+ "readMailboxReceiptState: absent read leaves no state.json on disk",
109
+ !fs.existsSync(mailboxReceiptStatePath(mailboxDir, GID)),
110
+ );
111
+
112
+ // THE 3B observable: stamp lastReadAt → state.json exists, lastReadAt set.
113
+ const afterRead = stampMailboxReceipt({
114
+ gardenId: GID,
115
+ mailboxDir,
116
+ field: "lastReadAt",
117
+ now: new Date("2026-03-01T12:05:00.000Z"),
118
+ });
119
+ ok("stamp lastReadAt: state.json created on first stamp", fs.existsSync(mailboxReceiptStatePath(mailboxDir, GID)));
120
+ ok(
121
+ "stamp lastReadAt: only lastReadAt set, others null (읽음이 남는다)",
122
+ afterRead.lastReadAt === "2026-03-01T12:05:00.000Z" &&
123
+ afterRead.lastEnqueuedAt === null &&
124
+ afterRead.lastDeliveredAt === null,
125
+ );
126
+ golden(
127
+ "read-back equals the stamped state (persisted to disk)",
128
+ readMailboxReceiptState({ gardenId: GID, mailboxDir }),
129
+ afterRead,
130
+ );
131
+
132
+ // field isolation: a second stamp touches only its field, retains the first.
133
+ const afterEnqueue = stampMailboxReceipt({
134
+ gardenId: GID,
135
+ mailboxDir,
136
+ field: "lastEnqueuedAt",
137
+ now: new Date("2026-03-01T12:10:00.000Z"),
138
+ });
139
+ ok(
140
+ "stamp lastEnqueuedAt: retains lastReadAt, sets lastEnqueuedAt, lastDeliveredAt still null",
141
+ afterEnqueue.lastReadAt === "2026-03-01T12:05:00.000Z" &&
142
+ afterEnqueue.lastEnqueuedAt === "2026-03-01T12:10:00.000Z" &&
143
+ afterEnqueue.lastDeliveredAt === null,
144
+ );
145
+
146
+ // atomic write leaves no .tmp residue in the citizen dir.
147
+ const citizenDir = path.join(mailboxDir, GID);
148
+ ok("stamp is atomic: no .tmp residue left behind", !fs.readdirSync(citizenDir).some((f) => f.includes(".tmp-")));
149
+
150
+ // Blocker 1 — body/path gardenId drift is corruption (body is SSOT). Plant a
151
+ // state.json whose body claims a DIFFERENT citizen under GID's path; reading
152
+ // it back by GID must fail-fast, not silently return the wrong identity.
153
+ const OTHER = "20260301T120000-def456";
154
+ const driftFile = mailboxReceiptStatePath(mailboxDir, GID);
155
+ fs.writeFileSync(driftFile, serializeMailboxReceiptState(emptyMailboxReceiptState(OTHER)));
156
+ throws("readMailboxReceiptState: body/path gardenId drift is corruption", () =>
157
+ readMailboxReceiptState({ gardenId: GID, mailboxDir }),
158
+ );
159
+
160
+ // Blocker 2 — stamp field runtime validation (the TS type does not survive a
161
+ // JS/cast call site). An unknown field must crash, not create a stray key.
162
+ throws("stampMailboxReceipt rejects an unknown receipt field", () =>
163
+ // cast bypasses the TS field type on purpose, to prove the runtime guard
164
+ stampMailboxReceipt({ gardenId: OTHER, mailboxDir, field: "lastBogusAt" as unknown as MailboxReceiptField }),
165
+ );
166
+ } finally {
167
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
168
+ }
169
+
170
+ console.log(`[check-mailbox-receipt-state] ${passed} assertions ok`);