@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,427 @@
1
+ /**
2
+ * entwurf-v2-contract — the FROZEN contract surface for the unified `entwurf_v2`
3
+ * verb (0.11 Stage 0 step 4-pre / 동결결정 10). PURE: TypeBox schemas + the
4
+ * intent×liveness decision table + the reject taxonomy + a pure resolver.
5
+ * NO runtime dispatch, NO spawn/send, NO I/O — step 5 wires this to transports.
6
+ *
7
+ * Why a frozen contract BEFORE the fact-provider (step 4): with the legacy
8
+ * 3-verb surface (`entwurf`/`entwurf_resume`/`entwurf_send`) still live, building
9
+ * discovery first bakes verb-routing into the fact layer and `entwurf_peers`
10
+ * goes wrong (동결결정 10 순서 근거). So the SHAPE is locked here; the facts read
11
+ * it; dispatch computes from facts at call time (step 5). The legacy 3-verb
12
+ * surface is untouched — this is purely additive (동결결정 10 scope A).
13
+ *
14
+ * Source-verified invariants folded in (Opus 실측 + GPT 보정 + Fable R1-R5, 2026-06-11):
15
+ * - F1: caller intent is DECLARED in the input, so the contract a caller
16
+ * receives is deterministic — never computed from liveness at call time.
17
+ * `owned-outcome` (caller owns completion) ≠ `fire-and-forget` (ack only).
18
+ * - R1: the liveness predicate is defined PER-BACKEND. Only pi (direct-inject,
19
+ * control-socket) has one initially; claude-code is self-fetch with no socket,
20
+ * so its liveness is `unsupported`, NOT folded into dead/indeterminate — that
21
+ * fold is the identity-split trap. `unsupported` is a 4th FACT value, not a
22
+ * 4th dispatch column: an out-of-domain backend rejects before the table.
23
+ * - R2: `target` is the garden-id of an EXISTING citizen. spawn-new is out of
24
+ * v2 scope (legacy `entwurf` keeps it; additive later). Absent/typo gid =
25
+ * `bad-target` (so F6 "오타 gid가 신규 spawn 사고 막기" holds automatically).
26
+ * - N1/F3: an `indeterminate` target never spawns. N2: `fire-and-forget` to a
27
+ * `dormant` target is "reject for now" (mailbox-wake lacks a reply-correlation
28
+ * id in the substrate; an additive extension later, not a permanent no).
29
+ * - Q2: every cell is a SINGLE verdict — no "default", no escape hatch (a
30
+ * "default reject" would re-admit the call-time nondeterminism F1 closes).
31
+ * - F-mailbox: a `fire-and-forget` to an `unsupported` citizen (claude-code etc.)
32
+ * is NOT a reject — the 0.10.0 meta-bridge mailbox delivers without liveness.
33
+ * `unsupported` is the "no liveness predicate" fact, not a delivery verdict; so
34
+ * ff+unsupported routes to the `meta-mailbox` transport, gated by a SEPARATE
35
+ * `mailboxDeliverable` fact (NOT a column of the 6-cell table — Fable (i)).
36
+ * owned-outcome+unsupported still rejects (self-fetch needs real liveness).
37
+ *
38
+ * The decision table here is a constant; `check-entwurf-v2-contract` asserts it
39
+ * exhaustively + proves the "table cell ↔ receipt" round-trip. THAT round-trip
40
+ * is the machine proof of F6 "결정표가 코드로 강제됨" — the executable contract,
41
+ * not prose.
42
+ */
43
+
44
+ import { StringEnum, Type } from "@earendil-works/pi-ai";
45
+ import { SESSION_ID_RE } from "./session-id.js";
46
+ import type { SocketLiveness } from "./socket-probe.ts";
47
+
48
+ // ── Caller-declared intent (F1) ────────────────────────────────────────────
49
+ // The outcome contract is an INPUT, not an inference. `fire-and-forget` = the
50
+ // RPC ack is the end of the contract (entwurf-control.ts:29-37). `owned-outcome`
51
+ // = the caller owns the dispatched session's completion.
52
+ export const ENTWURF_INTENTS = ["fire-and-forget", "owned-outcome"] as const;
53
+ export type EntwurfIntent = (typeof ENTWURF_INTENTS)[number];
54
+
55
+ // ── Liveness axes ──────────────────────────────────────────────────────────
56
+ // FactLiveness (R1/R3b) = what `entwurf_peers` exposes: the 3 socket-probe
57
+ // values PLUS `unsupported` (predicate undefined for this backend). Four values.
58
+ export const FACT_LIVENESSES = ["alive", "dead", "indeterminate", "unsupported"] as const;
59
+ export type FactLiveness = SocketLiveness | "unsupported";
60
+
61
+ // DispatchLiveness = the in-domain routing axis the table is keyed on. The
62
+ // socket result maps: alive→live (send), dead→dormant (resume from disk),
63
+ // indeterminate→indeterminate (never spawn). `unsupported` is NOT here — it is
64
+ // handled by the domain guard before the table is consulted.
65
+ export const DISPATCH_LIVENESSES = ["live", "dormant", "indeterminate"] as const;
66
+ export type DispatchLiveness = (typeof DISPATCH_LIVENESSES)[number];
67
+
68
+ // ── Backend liveness domain (R1 + F4) ──────────────────────────────────────
69
+ // Backends whose liveness predicate is DEFINED. Initial = pi only (control-socket
70
+ // connect + RPC `get_info`, entwurf-control.ts). claude-code (self-fetch, no
71
+ // socket) and codex/antigravity (direct-inject without a probe surface yet) are
72
+ // OUT of domain → `unsupported`. Widening this set is a deliberate future
73
+ // decision (Stage 1+), gated by a REAL liveness predicate for that backend —
74
+ // never by silently mapping its sessions to dead/indeterminate (R1 핵심).
75
+ export const LIVENESS_DOMAIN_BACKENDS = ["pi"] as const;
76
+ export type LivenessDomainBackend = (typeof LIVENESS_DOMAIN_BACKENDS)[number];
77
+
78
+ export function isLivenessSupported(backend: string): boolean {
79
+ return (LIVENESS_DOMAIN_BACKENDS as readonly string[]).includes(backend);
80
+ }
81
+
82
+ /**
83
+ * Compose the 4-value FACT liveness from a backend and its socket probe.
84
+ * Out-of-domain backend → `unsupported` (NOT dead/indeterminate, R1). An
85
+ * in-domain backend with no probe result yet → `indeterminate` (no proof → the
86
+ * table will refuse to spawn; we never coerce absence of proof into `dead`).
87
+ */
88
+ export function factLivenessOf(backend: string, socket: SocketLiveness | null): FactLiveness {
89
+ if (!isLivenessSupported(backend)) return "unsupported";
90
+ return socket ?? "indeterminate";
91
+ }
92
+
93
+ /** Map an in-domain socket-probe result to the table's routing axis. */
94
+ export function dispatchLivenessOf(socket: SocketLiveness): DispatchLiveness {
95
+ return socket === "alive" ? "live" : socket === "dead" ? "dormant" : "indeterminate";
96
+ }
97
+
98
+ // ── Reject taxonomy (R5) ───────────────────────────────────────────────────
99
+ // SCOPE: these are PRE-DISPATCH reject reasons — decided before any transport is
100
+ // attempted. A post-dispatch "send-fail fallback" (transport failed after the
101
+ // verdict) is a SEPARATE axis (bucket B) and must NOT be merged into this enum.
102
+ export const ENTWURF_V2_REJECT_REASONS = [
103
+ "indeterminate-no-spawn", // N1/F3: never spawn an indeterminate target
104
+ "dormant-fire-forget-unsupported", // N2: fire-and-forget to a dormant target — reject for now
105
+ "owned-live-no-autosend", // Q2/F1: owned-outcome to a live target is not an auto-send
106
+ "backend-liveness-unsupported", // R1: backend has no liveness predicate (e.g. claude-code) — owned-outcome only
107
+ "mailbox-undeliverable", // F-mailbox: fire-and-forget to an unsupported citizen whose mailbox is not deliverable (fail-closed; future pi-backend non-drainable mailbox)
108
+ "bad-target", // R2: absent/typo garden-id (no existing citizen); spawn-new out of v2 scope
109
+ "untrusted-fail-fast", // 동결결정 5: controlled launch into an untrusted cwd
110
+ "socket-only-no-resume-authority", // A1: a record-less socket-only endpoint resolved to a resume verdict (owned-outcome × dormant), but spawn-bg cannot open into it — no trusted cwd/resume authority. Post-probe guard reject (NOT pre-probe, NOT a table resolver cell): the in-domain probe ran and measured the liveness, then `allowResume:false` refused the resume. Carries the honest measured FactLiveness (non-null), unlike the pre-probe `bad-target` it replaces here — a live/addressable socket-only citizen must NEVER be mislabeled absent.
111
+ "target-locked", // R5 pre-claim for bucket B F2 per-gid lockfile conflict
112
+ "target-address-conflict", // F3: a quarantined citizen (garden-id-socket-conflict / symlinked socket) — the gid resolves to two different receivers (record vs socket), so dispatch refuses to pick. The ONLY in-band honest channel for a dispatch-level identity-split (the listing diagnostic channel is not visible to a v2 caller, who only gets a receipt). Pre-resolver, like bad-target/target-locked — NOT a RESOLVER_REJECT_REASONS member.
113
+ ] as const;
114
+ export type EntwurfV2RejectReason = (typeof ENTWURF_V2_REJECT_REASONS)[number];
115
+
116
+ // ── Pre-probe reject reasons (?6 — observedLiveness = null) ────────────────
117
+ // These three rejects are decided BEFORE any liveness probe runs, so there is no
118
+ // honest 4-value FactLiveness to stamp: `bad-target` (no citizen/backend),
119
+ // `target-locked` (5a lock conflict, before lstat/connect), `target-address-conflict`
120
+ // (address-subject conflict → probing is forbidden). `indeterminate` means an
121
+ // in-domain probe was inconclusive (≠ "not looked yet"); `unsupported` means the
122
+ // backend has no predicate (≠ "pre-probe"). So a pre-probe reject's
123
+ // observedLiveness is `null`, NOT one of the four values. Every OTHER reject —
124
+ // the RESOLVER_REJECT_REASONS (5, post-probe) plus `untrusted-fail-fast` (1B: it
125
+ // now runs AFTER the lock+probe, only on a resume verdict, so its observedLiveness
126
+ // is the honest measured `dormant`) — carries a non-null FactLiveness, as does
127
+ // every success. This null/non-null split is REASON-DEPENDENT, so the receipt
128
+ // schema (which allows null on every reject branch) cannot enforce it alone — the
129
+ // semantic fixture in `check-entwurf-v2-contract` does, via `isPreProbeReject` /
130
+ // `rejectObservedLivenessWellFormed` below (the SSOT 5b mints against).
131
+ export const PRE_PROBE_REJECT_REASONS = [
132
+ "bad-target",
133
+ "target-locked",
134
+ "target-address-conflict",
135
+ ] as const satisfies readonly EntwurfV2RejectReason[];
136
+ export type PreProbeRejectReason = (typeof PRE_PROBE_REJECT_REASONS)[number];
137
+
138
+ export function isPreProbeReject(reason: EntwurfV2RejectReason): reason is PreProbeRejectReason {
139
+ return (PRE_PROBE_REJECT_REASONS as readonly string[]).includes(reason);
140
+ }
141
+
142
+ /**
143
+ * The ?6 well-formedness rule for a reject receipt's `observedLiveness`, made a
144
+ * pure SSOT predicate so 5b mints against it and the gate proves it: a pre-probe
145
+ * reject MUST carry `null`; every other reject MUST carry a non-null FactLiveness.
146
+ * Catches the illegal `{ok:false, reason:"bad-target", observedLiveness:"indeterminate"}`
147
+ * (pre-probe with a stamped value) and `{ok:false, reason:"owned-live-no-autosend",
148
+ * observedLiveness:null}` (post-probe with no value) — both reason-dependent, so
149
+ * unreachable by the schema's blanket `FactLiveness | null`.
150
+ *
151
+ * NOTE this predicate FREEZES the 1B ordering into the contract: classifying
152
+ * `untrusted-fail-fast` as post-probe (non-null required) encodes "preflight runs
153
+ * AFTER the probe". Moving preflight back ahead of the probe would make its
154
+ * observedLiveness un-measured (null) and reopen this predicate + the enum split.
155
+ */
156
+ export function rejectObservedLivenessWellFormed(
157
+ reason: EntwurfV2RejectReason,
158
+ observedLiveness: FactLiveness | null,
159
+ ): boolean {
160
+ return isPreProbeReject(reason) ? observedLiveness === null : observedLiveness !== null;
161
+ }
162
+
163
+ // Reasons the RESOLVER emits — the in-domain 6-cell table cells PLUS the
164
+ // unsupported domain-guard mini-table (backend-liveness-unsupported for
165
+ // owned-outcome, mailbox-undeliverable for a fail-closed fire-and-forget). NOT
166
+ // just the 6-cell table (the F-mailbox mini-table emits two of these), hence
167
+ // RESOLVER_ not TABLE_. The remaining taxonomy members are produced by stages
168
+ // OTHER than the resolver: `bad-target` (target resolution) and `target-locked`
169
+ // (lockfile) run BEFORE the resolver, while `untrusted-fail-fast` is decided
170
+ // AFTER it — preflight runs only behind a resume verdict (1B), so it is a LATER
171
+ // stage, not an earlier one. All three are pre-claimed in the enum so bucket B
172
+ // does not reopen it.
173
+ export const RESOLVER_REJECT_REASONS = [
174
+ "indeterminate-no-spawn",
175
+ "dormant-fire-forget-unsupported",
176
+ "owned-live-no-autosend",
177
+ "backend-liveness-unsupported",
178
+ "mailbox-undeliverable",
179
+ ] as const satisfies readonly EntwurfV2RejectReason[];
180
+
181
+ // ── Transport + verdict ────────────────────────────────────────────────────
182
+ // `meta-mailbox` (F-mailbox) = liveness-free delivery via the 0.10.0 meta-bridge
183
+ // mailbox + doorbell. The ack is "enqueued + doorbell rung", NOT a read and NOT a
184
+ // turn injection — so `mode` (steer/follow_up) is meaningless on this transport.
185
+ export const ENTWURF_V2_TRANSPORTS = ["control-socket", "spawn-bg", "tmux-live", "meta-mailbox"] as const;
186
+ export type EntwurfV2Transport = (typeof ENTWURF_V2_TRANSPORTS)[number];
187
+
188
+ // Allow-branch facets (exported so the schema↔types gate asserts every enum).
189
+ export const ENTWURF_V2_ACTIONS = ["send", "resume"] as const;
190
+ export const ENTWURF_V2_OWNERSHIPS = ["ack-only", "owned"] as const;
191
+ // Delivery mode of the message to the target (how it is injected) — steer =
192
+ // interrupt the current turn, follow_up = queue after it. A SEPARATE axis from
193
+ // both the intent/ownership axis (F1) and the liveness-routing axis; the legacy
194
+ // entwurf_send carries the same steer|follow_up surface.
195
+ export const ENTWURF_V2_MODES = ["steer", "follow_up"] as const;
196
+
197
+ export type DispatchVerdict =
198
+ | { action: "send"; transport: "control-socket" | "meta-mailbox"; ownership: "ack-only" }
199
+ | { action: "resume"; transport: "spawn-bg" | "tmux-live"; ownership: "owned" }
200
+ | { action: "reject"; reason: EntwurfV2RejectReason };
201
+
202
+ // ── The FROZEN decision table ──────────────────────────────────────────────
203
+ // intent × dispatch-liveness → exactly one verdict (Q2). v2-initial ALLOWS
204
+ // exactly two cells (fire-and-forget+live = send; owned-outcome+dormant =
205
+ // resume); the other four reject. The reject cells are honest "지금은 없음"
206
+ // locks (N2) — the legacy 3-verb surface still covers those flows unchanged.
207
+ export const DISPATCH_TABLE: Record<EntwurfIntent, Record<DispatchLiveness, DispatchVerdict>> = {
208
+ "fire-and-forget": {
209
+ live: { action: "send", transport: "control-socket", ownership: "ack-only" },
210
+ dormant: { action: "reject", reason: "dormant-fire-forget-unsupported" },
211
+ indeterminate: { action: "reject", reason: "indeterminate-no-spawn" },
212
+ },
213
+ "owned-outcome": {
214
+ // wants_reply is etiquette, not ownership — owned+live never auto-sends (Q2/F1).
215
+ live: { action: "reject", reason: "owned-live-no-autosend" },
216
+ dormant: { action: "resume", transport: "spawn-bg", ownership: "owned" },
217
+ indeterminate: { action: "reject", reason: "indeterminate-no-spawn" },
218
+ },
219
+ };
220
+
221
+ // ── The unsupported-backend mailbox mini-table (F-mailbox) ─────────────────
222
+ // SEPARATE from the in-domain 6-cell DISPATCH_TABLE (Fable (i)): an `unsupported`
223
+ // backend (claude-code self-fetch, codex/agy without a probe surface) has NO
224
+ // liveness predicate, so it never enters the liveness-keyed table. Instead the
225
+ // domain guard routes it here, keyed on intent alone:
226
+ // - fire-and-forget needs no liveness — the 0.10.0 meta-bridge mailbox delivers
227
+ // to any DELIVERABLE citizen. This cell is the deliverable path; resolveDispatch
228
+ // downgrades it to `mailbox-undeliverable` when the separate mailboxDeliverable
229
+ // fact is false (fail-closed). The ack is enqueue+doorbell, NOT read, and
230
+ // observedLiveness stays `unsupported` — the receipt's `meta-mailbox` transport
231
+ // is what says "this went to the mailbox".
232
+ // - owned-outcome has no real liveness to own on a self-fetch backend → reject.
233
+ //
234
+ // N2 asymmetry (명문화 — without this the two tables read as contradictory):
235
+ // fire-and-forget+dormant-PI = reject vs fire-and-forget+unsupported-CITIZEN = mailbox.
236
+ // In-domain `dormant` is a CONFIRMED not-running pi, so enqueuing would be a
237
+ // silent pileup (resume is the honest place). `unsupported` is UNKNOWN liveness
238
+ // on a backend we cannot probe, so a best-effort mailbox doorbell is the most we
239
+ // can honestly offer — there is nothing to resume into.
240
+ export const UNSUPPORTED_DISPATCH_TABLE: Record<EntwurfIntent, DispatchVerdict> = {
241
+ "fire-and-forget": { action: "send", transport: "meta-mailbox", ownership: "ack-only" },
242
+ "owned-outcome": { action: "reject", reason: "backend-liveness-unsupported" },
243
+ };
244
+
245
+ // ── Dispatch receipt (R3) ──────────────────────────────────────────────────
246
+ // Carries `observedLiveness` + the transport/action so `check-entwurf-v2-contract`
247
+ // can assert a "table cell ↔ receipt" round-trip — the machine proof of F6.
248
+ export type EntwurfV2Receipt =
249
+ | {
250
+ ok: true;
251
+ action: "send" | "resume";
252
+ transport: EntwurfV2Transport;
253
+ ownership: "ack-only" | "owned";
254
+ observedLiveness: FactLiveness;
255
+ }
256
+ // observedLiveness is `FactLiveness | null` (?6): null for the pre-probe
257
+ // rejects (PRE_PROBE_REJECT_REASONS — no honest value to stamp before a probe),
258
+ // non-null for every other reject. The split is reason-dependent, enforced by
259
+ // `rejectObservedLivenessWellFormed`, not by this union alone.
260
+ | { ok: false; reason: EntwurfV2RejectReason; observedLiveness: FactLiveness | null };
261
+
262
+ // The reject branch on its own — `makeRejectReceipt` returns exactly this (NOT the
263
+ // widened `EntwurfV2Receipt`), so a consumer that mints a reject keeps the precise
264
+ // type without a cast (the 5b decider's `DispatchDecision` reject branch carries it
265
+ // directly). A type-only precision alias over the union above; discriminant unchanged.
266
+ export type EntwurfV2RejectReceipt = Extract<EntwurfV2Receipt, { ok: false }>;
267
+
268
+ /**
269
+ * The ONLY sanctioned way to mint a reject receipt (?6 enforcement). A pure
270
+ * predicate (`rejectObservedLivenessWellFormed`) cannot force a caller to consult
271
+ * it — 5b could hand-assemble `{ok:false, reason:"bad-target",
272
+ * observedLiveness:"indeterminate"}`, which the blanket `FactLiveness | null`
273
+ * schema accepts. This constructor THROWS on a well-formedness violation, so
274
+ * every reject path (resolveDispatch's own mints below + the 5b stages that
275
+ * produce bad-target / target-locked / target-address-conflict / untrusted-
276
+ * fail-fast) routes through one chokepoint and the bypass surface is zero. 5b
277
+ * MUST build rejects with this, never by object literal.
278
+ */
279
+ export function makeRejectReceipt(
280
+ reason: EntwurfV2RejectReason,
281
+ observedLiveness: FactLiveness | null,
282
+ ): EntwurfV2RejectReceipt {
283
+ if (!rejectObservedLivenessWellFormed(reason, observedLiveness)) {
284
+ throw new Error(
285
+ `entwurf_v2: ill-formed reject receipt — reason '${reason}' requires ${
286
+ isPreProbeReject(reason) ? "observedLiveness=null (pre-probe)" : "a non-null observedLiveness (post-probe)"
287
+ }, got ${JSON.stringify(observedLiveness)}.`,
288
+ );
289
+ }
290
+ return { ok: false, reason, observedLiveness };
291
+ }
292
+
293
+ /**
294
+ * PURE dispatch decision over already-resolved facts. Before reaching here the
295
+ * caller has resolved the target (→ `bad-target` if no existing citizen) and, for
296
+ * an in-domain backend, acquired the per-gid lock (→ `target-locked`) and probed
297
+ * liveness UNDER that lock. This function only decides the liveness-routed
298
+ * verdict. preflight (→ `untrusted-fail-fast`) is NOT a precondition here: per 1B
299
+ * it runs only AFTER this resolver returns a resume verdict (the sole branch that
300
+ * launches a child into a target cwd), so a send/mailbox verdict never touches it.
301
+ * Do NOT reintroduce a global pre-resolver preflight — that re-breaks F-mailbox.
302
+ *
303
+ * Two facts in: `liveness` (the 4-value FactLiveness) and `mailboxDeliverable`
304
+ * (F-mailbox — a SEPARATE axis from liveness, NOT a column of either table, NOT
305
+ * an entwurf_peers row field; step 5's target/capability/presence layer supplies it
306
+ * via the required mailboxDeliverabilityFor seam — wake-mode capability AND a live
307
+ * active-receiver (SE-2 2d-3) — and unknown deliverability MUST be passed as false =
308
+ * fail-closed). The deliverable
309
+ * fact is consulted ONLY on the `unsupported` mailbox path; for an in-domain (pi)
310
+ * backend the liveness-routed table is authoritative and the flag is ignored.
311
+ *
312
+ * R1 domain guard runs first: an `unsupported` liveness is routed through the
313
+ * UNSUPPORTED_DISPATCH_TABLE (mailbox mini-table), never the 6-cell table.
314
+ * No spawn, no send, no I/O — step 5 executes the chosen transport.
315
+ */
316
+ export function resolveDispatch(
317
+ intent: EntwurfIntent,
318
+ liveness: FactLiveness,
319
+ mailboxDeliverable: boolean,
320
+ ): EntwurfV2Receipt {
321
+ if (liveness === "unsupported") {
322
+ // R1 domain guard → the mailbox mini-table (intent-keyed), NOT the 6-cell table.
323
+ const mboxCell = UNSUPPORTED_DISPATCH_TABLE[intent];
324
+ if (mboxCell.action === "reject") {
325
+ return makeRejectReceipt(mboxCell.reason, liveness);
326
+ }
327
+ // fire-and-forget allow cell, gated by the separate deliverability fact.
328
+ if (!mailboxDeliverable) {
329
+ return makeRejectReceipt("mailbox-undeliverable", liveness);
330
+ }
331
+ return {
332
+ ok: true,
333
+ action: mboxCell.action,
334
+ transport: mboxCell.transport,
335
+ ownership: mboxCell.ownership,
336
+ observedLiveness: liveness,
337
+ };
338
+ }
339
+ // liveness is now narrowed to SocketLiveness; deliverability does not apply.
340
+ const cell = DISPATCH_TABLE[intent][dispatchLivenessOf(liveness)];
341
+ if (cell.action === "reject") {
342
+ return makeRejectReceipt(cell.reason, liveness);
343
+ }
344
+ return {
345
+ ok: true,
346
+ action: cell.action,
347
+ transport: cell.transport,
348
+ ownership: cell.ownership,
349
+ observedLiveness: liveness,
350
+ };
351
+ }
352
+
353
+ // ── TypeBox schemas (for step 5 MCP tool params + the gate's structural assert) ──
354
+ // StringEnum (typebox 1.x) inside Type.Object (typebox 0.34) — same mix the
355
+ // existing entwurf tools use (entwurf-control.ts:92-95). The logic types above
356
+ // are hand-written unions, NOT `Static<>` inferences, so the 0.34/1.x widening
357
+ // caveat does not touch them; the gate keeps schema ↔ types in lockstep.
358
+ export const EntwurfV2InputSchema = Type.Object(
359
+ {
360
+ // R2/F6 executable: the garden-id shape is enforced by pattern, not prose —
361
+ // a malformed/typo gid fails the schema (→ bad-target) and can never reach a
362
+ // spawn. SSOT regex = SESSION_ID_RE (pi-extensions/lib/session-id.js).
363
+ target: Type.String({
364
+ pattern: SESSION_ID_RE.source,
365
+ description:
366
+ "garden-id of an EXISTING citizen (pattern-enforced). spawn-new is out of v2 scope (legacy entwurf keeps it); a malformed/typo gid is bad-target.",
367
+ }),
368
+ intent: StringEnum(ENTWURF_INTENTS, {
369
+ description:
370
+ "caller's declared outcome contract (F1): fire-and-forget = ack only, owned-outcome = caller owns completion.",
371
+ }),
372
+ mode: Type.Optional(
373
+ StringEnum(ENTWURF_V2_MODES, {
374
+ description:
375
+ "delivery mode (steer = interrupt current turn, follow_up = queue) — NOT the ownership axis (F1) nor liveness routing. MEANINGLESS on the meta-mailbox transport (F-mailbox): a mailbox ack is enqueue+doorbell, not a turn injection, so steer/follow_up does not apply when the verdict transport is meta-mailbox.",
376
+ }),
377
+ ),
378
+ wantsReply: Type.Optional(
379
+ Type.Boolean({
380
+ description: "conversation etiquette only — NOT ownership; never triggers an auto-send (Q2).",
381
+ }),
382
+ ),
383
+ // `additionalProperties: false` — a frozen contract input is exact; an unknown
384
+ // key is a caller error, not silently ignored.
385
+ },
386
+ { additionalProperties: false },
387
+ );
388
+
389
+ // Receipt = a DISCRIMINATED union on `ok` (R3/F6) — NOT one flat object with
390
+ // optionals. Each branch is EXACT (`additionalProperties: false`): without it,
391
+ // JSON Schema's default admits extra keys, so an illegal receipt like
392
+ // {ok:true, ..., reason:"bad-target"} would validate against the success branch.
393
+ // With it, success carries action/transport/ownership and rejects a stray reason;
394
+ // reject carries reason and rejects any allow facet — the branches are mutually
395
+ // exclusive at the schema level, not merely by declared-property convention.
396
+ export const EntwurfV2ReceiptSuccessSchema = Type.Object(
397
+ {
398
+ ok: Type.Literal(true),
399
+ action: StringEnum(ENTWURF_V2_ACTIONS),
400
+ transport: StringEnum(ENTWURF_V2_TRANSPORTS),
401
+ ownership: StringEnum(ENTWURF_V2_OWNERSHIPS),
402
+ observedLiveness: StringEnum(FACT_LIVENESSES, {
403
+ description: "the 4-value fact liveness the verdict was computed from (R1/R3).",
404
+ }),
405
+ },
406
+ { additionalProperties: false },
407
+ );
408
+
409
+ export const EntwurfV2ReceiptRejectSchema = Type.Object(
410
+ {
411
+ ok: Type.Literal(false),
412
+ reason: StringEnum(ENTWURF_V2_REJECT_REASONS),
413
+ // ?6: required-nullable, NOT optional — a reject branch ALWAYS carries the
414
+ // key, and it is `null` for the pre-probe rejects (PRE_PROBE_REJECT_REASONS)
415
+ // and a real FactLiveness otherwise. Optional would lose the "key always
416
+ // present, value may be null" shape and weaken the discriminated union; the
417
+ // reason-dependent null/non-null rule is enforced semantically (the gate's
418
+ // rejectObservedLivenessWellFormed fixture), not by this blanket union.
419
+ observedLiveness: Type.Union([StringEnum(FACT_LIVENESSES), Type.Null()], {
420
+ description:
421
+ "the 4-value fact liveness the reject was computed from (R1/R3); null for the pre-probe rejects (bad-target / target-locked / target-address-conflict) where no probe ran.",
422
+ }),
423
+ },
424
+ { additionalProperties: false },
425
+ );
426
+
427
+ export const EntwurfV2ReceiptSchema = Type.Union([EntwurfV2ReceiptSuccessSchema, EntwurfV2ReceiptRejectSchema]);