@junghanacs/entwurf 0.12.0 → 0.12.2

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 (49) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +88 -28
  3. package/docs/setup-clean-host.md +117 -219
  4. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +454 -0
  5. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +111 -0
  6. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +1683 -0
  7. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +121 -0
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +155 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +119 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +160 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +63 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +81 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +290 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +254 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +365 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +64 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +218 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +108 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-resume-marker.js +33 -0
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +116 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +125 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +184 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +237 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +216 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +164 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +66 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +1502 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +50 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +259 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-probe.js +81 -0
  32. package/mcp/entwurf-bridge/dist/protocol.js +29 -0
  33. package/mcp/entwurf-bridge/start.sh +49 -7
  34. package/mcp/entwurf-bridge/test.sh +12 -3
  35. package/mcp/entwurf-bridge/tsconfig.build.json +42 -0
  36. package/package.json +30 -9
  37. package/pi/meta-bridge/.claude-plugin/marketplace.json +0 -1
  38. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +101 -0
  39. package/pi-extensions/lib/entwurf-v2-contract.ts +10 -78
  40. package/pi-extensions/lib/entwurf-v2-decider.ts +6 -2
  41. package/pi-extensions/lib/entwurf-v2-production.ts +26 -4
  42. package/run.sh +150 -15
  43. package/scripts/check-entwurf-bridge-pi-free.ts +146 -0
  44. package/scripts/check-entwurf-v2-contract.ts +6 -4
  45. package/scripts/check-meta-manifest-schema.py +145 -0
  46. package/scripts/meta-bridge-install.sh +17 -3
  47. package/scripts/meta-bridge-state.py +37 -10
  48. package/scripts/smoke-acp-bundled-mcp-live.ts +13 -2
  49. package/scripts/smoke-acp-carrier-augment-live.ts +35 -19
