@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.
- package/CHANGELOG.md +40 -0
- package/README.md +88 -28
- package/docs/setup-clean-host.md +117 -219
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +454 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +111 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +1683 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +121 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +155 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +119 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +160 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +63 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +81 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +290 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +254 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +365 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +64 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +218 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +108 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-resume-marker.js +33 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +116 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +184 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +237 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +216 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +164 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +66 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +1502 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +50 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +259 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-probe.js +81 -0
- package/mcp/entwurf-bridge/dist/protocol.js +29 -0
- package/mcp/entwurf-bridge/start.sh +49 -7
- package/mcp/entwurf-bridge/test.sh +12 -3
- package/mcp/entwurf-bridge/tsconfig.build.json +42 -0
- package/package.json +30 -9
- package/pi/meta-bridge/.claude-plugin/marketplace.json +0 -1
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +101 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +10 -78
- package/pi-extensions/lib/entwurf-v2-decider.ts +6 -2
- package/pi-extensions/lib/entwurf-v2-production.ts +26 -4
- package/run.sh +150 -15
- package/scripts/check-entwurf-bridge-pi-free.ts +146 -0
- package/scripts/check-entwurf-v2-contract.ts +6 -4
- package/scripts/check-meta-manifest-schema.py +145 -0
- package/scripts/meta-bridge-install.sh +17 -3
- package/scripts/meta-bridge-state.py +37 -10
- package/scripts/smoke-acp-bundled-mcp-live.ts +13 -2
- 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.
|