@@ -0,0 +1,63 @@
1
+ /**
2
+ * entwurf-resume-args — the SINGLE source of truth for the `pi` argv a resume spawn is
3
+ * launched with (0.11 Stage 0 step 5c-3b). Two variants share this builder so the legacy
4
+ * async-entwurf worker and the v2 spawn-bg resident citizen can NEVER drift in their launch
5
+ * shape — the one place the `--no-extensions` / `--entwurf-control` decision lives.
6
+ *
7
+ * The load-bearing difference (A1):
8
+ * - legacy — a one-shot async worker. `pi -p <prompt>` runs the resume turn and EXITS;
9
+ * `proc.on('close')` then delivers the followUp. It is launched `--no-extensions` AND
10
+ * WITHOUT `--entwurf-control` precisely BECAUSE a control-socket server would keep
11
+ * `pi -p` from exiting (entwurf.ts:22 — that keep-alive was a bug for a one-shot worker).
12
+ * - v2-control — a RESIDENT, addressable garden citizen. The resume turn STILL runs via
13
+ * `-p <prompt>` (the prompt-as-turn authority is unchanged), but the child is launched
14
+ * WITH `--entwurf-control` and WITHOUT `--no-extensions`, so the very keep-alive legacy
15
+ * avoided is now the GOAL: the resumed session stands its control socket up and stays
16
+ * live. The 5c-3a watcher's `socket-alive` is exactly that "resumed citizen is up and
17
+ * addressable" observation (→ release the per-gid lock, child lives on); `child-exited`
18
+ * is the early-exit/failure observation. `plan.launchArgs` (`--approve` or empty, from
19
+ * the decider's preflight) rides along as pi flags before the prompt.
20
+ *
21
+ * Provider/model identity is the caller's existing authority (readSessionIdentity /
22
+ * getEntwurfExplicitExtensions) — this builder only LAYS OUT argv, it never resolves
23
+ * identity. `explicitExtensionArgs` is preserved verbatim in BOTH variants: a recorded
24
+ * `provider=entwurf` resume needs the bridge re-injected to resolve the provider, and
25
+ * dropping it when `--no-extensions` is removed would re-introduce the "Unknown provider"
26
+ * footgun (#29). (A future slice may dedup against settings-loaded extensions; not here.)
27
+ *
28
+ * This module is import-free on purpose: the legacy launcher (entwurf-async.ts) is
29
+ * root-typechecked and imports it as `./entwurf-resume-args.js`, while the v2 adapter and
30
+ * the gate import the same source — a self-contained string builder keeps both configs happy.
31
+ */
32
+ /**
33
+ * Build the `pi` argv for a resume spawn. The SHARED prefix is `--mode json -p` (headless
34
+ * JSON child, prompt-as-turn); the variant then chooses the extension/socket posture; the
35
+ * SHARED suffix is `[…ext args] --session-id <gid> [--provider <p>] --model <m> <prompt>`.
36
+ *
37
+ * Invariants the gate pins:
38
+ * - legacy carries `--no-extensions` and NO `--entwurf-control`.
39
+ * - v2-control carries `--entwurf-control` and NO `--no-extensions`, plus `-p` + prompt.
40
+ * - `explicitExtensionArgs` appears exactly once in both.
41
+ * - `launchArgs` is included for v2-control (before the suffix) and ignored for legacy.
42
+ * - provider/model/prompt identity is laid out identically in both.
43
+ */
44
+ export function buildResumePiArgs(input) {
45
+ const args = ["--mode", "json", "-p"];
46
+ if (input.variant === "legacy") {
47
+ // One-shot worker: no extensions, no control socket (so `pi -p` can exit).
48
+ args.push("--no-extensions");
49
+ }
50
+ else {
51
+ // Resident citizen: stand the control socket up (A1) and keep extensions loaded.
52
+ // The keep-alive legacy avoided is the goal here. `--approve`/launchArgs ride along.
53
+ args.push("--entwurf-control");
54
+ args.push(...(input.launchArgs ?? []));
55
+ }
56
+ // Shared suffix — identical identity layout in both variants.
57
+ args.push(...input.explicitExtensionArgs);
58
+ args.push("--session-id", input.sessionId);
59
+ if (input.provider)
60
+ args.push("--provider", input.provider);
61
+ args.push("--model", input.model, input.prompt);
62
+ return args;
63
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * entwurf-self-address — the PURE self-addressability predicate (SE-1/SE-2 slice 1).
3
+ *
4
+ * "Can a reply to THIS session actually land where its model will see it?"
5
+ *
6
+ * Today both the MCP bridge (buildStrictPiSenderEnvelope /
7
+ * buildTrustedMetaSenderEnvelope / entwurf_self) and pi-native answer that from env
8
+ * presence alone and hardcode `replyable: true`: a pi session with no
9
+ * --entwurf-control socket, or a meta citizen whose owner has exited / whose
10
+ * idle-watch was never armed, all claim replyable while delivery silently fails
11
+ * (SE-1: "all layers say yes, only delivery says no" = a Crash-Don't-Warn
12
+ * violation). This module is the single truth-table both surfaces compute from,
13
+ * with every fact INJECTED (no IO) so the gate can pin each row.
14
+ *
15
+ * Axes by origin:
16
+ * - pi-session: replyable ⟺ a live control socket exists at the canonical path.
17
+ * socketState distinguishes alive / expected (path computable but no
18
+ * live socket) / none (no session id to even compute a path).
19
+ * - meta-session (self-fetch backend, e.g. claude-code): replyable ⟺ the 3-conjunct
20
+ * deliverability — recordBacked AND ownerAlive AND watchArmed (Q4-1
21
+ * lock: self-fetch(static) ∧ ownerPid-startKey-alive(runtime) ∧
22
+ * watch-armed(runtime)). The watchArmed FACT is sourced from the
23
+ * slice-2 meta-receiver presence marker; until that wiring lands the
24
+ * caller passes watchArmed=false, so meta-self is intentionally
25
+ * FAIL-CLOSED until slice 2. Slices 1 and 2 close in the SAME release
26
+ * block, so no intermediate "meta self all-false" state is ever pushed.
27
+ * - external-mcp: never replyable — no authoritative reply address.
28
+ */
29
+ import { computeMetaReceiverActive } from "./entwurf-deliverability.js";
30
+ /**
31
+ * Decide whether a reply to this session is actually deliverable, from injected
32
+ * facts only. Pure: no env reads, no fs, no probing — the caller gathers the facts
33
+ * (existsSync the socket, validate the marker against its record, read the presence
34
+ * marker) and hands them in, so every row is gate-pinnable.
35
+ */
36
+ export function computeSelfAddressability(facts) {
37
+ switch (facts.origin) {
38
+ case "pi-session": {
39
+ if (facts.socketAlive === true) {
40
+ return { replyable: true, socketState: "alive", reason: "pi control socket alive at canonical path" };
41
+ }
42
+ if (facts.socketPathComputable === true) {
43
+ return {
44
+ replyable: false,
45
+ socketState: "expected",
46
+ reason: "pi control socket not found at expected path (session not run with --entwurf-control)",
47
+ };
48
+ }
49
+ return {
50
+ replyable: false,
51
+ socketState: "none",
52
+ reason: "no pi session id — cannot compute a control socket path",
53
+ };
54
+ }
55
+ case "meta-session": {
56
+ // Share the active-receiver atom with the deliverability predicate (one
57
+ // source of truth for "record backed AND owner alive AND watch armed").
58
+ const recv = computeMetaReceiverActive({
59
+ recordBacked: facts.recordBacked,
60
+ ownerAlive: facts.ownerAlive,
61
+ watchArmed: facts.watchArmed,
62
+ });
63
+ return {
64
+ replyable: recv.active,
65
+ socketState: "none",
66
+ reason: recv.active ? `meta receiver active (${recv.reason})` : `meta receiver inactive — ${recv.reason}`,
67
+ };
68
+ }
69
+ case "external-mcp":
70
+ return {
71
+ replyable: false,
72
+ socketState: "none",
73
+ reason: "external MCP host has no authoritative reply address",
74
+ };
75
+ default: {
76
+ // exhaustiveness — an unknown origin is a wiring bug; fail-closed.
77
+ const never = facts.origin;
78
+ return { replyable: false, socketState: "none", reason: `unknown origin: ${String(never)}` };
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,290 @@
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 pi-FREE core: 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
+ * The pi-ai TypeBox REPRESENTATION of this contract lives in the separate
7
+ * `entwurf-v2-contract-schema.ts` (0.12.1 B-1) so this module — which the
8
+ * harness-neutral MCP bridge reaches at boot — carries no pi dependency.
9
+ *
10
+ * Why a frozen contract BEFORE the fact-provider (step 4): with the legacy
11
+ * 3-verb surface (`entwurf`/`entwurf_resume`/`entwurf_send`) still live, building
12
+ * discovery first bakes verb-routing into the fact layer and `entwurf_peers`
13
+ * goes wrong (동결결정 10 순서 근거). So the SHAPE is locked here; the facts read
14
+ * it; dispatch computes from facts at call time (step 5). The legacy 3-verb
15
+ * surface is untouched — this is purely additive (동결결정 10 scope A).
16
+ *
17
+ * Source-verified invariants folded in (Opus 실측 + GPT 보정 + Fable R1-R5, 2026-06-11):
18
+ * - F1: caller intent is DECLARED in the input, so the contract a caller
19
+ * receives is deterministic — never computed from liveness at call time.
20
+ * `owned-outcome` (caller owns completion) ≠ `fire-and-forget` (ack only).
21
+ * - R1: the liveness predicate is defined PER-BACKEND. Only pi (direct-inject,
22
+ * control-socket) has one initially; claude-code is self-fetch with no socket,
23
+ * so its liveness is `unsupported`, NOT folded into dead/indeterminate — that
24
+ * fold is the identity-split trap. `unsupported` is a 4th FACT value, not a
25
+ * 4th dispatch column: an out-of-domain backend rejects before the table.
26
+ * - R2: `target` is the garden-id of an EXISTING citizen. spawn-new is out of
27
+ * v2 scope (legacy `entwurf` keeps it; additive later). Absent/typo gid =
28
+ * `bad-target` (so F6 "오타 gid가 신규 spawn 사고 막기" holds automatically).
29
+ * - N1/F3: an `indeterminate` target never spawns. N2: `fire-and-forget` to a
30
+ * `dormant` target is "reject for now" (mailbox-wake lacks a reply-correlation
31
+ * id in the substrate; an additive extension later, not a permanent no).
32
+ * - Q2: every cell is a SINGLE verdict — no "default", no escape hatch (a
33
+ * "default reject" would re-admit the call-time nondeterminism F1 closes).
34
+ * - F-mailbox: a `fire-and-forget` to an `unsupported` citizen (claude-code etc.)
35
+ * is NOT a reject — the 0.10.0 meta-bridge mailbox delivers without liveness.
36
+ * `unsupported` is the "no liveness predicate" fact, not a delivery verdict; so
37
+ * ff+unsupported routes to the `meta-mailbox` transport, gated by a SEPARATE
38
+ * `mailboxDeliverable` fact (NOT a column of the 6-cell table — Fable (i)).
39
+ * owned-outcome+unsupported still rejects (self-fetch needs real liveness).
40
+ *
41
+ * The decision table here is a constant; `check-entwurf-v2-contract` asserts it
42
+ * exhaustively + proves the "table cell ↔ receipt" round-trip. THAT round-trip
43
+ * is the machine proof of F6 "결정표가 코드로 강제됨" — the executable contract,
44
+ * not prose.
45
+ */
46
+ // ── Caller-declared intent (F1) ────────────────────────────────────────────
47
+ // The outcome contract is an INPUT, not an inference. `fire-and-forget` = the
48
+ // RPC ack is the end of the contract (entwurf-control.ts:29-37). `owned-outcome`
49
+ // = the caller owns the dispatched session's completion.
50
+ export const ENTWURF_INTENTS = ["fire-and-forget", "owned-outcome"];
51
+ // ── Liveness axes ──────────────────────────────────────────────────────────
52
+ // FactLiveness (R1/R3b) = what `entwurf_peers` exposes: the 3 socket-probe
53
+ // values PLUS `unsupported` (predicate undefined for this backend). Four values.
54
+ export const FACT_LIVENESSES = ["alive", "dead", "indeterminate", "unsupported"];
55
+ // DispatchLiveness = the in-domain routing axis the table is keyed on. The
56
+ // socket result maps: alive→live (send), dead→dormant (resume from disk),
57
+ // indeterminate→indeterminate (never spawn). `unsupported` is NOT here — it is
58
+ // handled by the domain guard before the table is consulted.
59
+ export const DISPATCH_LIVENESSES = ["live", "dormant", "indeterminate"];
60
+ // ── Backend liveness domain (R1 + F4) ──────────────────────────────────────
61
+ // Backends whose liveness predicate is DEFINED. Initial = pi only (control-socket
62
+ // connect + RPC `get_info`, entwurf-control.ts). claude-code (self-fetch, no
63
+ // socket) and codex/antigravity (direct-inject without a probe surface yet) are
64
+ // OUT of domain → `unsupported`. Widening this set is a deliberate future
65
+ // decision (Stage 1+), gated by a REAL liveness predicate for that backend —
66
+ // never by silently mapping its sessions to dead/indeterminate (R1 핵심).
67
+ export const LIVENESS_DOMAIN_BACKENDS = ["pi"];
68
+ export function isLivenessSupported(backend) {
69
+ return LIVENESS_DOMAIN_BACKENDS.includes(backend);
70
+ }
71
+ /**
72
+ * Compose the 4-value FACT liveness from a backend and its socket probe.
73
+ * Out-of-domain backend → `unsupported` (NOT dead/indeterminate, R1). An
74
+ * in-domain backend with no probe result yet → `indeterminate` (no proof → the
75
+ * table will refuse to spawn; we never coerce absence of proof into `dead`).
76
+ */
77
+ export function factLivenessOf(backend, socket) {
78
+ if (!isLivenessSupported(backend))
79
+ return "unsupported";
80
+ return socket ?? "indeterminate";
81
+ }
82
+ /** Map an in-domain socket-probe result to the table's routing axis. */
83
+ export function dispatchLivenessOf(socket) {
84
+ return socket === "alive" ? "live" : socket === "dead" ? "dormant" : "indeterminate";
85
+ }
86
+ // ── Reject taxonomy (R5) ───────────────────────────────────────────────────
87
+ // SCOPE: these are PRE-DISPATCH reject reasons — decided before any transport is
88
+ // attempted. A post-dispatch "send-fail fallback" (transport failed after the
89
+ // verdict) is a SEPARATE axis (bucket B) and must NOT be merged into this enum.
90
+ export const ENTWURF_V2_REJECT_REASONS = [
91
+ "indeterminate-no-spawn", // N1/F3: never spawn an indeterminate target
92
+ "dormant-fire-forget-unsupported", // N2: fire-and-forget to a dormant target — reject for now
93
+ "owned-live-no-autosend", // Q2/F1: owned-outcome to a live target is not an auto-send
94
+ "backend-liveness-unsupported", // R1: backend has no liveness predicate (e.g. claude-code) — owned-outcome only
95
+ "mailbox-undeliverable", // F-mailbox: fire-and-forget to an unsupported citizen whose mailbox is not deliverable (fail-closed; future pi-backend non-drainable mailbox)
96
+ "bad-target", // R2: absent/typo garden-id (no existing citizen); spawn-new out of v2 scope
97
+ "untrusted-fail-fast", // 동결결정 5: controlled launch into an untrusted cwd
98
+ "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.
99
+ "target-locked", // R5 pre-claim for bucket B F2 per-gid lockfile conflict
100
+ "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.
101
+ ];
102
+ // ── Pre-probe reject reasons (?6 — observedLiveness = null) ────────────────
103
+ // These three rejects are decided BEFORE any liveness probe runs, so there is no
104
+ // honest 4-value FactLiveness to stamp: `bad-target` (no citizen/backend),
105
+ // `target-locked` (5a lock conflict, before lstat/connect), `target-address-conflict`
106
+ // (address-subject conflict → probing is forbidden). `indeterminate` means an
107
+ // in-domain probe was inconclusive (≠ "not looked yet"); `unsupported` means the
108
+ // backend has no predicate (≠ "pre-probe"). So a pre-probe reject's
109
+ // observedLiveness is `null`, NOT one of the four values. Every OTHER reject —
110
+ // the RESOLVER_REJECT_REASONS (5, post-probe) plus `untrusted-fail-fast` (1B: it
111
+ // now runs AFTER the lock+probe, only on a resume verdict, so its observedLiveness
112
+ // is the honest measured `dormant`) — carries a non-null FactLiveness, as does
113
+ // every success. This null/non-null split is REASON-DEPENDENT, so the receipt
114
+ // schema (which allows null on every reject branch) cannot enforce it alone — the
115
+ // semantic fixture in `check-entwurf-v2-contract` does, via `isPreProbeReject` /
116
+ // `rejectObservedLivenessWellFormed` below (the SSOT 5b mints against).
117
+ export const PRE_PROBE_REJECT_REASONS = [
118
+ "bad-target",
119
+ "target-locked",
120
+ "target-address-conflict",
121
+ ];
122
+ export function isPreProbeReject(reason) {
123
+ return PRE_PROBE_REJECT_REASONS.includes(reason);
124
+ }
125
+ /**
126
+ * The ?6 well-formedness rule for a reject receipt's `observedLiveness`, made a
127
+ * pure SSOT predicate so 5b mints against it and the gate proves it: a pre-probe
128
+ * reject MUST carry `null`; every other reject MUST carry a non-null FactLiveness.
129
+ * Catches the illegal `{ok:false, reason:"bad-target", observedLiveness:"indeterminate"}`
130
+ * (pre-probe with a stamped value) and `{ok:false, reason:"owned-live-no-autosend",
131
+ * observedLiveness:null}` (post-probe with no value) — both reason-dependent, so
132
+ * unreachable by the schema's blanket `FactLiveness | null`.
133
+ *
134
+ * NOTE this predicate FREEZES the 1B ordering into the contract: classifying
135
+ * `untrusted-fail-fast` as post-probe (non-null required) encodes "preflight runs
136
+ * AFTER the probe". Moving preflight back ahead of the probe would make its
137
+ * observedLiveness un-measured (null) and reopen this predicate + the enum split.
138
+ */
139
+ export function rejectObservedLivenessWellFormed(reason, observedLiveness) {
140
+ return isPreProbeReject(reason) ? observedLiveness === null : observedLiveness !== null;
141
+ }
142
+ // Reasons the RESOLVER emits — the in-domain 6-cell table cells PLUS the
143
+ // unsupported domain-guard mini-table (backend-liveness-unsupported for
144
+ // owned-outcome, mailbox-undeliverable for a fail-closed fire-and-forget). NOT
145
+ // just the 6-cell table (the F-mailbox mini-table emits two of these), hence
146
+ // RESOLVER_ not TABLE_. The remaining taxonomy members are produced by stages
147
+ // OTHER than the resolver: `bad-target` (target resolution) and `target-locked`
148
+ // (lockfile) run BEFORE the resolver, while `untrusted-fail-fast` is decided
149
+ // AFTER it — preflight runs only behind a resume verdict (1B), so it is a LATER
150
+ // stage, not an earlier one. All three are pre-claimed in the enum so bucket B
151
+ // does not reopen it.
152
+ export const RESOLVER_REJECT_REASONS = [
153
+ "indeterminate-no-spawn",
154
+ "dormant-fire-forget-unsupported",
155
+ "owned-live-no-autosend",
156
+ "backend-liveness-unsupported",
157
+ "mailbox-undeliverable",
158
+ ];
159
+ // ── Transport + verdict ────────────────────────────────────────────────────
160
+ // `meta-mailbox` (F-mailbox) = liveness-free delivery via the 0.10.0 meta-bridge
161
+ // mailbox + doorbell. The ack is "enqueued + doorbell rung", NOT a read and NOT a
162
+ // turn injection — so `mode` (steer/follow_up) is meaningless on this transport.
163
+ export const ENTWURF_V2_TRANSPORTS = ["control-socket", "spawn-bg", "tmux-live", "meta-mailbox"];
164
+ // Allow-branch facets (exported so the schema↔types gate asserts every enum).
165
+ export const ENTWURF_V2_ACTIONS = ["send", "resume"];
166
+ export const ENTWURF_V2_OWNERSHIPS = ["ack-only", "owned"];
167
+ // Delivery mode of the message to the target (how it is injected) — steer =
168
+ // interrupt the current turn, follow_up = queue after it. A SEPARATE axis from
169
+ // both the intent/ownership axis (F1) and the liveness-routing axis; the legacy
170
+ // entwurf_send carries the same steer|follow_up surface.
171
+ export const ENTWURF_V2_MODES = ["steer", "follow_up"];
172
+ // ── The FROZEN decision table ──────────────────────────────────────────────
173
+ // intent × dispatch-liveness → exactly one verdict (Q2). v2-initial ALLOWS
174
+ // exactly two cells (fire-and-forget+live = send; owned-outcome+dormant =
175
+ // resume); the other four reject. The reject cells are honest "지금은 없음"
176
+ // locks (N2) — the legacy 3-verb surface still covers those flows unchanged.
177
+ export const DISPATCH_TABLE = {
178
+ "fire-and-forget": {
179
+ live: { action: "send", transport: "control-socket", ownership: "ack-only" },
180
+ dormant: { action: "reject", reason: "dormant-fire-forget-unsupported" },
181
+ indeterminate: { action: "reject", reason: "indeterminate-no-spawn" },
182
+ },
183
+ "owned-outcome": {
184
+ // wants_reply is etiquette, not ownership — owned+live never auto-sends (Q2/F1).
185
+ live: { action: "reject", reason: "owned-live-no-autosend" },
186
+ dormant: { action: "resume", transport: "spawn-bg", ownership: "owned" },
187
+ indeterminate: { action: "reject", reason: "indeterminate-no-spawn" },
188
+ },
189
+ };
190
+ // ── The unsupported-backend mailbox mini-table (F-mailbox) ─────────────────
191
+ // SEPARATE from the in-domain 6-cell DISPATCH_TABLE (Fable (i)): an `unsupported`
192
+ // backend (claude-code self-fetch, codex/agy without a probe surface) has NO
193
+ // liveness predicate, so it never enters the liveness-keyed table. Instead the
194
+ // domain guard routes it here, keyed on intent alone:
195
+ // - fire-and-forget needs no liveness — the 0.10.0 meta-bridge mailbox delivers
196
+ // to any DELIVERABLE citizen. This cell is the deliverable path; resolveDispatch
197
+ // downgrades it to `mailbox-undeliverable` when the separate mailboxDeliverable
198
+ // fact is false (fail-closed). The ack is enqueue+doorbell, NOT read, and
199
+ // observedLiveness stays `unsupported` — the receipt's `meta-mailbox` transport
200
+ // is what says "this went to the mailbox".
201
+ // - owned-outcome has no real liveness to own on a self-fetch backend → reject.
202
+ //
203
+ // N2 asymmetry (명문화 — without this the two tables read as contradictory):
204
+ // fire-and-forget+dormant-PI = reject vs fire-and-forget+unsupported-CITIZEN = mailbox.
205
+ // In-domain `dormant` is a CONFIRMED not-running pi, so enqueuing would be a
206
+ // silent pileup (resume is the honest place). `unsupported` is UNKNOWN liveness
207
+ // on a backend we cannot probe, so a best-effort mailbox doorbell is the most we
208
+ // can honestly offer — there is nothing to resume into.
209
+ export const UNSUPPORTED_DISPATCH_TABLE = {
210
+ "fire-and-forget": { action: "send", transport: "meta-mailbox", ownership: "ack-only" },
211
+ "owned-outcome": { action: "reject", reason: "backend-liveness-unsupported" },
212
+ };
213
+ /**
214
+ * The ONLY sanctioned way to mint a reject receipt (?6 enforcement). A pure
215
+ * predicate (`rejectObservedLivenessWellFormed`) cannot force a caller to consult
216
+ * it — 5b could hand-assemble `{ok:false, reason:"bad-target",
217
+ * observedLiveness:"indeterminate"}`, which the blanket `FactLiveness | null`
218
+ * schema accepts. This constructor THROWS on a well-formedness violation, so
219
+ * every reject path (resolveDispatch's own mints below + the 5b stages that
220
+ * produce bad-target / target-locked / target-address-conflict / untrusted-
221
+ * fail-fast) routes through one chokepoint and the bypass surface is zero. 5b
222
+ * MUST build rejects with this, never by object literal.
223
+ */
224
+ export function makeRejectReceipt(reason, observedLiveness) {
225
+ if (!rejectObservedLivenessWellFormed(reason, observedLiveness)) {
226
+ throw new Error(`entwurf_v2: ill-formed reject receipt — reason '${reason}' requires ${isPreProbeReject(reason) ? "observedLiveness=null (pre-probe)" : "a non-null observedLiveness (post-probe)"}, got ${JSON.stringify(observedLiveness)}.`);
227
+ }
228
+ return { ok: false, reason, observedLiveness };
229
+ }
230
+ /**
231
+ * PURE dispatch decision over already-resolved facts. Before reaching here the
232
+ * caller has resolved the target (→ `bad-target` if no existing citizen) and, for
233
+ * an in-domain backend, acquired the per-gid lock (→ `target-locked`) and probed
234
+ * liveness UNDER that lock. This function only decides the liveness-routed
235
+ * verdict. preflight (→ `untrusted-fail-fast`) is NOT a precondition here: per 1B
236
+ * it runs only AFTER this resolver returns a resume verdict (the sole branch that
237
+ * launches a child into a target cwd), so a send/mailbox verdict never touches it.
238
+ * Do NOT reintroduce a global pre-resolver preflight — that re-breaks F-mailbox.
239
+ *
240
+ * Two facts in: `liveness` (the 4-value FactLiveness) and `mailboxDeliverable`
241
+ * (F-mailbox — a SEPARATE axis from liveness, NOT a column of either table, NOT
242
+ * an entwurf_peers row field; step 5's target/capability/presence layer supplies it
243
+ * via the required mailboxDeliverabilityFor seam — wake-mode capability AND a live
244
+ * active-receiver (SE-2 2d-3) — and unknown deliverability MUST be passed as false =
245
+ * fail-closed). The deliverable
246
+ * fact is consulted ONLY on the `unsupported` mailbox path; for an in-domain (pi)
247
+ * backend the liveness-routed table is authoritative and the flag is ignored.
248
+ *
249
+ * R1 domain guard runs first: an `unsupported` liveness is routed through the
250
+ * UNSUPPORTED_DISPATCH_TABLE (mailbox mini-table), never the 6-cell table.
251
+ * No spawn, no send, no I/O — step 5 executes the chosen transport.
252
+ */
253
+ export function resolveDispatch(intent, liveness, mailboxDeliverable) {
254
+ if (liveness === "unsupported") {
255
+ // R1 domain guard → the mailbox mini-table (intent-keyed), NOT the 6-cell table.
256
+ const mboxCell = UNSUPPORTED_DISPATCH_TABLE[intent];
257
+ if (mboxCell.action === "reject") {
258
+ return makeRejectReceipt(mboxCell.reason, liveness);
259
+ }
260
+ // fire-and-forget allow cell, gated by the separate deliverability fact.
261
+ if (!mailboxDeliverable) {
262
+ return makeRejectReceipt("mailbox-undeliverable", liveness);
263
+ }
264
+ return {
265
+ ok: true,
266
+ action: mboxCell.action,
267
+ transport: mboxCell.transport,
268
+ ownership: mboxCell.ownership,
269
+ observedLiveness: liveness,
270
+ };
271
+ }
272
+ // liveness is now narrowed to SocketLiveness; deliverability does not apply.
273
+ const cell = DISPATCH_TABLE[intent][dispatchLivenessOf(liveness)];
274
+ if (cell.action === "reject") {
275
+ return makeRejectReceipt(cell.reason, liveness);
276
+ }
277
+ return {
278
+ ok: true,
279
+ action: cell.action,
280
+ transport: cell.transport,
281
+ ownership: cell.ownership,
282
+ observedLiveness: liveness,
283
+ };
284
+ }
285
+ // ── TypeBox schemas ────────────────────────────────────────────────────────
286
+ // MOVED to `entwurf-v2-contract-schema.ts` (0.12.1 B-1): the pi-ai TypeBox
287
+ // builders (StringEnum/Type) are a pi-lane dependency, so they cannot live in
288
+ // this pi-free core — the MCP bridge reaches this module at boot and must stay
289
+ // harness-neutral (check-entwurf-bridge-pi-free). The schemas import the
290
+ // constants/types above; pi-side consumers import the schemas from there.