@junghanacs/entwurf 0.12.9 → 0.12.10

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 (69) hide show
  1. package/AGENTS.md +134 -248
  2. package/BASELINE.md +1 -1
  3. package/CHANGELOG.md +19 -0
  4. package/DELIVERY.md +4 -3
  5. package/README.md +26 -13
  6. package/VERIFY.md +3 -2
  7. package/demo/demo.sh +7 -1
  8. package/docs/setup-clean-host.md +10 -4
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +159 -66
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +5 -3
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +15 -17
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +9 -11
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +3 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +17 -20
  23. package/mcp/entwurf-bridge/src/index.ts +190 -82
  24. package/package.json +5 -3
  25. package/pi-extensions/entwurf-control.ts +69 -27
  26. package/pi-extensions/lib/acp/overlay.ts +5 -3
  27. package/pi-extensions/lib/entwurf-core.ts +26 -9
  28. package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
  29. package/pi-extensions/lib/entwurf-fact-provider.ts +15 -17
  30. package/pi-extensions/lib/entwurf-facts.ts +13 -13
  31. package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
  32. package/pi-extensions/lib/entwurf-self-address.ts +8 -9
  33. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
  34. package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
  35. package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
  36. package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
  37. package/pi-extensions/lib/entwurf-v2-production.ts +14 -11
  38. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +3 -1
  39. package/pi-extensions/lib/meta-session.ts +17 -20
  40. package/run.sh +48 -41
  41. package/scripts/agy-bridge-config.py +249 -49
  42. package/scripts/agy-bridge.sh +59 -14
  43. package/scripts/check-acp-carrier-augment.ts +34 -2
  44. package/scripts/check-acp-sdk-surface.ts +22 -11
  45. package/scripts/check-agy-permission-matrix.py +655 -0
  46. package/scripts/check-entwurf-deliverability.ts +9 -6
  47. package/scripts/check-entwurf-fact-provider.ts +7 -7
  48. package/scripts/check-entwurf-facts.ts +12 -12
  49. package/scripts/check-entwurf-resume-args.ts +22 -60
  50. package/scripts/check-entwurf-self-address.ts +187 -4
  51. package/scripts/check-entwurf-v2-contract.ts +2 -2
  52. package/scripts/check-entwurf-v2-production.ts +5 -5
  53. package/scripts/check-entwurf-v2-spawn-production.ts +6 -2
  54. package/scripts/check-entwurf-v2-surface.ts +327 -14
  55. package/scripts/check-gate-qualification.ts +785 -0
  56. package/scripts/check-meta-identity-consumers.ts +1 -1
  57. package/scripts/check-shell-quote.ts +2 -1
  58. package/scripts/lib/mutation-qualify.ts +794 -0
  59. package/scripts/mutants/acp-augment.json +30 -0
  60. package/scripts/mutants/agy-permission.json +144 -0
  61. package/scripts/mutants/meta-identity.json +17 -0
  62. package/scripts/mutants/self-address.json +59 -0
  63. package/scripts/mutants/v2-surface.json +87 -0
  64. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  65. package/scripts/smoke-acp-v2-send-live.ts +33 -18
  66. package/scripts/smoke-agy-install-state.sh +205 -20
  67. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
  68. package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
  69. package/scripts/check-entwurf-mailbox-guard.ts +0 -266
@@ -1,14 +1,29 @@
1
1
  /**
2
2
  * entwurf-core — sync entwurf execution, host-agnostic.
3
3
  *
4
- * Single implementation shared by:
5
- * - pi-extensions/entwurf.ts (pi native tool surface)
6
- * - mcp/entwurf-bridge/src/index.ts (MCP tool surface for ACP hosts)
4
+ * DIRECT importers, MEASURED from the import graph (2026-07-27) — not from memory:
5
+ * - pi-extensions/entwurf-control.ts (the pi adapter)
6
+ * - pi-extensions/lib/entwurf-v2-spawn-production.ts (v2 spawn-bg launch identity)
7
+ * - pi-extensions/lib/project-trust-handler.ts · lib/acp/augment.ts ·
8
+ * lib/native-push/adapter.ts
9
+ * - plus gates/smokes (check-shell-quote, check-package-source-routing, …)
10
+ *
11
+ * The MCP bridge is a TRANSITIVE consumer, not a direct one, and the distinction is the
12
+ * whole point: `mcp/entwurf-bridge/src/index.ts → entwurf-v2-surface.ts →
13
+ * entwurf-v2-production.ts → entwurf-v2-spawn-production.ts → entwurf-core.ts`. So the old
14
+ * header's claim — that the bridge imports this shared implementation the way a v1 tool
15
+ * surface did — was false, but so is "the bridge does not reach it": it reaches it at
16
+ * runtime, only for spawn-bg production.
17
+ *
18
+ * NOT consumers on any path: `pi-extensions/entwurf.ts` (the v1 pi native tool surface) and
19
+ * `pi-extensions/lib/entwurf-async.ts` were REMOVED in the 0.12 cutover. Do not reintroduce
20
+ * either as a live consumer.
7
21
  *
8
22
  * This module MUST NOT import anything from @earendil-works/pi-coding-agent or any
9
- * other pi runtime API. It is pure Node + @sinclair/typebox-free. Anything that
10
- * requires pi's ExtensionAPI (sendMessage, appendEntry, sessionManager) belongs
11
- * in the async entwurf path, which stays in pi-extensions/entwurf.ts for now.
23
+ * other pi runtime API. It is pure Node + @sinclair/typebox-free. Anything that
24
+ * requires pi's ExtensionAPI (sendMessage, appendEntry, sessionManager) belongs to a
25
+ * pi-adapter surface, never here — the v1 async entwurf path that used to hold that
26
+ * work went away with `pi-extensions/entwurf.ts`.
12
27
  *
13
28
  * Scope:
14
29
  * - sync execution (spawn pi, collect message_end events, return summary)
@@ -63,9 +78,11 @@ const PI_SETTINGS_PATH = process.env.PI_SETTINGS_PATH
63
78
  ? expandTilde(process.env.PI_SETTINGS_PATH)
64
79
  : path.join(AGENT_DIR, "settings.json");
65
80
  export const ENTWURF_CODEX_ACP_ENV = "ENTWURF_ACP_FOR_CODEX";
66
- // Currently unused: remote/SSH entwurf is fail-fast in 0.9.0 (garden-native
67
- // identity is local-FS only). Retained for #11 remote revival; parity-gated by
68
- // scripts/check-shell-quote.ts across entwurf.ts / entwurf-core.ts / entwurf-async.ts.
81
+ // Currently unused: remote/SSH entwurf is fail-fast (garden-native identity is
82
+ // local-FS only). Retained for #11 remote revival and pinned by
83
+ // scripts/check-shell-quote.ts, whose SOURCE_SITES is now THIS FILE ALONE — the
84
+ // entwurf.ts / entwurf-async.ts halves of that former parity check were removed
85
+ // with those files, so there is no cross-file parity left to assert.
69
86
  // biome-ignore lint/correctness/noUnusedVariables: retained for #11 remote revival; parity-gated.
70
87
  function shellQuote(value) {
71
88
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -18,9 +18,10 @@
18
18
  * drains its own inbox on wake. A DIRECT-INJECT backend (pi / codex / antigravity)
19
19
  * has no mailbox drain at all — enqueuing for it is the SE-1 false success
20
20
  * ("✓ delivered" into a void). So deliverable = wakeMode === "self-fetch" AND the
21
- * receiver is active. This is the guard that the v1 fallback, MCP v1, pi-native v1,
22
- * and the v2 decider/send-fallback enqueue sites must all pass before writing a
23
- * .msg (slice 2d).
21
+ * receiver is active. Every enqueue site must pass this guard before writing a .msg
22
+ * (slice 2d). The shipped sites are the v2 decider and its send-fallback re-resolve;
23
+ * the v1 fallback / MCP v1 / pi-native v1 sites this once also listed were removed in
24
+ * the 0.12 cutover — do not read them as live.
24
25
  *
25
26
  * - nativePushDeliverable(facts): the SEPARATE deliverability predicate for a
26
27
  * NATIVE-PUSH backend (antigravity). A native-push citizen has no mailbox and no
@@ -58,9 +59,12 @@ export function computeMetaReceiverActive(facts) {
58
59
  * Does this presence marker actually belong to the target identity? A marker that is
59
60
  * absent, or whose garden id / backend / native session id has drifted from the record,
60
61
  * is NOT this receiver — fail-closed (a stale/foreign marker must never raise a dead
61
- * target to "active"). The single source of truth for "marker ↔ identity match" shared
62
- * by the v1 mailbox guard (gatherMailboxDeliverabilityFacts) and the v2 production
63
- * `mailboxDeliverabilityFor` seam, so the two paths cannot drift to different meanings.
62
+ * target to "active"). The single source of truth for "marker ↔ identity match". Its
63
+ * PRODUCTION consumers are the v2 `mailboxDeliverabilityFor` seam and the MCP bridge's
64
+ * `entwurf_self`. There is no second implementation: `entwurf-mailbox-guard.ts` used to
65
+ * wrap this atom with its own enqueue orchestration, had ZERO production importers (import
66
+ * graph measured 2026-07-27), and was DELETED rather than left as a green gate proving only
67
+ * retired behaviour. A new enqueue site consults this predicate through that seam.
64
68
  */
65
69
  export function receiverMarkerMatchesIdentity(marker, identity) {
66
70
  return (!!marker &&
@@ -5,8 +5,8 @@
5
5
  * (one-way: provider → facts / socket-discovery / meta-session) — no import cycle
6
6
  * with `entwurf-facts.ts` (which owns `SocketProbe`/`resolveFactList`).
7
7
  *
8
- * listAllMetaIdentities → pi gid 추출 → scanSocketProbes(piGids)
9
- * → pre-quarantine non-pi/socket conflicts → resolveFactList(clean)
8
+ * listAllMetaIdentities → socket-domain gid extraction → scanSocketProbes
9
+ * → pre-quarantine out-of-domain/socket conflicts → resolveFactList(clean)
10
10
  * → { facts, diagnostics }
11
11
  *
12
12
  * Two throw-vs-diagnostics policies, kept distinct (GPT힣 C-원칙):
@@ -20,13 +20,13 @@
20
20
  * — that is not a re-implementation of the collision rule, it is input
21
21
  * sanitation that leaves the pure-core invariant intact.
22
22
  *
23
- * The non-pi+socket collision quarantines BOTH sides (the PeerFact AND the
24
- * socket): gardenId is the universal address and a send path reads the socket
23
+ * An out-of-socket-domain record/socket collision quarantines BOTH sides (the
24
+ * PeerFact AND the socket): gardenId is the universal address and a send path
25
25
  * first, so surfacing the record alone (as a clean `unsupported` PeerFact) while
26
26
  * a same-gid socket exists would be half a lie. Both leave the normal output;
27
27
  * one diagnostic carries the fact. (pi + same-gid socket = the normal merge.)
28
28
  */
29
- import { isNonPiGardenIdSocketConflict, resolveFactList } from "./entwurf-facts.js";
29
+ import { isOutOfSocketDomainGardenIdConflict, resolveFactList } from "./entwurf-facts.js";
30
30
  import { isLivenessSupported } from "./entwurf-v2-contract.js";
31
31
  import { FRESH_CUT_PRESCRIPTION, listAllMetaIdentities, } from "./meta-session.js";
32
32
  import { scanSocketProbes } from "./socket-discovery.js";
@@ -80,8 +80,8 @@ export async function listEntwurfFacts(deps) {
80
80
  // 2. socket axis — probe (dir sockets) ∪ (in-domain citizen canonical paths).
81
81
  // Its three hazards (symlink forgery / malformed name / dir-read error) are
82
82
  // folded into diagnostics here so the listing survives but never lies.
83
- const piGids = identities.filter((i) => isLivenessSupported(i.backend)).map((i) => i.gardenId);
84
- const scan = await scanSocketProbes(piGids, deps.socket ?? {});
83
+ const socketDomainGids = identities.filter((i) => isLivenessSupported(i.backend)).map((i) => i.gardenId);
84
+ const scan = await scanSocketProbes(socketDomainGids, deps.socket ?? {});
85
85
  const probes = scan.probes;
86
86
  const socketGids = new Set(probes.map((p) => p.gardenId));
87
87
  const symlinkedGids = new Set(scan.symlinkedGardenIds);
@@ -106,23 +106,21 @@ export async function listEntwurfFacts(deps) {
106
106
  message: `control-socket directory unreadable (socket axis incomplete; meta-record citizens still listed): ${scan.dirError}`,
107
107
  });
108
108
  }
109
- // 3. pre-quarantine non-pi citizens that collide with a control socket. The
110
- // predicate is SHARED with the v2 decider (isNonPiGardenIdSocketConflict) so
111
- // listing and dispatch cannot drift, and it unions socketGids with the
112
- // symlinkedGids: a symlinked socket is never probed (absent from socketGids),
113
- // so the old socketGids-only check let a non-pi citizen with a forged
114
- // (symlinked) socket survive as a clean PeerFact while the legacy send path
115
- // still followed the symlink — the gap this closes.
109
+ // 3. Pre-quarantine citizens outside the control-socket capability domain that
110
+ // collide with a control socket. The predicate is SHARED with v2 dispatch so
111
+ // listing and dispatch cannot drift. It unions real and symlinked socket gids:
112
+ // the earlier real-socket-only check let an out-of-domain record survive as a
113
+ // clean PeerFact beside a forged symlink receiver.
116
114
  const conflictGids = new Set();
117
115
  for (const id of identities) {
118
- if (isNonPiGardenIdSocketConflict(id.backend, id.gardenId, socketGids, symlinkedGids)) {
116
+ if (isOutOfSocketDomainGardenIdConflict(id.backend, id.gardenId, socketGids, symlinkedGids)) {
119
117
  conflictGids.add(id.gardenId);
120
118
  diagnostics.push({
121
119
  kind: "garden-id-socket-conflict",
122
120
  gardenId: id.gardenId,
123
121
  backend: id.backend,
124
- message: `non-pi citizen (${id.backend}) shares its gardenId with a control socket (real or symlinked) — address ` +
125
- "ambiguity; both the citizen and the socket are quarantined from the listing.",
122
+ message: `out-of-socket-domain citizen (${id.backend}) shares its gardenId with a control socket ` +
123
+ "(real or symlinked) address ambiguity; both sides are quarantined from the listing.",
126
124
  });
127
125
  }
128
126
  }
@@ -52,27 +52,27 @@ export function resolvePeerFact(identity, socket) {
52
52
  };
53
53
  }
54
54
  /**
55
- * A non-pi RECORD whose gardenId collides with a control socket — a real (probed)
56
- * one OR a symlinked/forged one. The gardenId is the universal address (동결결정3),
57
- * so a non-pi citizen sharing it with a socket means a send-path that reaches the
58
- * socket first hits a DIFFERENT receiver than the record names — an address split.
55
+ * An OUT-OF-SOCKET-DOMAIN record whose gardenId collides with a control socket —
56
+ * a real (probed) one OR a symlinked/forged one. The gardenId is the universal
57
+ * address, so a citizen without socket capability sharing it with a socket means
58
+ * the socket reaches a DIFFERENT receiver than the record names — an address split.
59
59
  * Both the citizen and the socket are quarantined from the facts listing.
60
60
  *
61
61
  * The union `socketGids ∪ symlinkedGardenIds` is load-bearing: `socketGids` are
62
62
  * gids with a real probed `*.sock`, but `symlinkedGardenIds` are NEVER probed (P1)
63
63
  * and so are absent from `socketGids`. Looking at `socketGids` alone (the
64
- * fact-provider:125 gap this closes) let a non-pi citizen with a *symlinked* socket
64
+ * fact-provider:125 gap this closes) let an out-of-domain citizen with a *symlinked* socket
65
65
  * survive as a clean PeerFact while the legacy send path still followed the symlink
66
66
  * to a forged receiver. Both axes claim the gid → both must quarantine it.
67
67
  *
68
- * SCOPE: this is the RECORD-side, non-pi conflict only — shared by the fact-provider
69
- * (listing) and the v2 decider (dispatch) so the two cannot drift (4c "재유도 금지"
70
- * 동형; only the observation-bit source is parameterized). A pi citizen whose own
71
- * canonical socket is a symlink is NOT this predicate's concern — that is a
68
+ * SCOPE: this is the record-side, out-of-socket-domain conflict only — shared by
69
+ * listing and dispatch so they cannot drift; only the observation-bit source is
70
+ * parameterized. A citizen IN the socket domain whose own canonical socket is a
71
+ * symlink is NOT this predicate's concern — that is a
72
72
  * target-specific lstat conflict the decider's `inspectTargetControlSocket` raises
73
73
  * as `address-conflict`, kept deliberately separate (GPT 1차 검수 C).
74
74
  */
75
- export function isNonPiGardenIdSocketConflict(backend, gardenId, socketGids, symlinkedGardenIds) {
75
+ export function isOutOfSocketDomainGardenIdConflict(backend, gardenId, socketGids, symlinkedGardenIds) {
76
76
  return !isLivenessSupported(backend) && (socketGids.has(gardenId) || symlinkedGardenIds.has(gardenId));
77
77
  }
78
78
  /**
@@ -89,8 +89,8 @@ export function isNonPiGardenIdSocketConflict(backend, gardenId, socketGids, sym
89
89
  * `indeterminate` and strand a dormant citizen as un-resumable); a dormant
90
90
  * citizen's absent socket file is probed to `dead` (ENOENT) by the wiring and
91
91
  * arrives here AS `dead` → dormant → resumable.
92
- * - out-of-domain citizen WITH a control socket at its gardenId → fail-loud
93
- * (address ambiguity; a non-pi citizen must not own a pi control socket).
92
+ * - out-of-socket-domain citizen WITH a control socket at its gardenId → fail-loud
93
+ * (address ambiguity; a backend without socket capability cannot own that rail).
94
94
  * - out-of-domain citizen without a socket → `unsupported` (via resolvePeerFact).
95
95
  * - a probed gardenId with NO citizen → `RecordLessSocketFact` (#50 C4: a
96
96
  * diagnostic subject the provider folds into a `record-less-socket`
@@ -125,7 +125,7 @@ export function resolveFactList(identities, socketProbes) {
125
125
  else {
126
126
  if (probeMap.has(gid)) {
127
127
  throw new Error(`resolveFactList: out-of-domain citizen ${gid} (${identity.backend}) has a control socket — ` +
128
- "address ambiguity (a non-pi citizen must not own a pi control socket)");
128
+ "address ambiguity (an out-of-socket-domain citizen cannot own a control socket)");
129
129
  }
130
130
  socket = null;
131
131
  }
@@ -1,59 +1,49 @@
1
1
  /**
2
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.
3
+ * launched with (0.11 Stage 0 step 5c-3b). One place owns the
4
+ * `--no-extensions` / `--entwurf-control` decision so the launch shape cannot drift.
6
5
  *
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.
6
+ * The shipped posture (A1): a RESIDENT, addressable garden citizen. The resume turn runs via
7
+ * `-p <prompt>` (the prompt-as-turn authority), and the child is launched WITH
8
+ * `--entwurf-control` and WITHOUT `--no-extensions` the keep-alive is the GOAL here: the
9
+ * resumed session stands its control socket up and stays addressable. The 5c-3a watcher's
10
+ * `socket-alive` is exactly that "resumed citizen is up" observation (→ release the per-gid
11
+ * lock, child lives on); `child-exited` is the early-exit/failure observation.
12
+ * `plan.launchArgs` (`--approve` or empty, from the decider's preflight) rides along as pi
13
+ * flags before the prompt.
14
+ *
15
+ * A second `legacy` one-shot posture (`--no-extensions`, no control socket, so `pi -p` could
16
+ * exit) lived here until 2026-07-27 and was removed with its launcher and the v1 verbs.
20
17
  *
21
18
  * Provider/model identity is the caller's existing authority (readSessionIdentity /
22
19
  * 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.)
20
+ * identity. `explicitExtensionArgs` is preserved verbatim: a recorded `provider=entwurf`
21
+ * resume needs the bridge re-injected to resolve the provider, and dropping it would
22
+ * re-introduce the "Unknown provider" footgun (#29). (A future slice may dedup against
23
+ * settings-loaded extensions; not here.)
27
24
  *
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.
25
+ * This module is import-free on purpose: the v2 adapter and the gate import the same source,
26
+ * and a self-contained string builder keeps both tsconfigs happy.
31
27
  */
32
28
  /**
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 <file> [--provider <p>] --model <m> <prompt>`.
29
+ * Build the `pi` argv for a resume spawn. The prefix is `--mode json -p` (headless JSON
30
+ * child, prompt-as-turn); then the resident posture; then
31
+ * `[…ext args] --session <file> [--provider <p>] --model <m> <prompt>`.
36
32
  *
37
33
  * Invariants the gate pins:
38
- * - legacy carries `--no-extensions` and NO `--entwurf-control`.
39
34
  * - 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.
35
+ * - `explicitExtensionArgs` appears exactly once.
36
+ * - `launchArgs` is included before the suffix.
37
+ * - provider/model/prompt identity layout is fixed.
43
38
  */
44
39
  export function buildResumePiArgs(input) {
45
40
  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.
41
+ // Resident citizen: stand the control socket up (A1) and keep extensions loaded. The
42
+ // keep-alive the removed one-shot launcher had to avoid is precisely the goal here
43
+ // the resumed session must stay addressable. `--approve`/launchArgs ride along.
44
+ args.push("--entwurf-control");
45
+ args.push(...(input.launchArgs ?? []));
46
+ // Suffix the fixed identity layout.
57
47
  args.push(...input.explicitExtensionArgs);
58
48
  args.push("--session", input.sessionFile);
59
49
  if (input.provider)
@@ -3,14 +3,13 @@
3
3
  *
4
4
  * "Can a reply to THIS session actually land where its model will see it?"
5
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.
6
+ * This module closed the former split where the MCP and native pi surfaces inferred
7
+ * `replyable: true` from identity-carrier presence alone. Identity and reachability
8
+ * are separate: pi's env carries the garden id established by record birth, while
9
+ * replyability requires its control socket; a native marker must be record-backed,
10
+ * while replyability additionally requires its own rail's live facts. This is the
11
+ * single truth-table both surfaces compute from, with every fact INJECTED (no IO) so
12
+ * the gate can pin each row.
14
13
  *
15
14
  * Axes by origin:
16
15
  * - pi-session: replyable ⟺ a live control socket exists at the canonical path.
@@ -33,7 +32,7 @@
33
32
  * — which never arms a watch — would report replyable:false forever.
34
33
  * - external-mcp: never replyable — no authoritative reply address.
35
34
  *
36
- * `origin` stays identity PROVENANCE (where the sender identity came from), never a rail.
35
+ * `origin` stays sender-carrier PROVENANCE, never the citizen identity authority and never a rail.
37
36
  * Which rail a meta citizen's reply rides is a SECOND axis — `metaDeliveryDomain`, derived
38
37
  * by the caller from `nativePushSupported(backend)`, not from `wakeMode` (direct-inject also
39
38
  * covers codex/pi, which have no native-push adapter). Fail-closed: an unsupplied domain is
@@ -7,25 +7,28 @@
7
7
  * `entwurf-v2-contract-schema.ts` (0.12.1 B-1) so this module — which the
8
8
  * harness-neutral MCP bridge reaches at boot — carries no pi dependency.
9
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).
10
+ * Why a frozen contract BEFORE the fact-provider (step 4): the legacy 3-verb
11
+ * surface (`entwurf`/`entwurf_resume`/`entwurf_send`) was still live when this
12
+ * was written, and building discovery first would have baked verb-routing into
13
+ * the fact layer, taking `entwurf_peers` wrong (동결결정 10 순서 근거). So the
14
+ * SHAPE is locked here; the facts read it; dispatch computes from facts at call
15
+ * time (step 5). Those v1 verbs were REMOVED in the 0.12 cutover — the ordering
16
+ * argument is history, but the layering it produced is the live design.
16
17
  *
17
18
  * Source-verified invariants folded in (Opus 실측 + GPT 보정 + Fable R1-R5, 2026-06-11):
18
19
  * - F1: caller intent is DECLARED in the input, so the contract a caller
19
20
  * receives is deterministic — never computed from liveness at call time.
20
21
  * `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.
22
+ * - R1: liveness is defined PER CAPABILITY DOMAIN. The control-socket domain
23
+ * currently contains backend `pi`; claude-code is self-fetch with no socket,
24
+ * so its socket liveness is `unsupported`, NOT folded into dead/indeterminate.
25
+ * This is transport capability, not identity rank: every target is first a
26
+ * record citizen. `unsupported` is a 4th FACT value, not a 4th table column.
26
27
  * - 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).
28
+ * v2 scope — it was the legacy `entwurf` verb's, and since that verb's removal
29
+ * fresh creation is a deferred capability with NO surface, not a fallback that
30
+ * still exists elsewhere. Absent/typo gid = `bad-target` (so F6 "오타 gid가
31
+ * 신규 spawn 사고 막기" holds automatically).
29
32
  * - N1/F3: an `indeterminate` target never spawns. N2: `fire-and-forget` to a
30
33
  * `dormant` target is "reject for now" (mailbox-wake lacks a reply-correlation
31
34
  * id in the substrate; an additive extension later, not a permanent no).
@@ -58,16 +61,16 @@ export const FACT_LIVENESSES = ["alive", "dead", "indeterminate", "unsupported"]
58
61
  // handled by the domain guard before the table is consulted.
59
62
  export const DISPATCH_LIVENESSES = ["live", "dormant", "indeterminate"];
60
63
  // ── Backend liveness domain (R1 + F4) ──────────────────────────────────────
61
- // Backends whose SOCKET liveness predicate is DEFINED the pi control-socket
62
- // domain ONLY (connect + RPC `get_info`, entwurf-control.ts). It stays ["pi"].
64
+ // Backends whose SOCKET liveness predicate is DEFINED. The control-socket
65
+ // capability domain currently contains `pi` (connect + RPC `get_info`).
63
66
  // claude-code (self-fetch, no socket) has no liveness predicate at all → `unsupported`.
64
67
  // codex/antigravity are direct-inject; antigravity's liveness IS measured, but by the
65
68
  // SEPARATE native-push adapter rail (a live app-server conversation probe), NOT this
66
69
  // pi-socket domain — so it must NEVER be added here. Adding it would pull agy into the
67
- // pi socket table (inspectSocket/probeSocket are socket-only); the fact layer keeps
68
- // reporting agy `unsupported` = "outside the pi-socket liveness domain", NOT
70
+ // control-socket table (inspectSocket/probeSocket are socket-only); the fact layer keeps
71
+ // reporting agy `unsupported` = "outside the control-socket liveness domain", NOT
69
72
  // unreachable (the native-push rail measures it — entwurf-v2-decider.ts). Widening
70
- // THIS set is a deliberate future decision (Stage 1+), gated by a REAL pi-shaped
73
+ // THIS set is a deliberate future decision, gated by a real compatible
71
74
  // control-socket predicate — never by silently mapping sessions to dead/indeterminate
72
75
  // (R1 핵심). check-entwurf-facts pins this == ["pi"] and asserts the native-push
73
76
  // domain is disjoint from it.
@@ -77,12 +80,12 @@ export function isLivenessSupported(backend) {
77
80
  }
78
81
  // ── Native-push backend domain (봉인 2/4) ───────────────────────────────────
79
82
  // A backend whose liveness is measured by the SEPARATE native-push adapter rail (a
80
- // live app-server conversation probe — antigravity's LS gRPC), NOT the pi control
81
- // socket. This domain is DISJOINT from LIVENESS_DOMAIN_BACKENDS (pi socket): an agy
82
- // session is `unsupported` at the pi-socket FACT level (entwurf_peers) yet fully
83
+ // live app-server conversation probe — antigravity's LS gRPC), NOT a control
84
+ // socket. This domain is DISJOINT from LIVENESS_DOMAIN_BACKENDS: an agy session
85
+ // is `unsupported` on the socket FACT axis (entwurf_peers) yet fully
83
86
  // measured + deliverable on the native-push axis. The two are separate rails on
84
87
  // purpose — check-entwurf-facts pins both sets and asserts their intersection is ∅
85
- // (a backend can never be in both a socket-liveness domain and a native-push domain).
88
+ // (a backend can never be in both domains).
86
89
  export const NATIVE_PUSH_BACKENDS = ["antigravity"];
87
90
  export function nativePushSupported(backend) {
88
91
  return NATIVE_PUSH_BACKENDS.includes(backend);
@@ -202,14 +205,18 @@ export const ENTWURF_V2_ACTIONS = ["send", "resume"];
202
205
  export const ENTWURF_V2_OWNERSHIPS = ["ack-only", "owned"];
203
206
  // Delivery mode of the message to the target (how it is injected) — steer =
204
207
  // interrupt the current turn, follow_up = queue after it. A SEPARATE axis from
205
- // both the intent/ownership axis (F1) and the liveness-routing axis; the legacy
206
- // entwurf_send carries the same steer|follow_up surface.
208
+ // both the intent/ownership axis (F1) and the liveness-routing axis. The removed
209
+ // v1 `entwurf_send` carried the same steer|follow_up surface, so this axis is
210
+ // inherited vocabulary, not a second live delivery verb.
207
211
  export const ENTWURF_V2_MODES = ["steer", "follow_up"];
208
212
  // ── The FROZEN decision table ──────────────────────────────────────────────
209
213
  // intent × dispatch-liveness → exactly one verdict (Q2). v2-initial ALLOWS
210
214
  // exactly two cells (fire-and-forget+live = send; owned-outcome+dormant =
211
215
  // resume); the other four reject. The reject cells are honest "지금은 없음"
212
- // locks (N2) the legacy 3-verb surface still covers those flows unchanged.
216
+ // locks (N2). They were written while the legacy 3-verb surface still covered
217
+ // those flows; that surface is GONE, so a reject cell is now a real absence with
218
+ // no fallback verb behind it — reopening one takes a new contract, never a
219
+ // quiet re-admission.
213
220
  export const DISPATCH_TABLE = {
214
221
  "fire-and-forget": {
215
222
  live: { action: "send", transport: "control-socket", ownership: "ack-only" },
@@ -26,7 +26,7 @@
26
26
  * MCP-schema bypass for pi-native/internal callers).
27
27
  * 2. resolveTarget — no citizen → bad-target; a record-less control socket →
28
28
  * record-less-socket (#50 C4: the record is the sole address authority);
29
- * quarantined (non-pi gid sharing a socket/symlink) → target-address-conflict.
29
+ * quarantined (out-of-socket-domain record sharing a socket/symlink) → target-address-conflict.
30
30
  * PROBE-FREE.
31
31
  * 3. backend → isLivenessSupported.
32
32
  * 4. acquireLock — IN-DOMAIN ONLY (?7), BEFORE lstat/connect, so the probe
@@ -45,14 +45,14 @@
45
45
  * never hand-assembles a `{ok:false, …}` literal, so the pre-probe-null rule cannot
46
46
  * be bypassed.
47
47
  */
48
- import { isNonPiGardenIdSocketConflict } from "./entwurf-facts.js";
48
+ import { isOutOfSocketDomainGardenIdConflict } from "./entwurf-facts.js";
49
49
  import { isLivenessSupported, makeRejectReceipt, nativePushSupported, resolveDispatch, resolveNativePushDispatch, } from "./entwurf-v2-contract.js";
50
50
  import { defaultMetaMailboxDir, defaultMetaSessionsDir, metaCapabilityFor, } from "./meta-session.js";
51
51
  import { isValidSessionId } from "./session-id.js";
52
52
  import { controlSocketPath, mapInspectionToLiveness } from "./socket-discovery.js";
53
53
  // Re-export the shared conflict predicate so producers of a TargetResolution have a
54
54
  // single import site for it (it is the SAME fn the fact-provider listing uses).
55
- export { isNonPiGardenIdSocketConflict };
55
+ export { isOutOfSocketDomainGardenIdConflict };
56
56
  // ── observe timeout (?3) ───────────────────────────────────────────────────
57
57
  // The bounded wait 5c's release-watcher gives a spawned child to surface its
58
58
  // control socket / exit. Planted into the resume plan so the hand does not invent
@@ -178,7 +178,7 @@ export async function decideDispatch(input, deps) {
178
178
  };
179
179
  return { kind: "execute", receipt, plan, lock: null };
180
180
  }
181
- // 4-5. in-domain (record-backed pi): lock → inspect → route (cwd from the record).
181
+ // 4-5. control-socket domain (currently backend pi): lock → inspect → route.
182
182
  return decideInDomain(gardenId, input, deps, ctx, identity.cwd);
183
183
  }
184
184
  async function decideInDomain(gardenId, input, deps, ctx, cwd) {
@@ -2,10 +2,11 @@
2
2
  * entwurf-v2-lock — the per-gid dispatch lock primitive (0.11 Stage 0 step 5a,
3
3
  * 버킷 B F2). LOAD-BEARING: the guard against a double-spawn of the same dormant
4
4
  * target by two V2 dispatchers that share the substrate through different entry
5
- * points. SCOPE (honest): this protects v2/v2 only. The legacy `entwurf_resume`
6
- * is unchanged (동결결정 10 scope A) and does NOT take this lock, so v2/legacy
7
- * concurrent resume is a KNOWN residual gap (rare single-orchestrator practice),
8
- * closed only at full cut-over. Do not read this header as "v2/legacy is guarded".
5
+ * points. SCOPE (honest): this protects v2/v2 only. It was written while the legacy
6
+ * `entwurf_resume` still ran unchanged (동결결정 10 scope A) without taking this
7
+ * lock, which left a v2/legacy concurrent-resume gap. That verb was REMOVED in the
8
+ * 0.12 cutover, so the gap is closed by subtraction — not by this lock growing to
9
+ * cover it. Any NEW resume entry point must take this lock or the gap reopens.
9
10
  *
10
11
  * ENVIRONMENT ASSUMPTION (stale reclaim): `hostname` equality is used as the
11
12
  * proxy for "same machine", so a holder pid is reclaim-probed with kill(0) only
@@ -30,7 +30,7 @@
30
30
  */
31
31
  import { formatSenderInfoBlock, sendRpcCommand as realSendRpc, } from "./entwurf-control-rpc.js";
32
32
  import { mailboxConversationalDeliverable, receiverMarkerMatchesIdentity, } from "./entwurf-deliverability.js";
33
- import { isNonPiGardenIdSocketConflict } from "./entwurf-facts.js";
33
+ import { isOutOfSocketDomainGardenIdConflict } from "./entwurf-facts.js";
34
34
  import { isLivenessSupported } from "./entwurf-v2-contract.js";
35
35
  import { decideDispatch, } from "./entwurf-v2-decider.js";
36
36
  import { ENTWURF_V2_LOCK_DIR, acquireLock as realAcquireLock, releaseLock as realReleaseLock, } from "./entwurf-v2-lock.js";
@@ -68,7 +68,7 @@ async function lazyProductionPreflight(input) {
68
68
  return preflight(input);
69
69
  }
70
70
  /** Map a record-side socket inspection to the singleton (socketGids, symlinkedGids) the
71
- * `isNonPiGardenIdSocketConflict` predicate consumes. `indeterminate` fails LOUD (QB2): an
71
+ * `isOutOfSocketDomainGardenIdConflict` predicate consumes. `indeterminate` fails LOUD (QB2): an
72
72
  * unprovable conflict must NOT be folded to "no conflict" — that would silently allow an
73
73
  * unsupported-backend mailbox send onto a quarantined address. */
74
74
  function conflictSetsFor(gid, inspection) {
@@ -162,20 +162,18 @@ export function makeProductionEntwurfV2Deps(opts) {
162
162
  };
163
163
  }
164
164
  const identity = io.readIdentity(gid, sessionsDir);
165
- // `preProbeAddressConflict` is the record-side NON-PI conflict ONLY (B1). An in-domain
166
- // (pi) target's socket lstat/connect MUST run UNDER the lock in the decider's later
167
- // `inspectSocket` step (1C: lock BEFORE lstat/connect) so a pi target short-circuits
168
- // here with NO pre-lock lstat. Doing the lstat here would (a) be a needless pre-lock IO
169
- // and (b) turn a pi target's `indeterminate` lstat into a top-level throw, stealing it
170
- // from the under-lock `inspectSocket → indeterminate → indeterminate-no-spawn` path.
165
+ // This pre-probe conflict applies only OUTSIDE the control-socket capability domain.
166
+ // An in-domain target's lstat/connect MUST run under the later per-target lock, so it
167
+ // short-circuits here with no pre-lock IO. Otherwise an indeterminate lstat would be
168
+ // stolen from the under-lock `inspectSocket indeterminate-no-spawn` path.
171
169
  if (isLivenessSupported(identity.backend)) {
172
170
  return { identity, preProbeAddressConflict: false };
173
171
  }
174
- // Only an unsupported (non-pi) citizen reaches the record-side lstat: a single lstat (no
175
- // connect) of the canonical path; `indeterminate` fails loud (QB2 — never "no conflict").
172
+ // Only a citizen outside the control-socket domain reaches this record-side lstat;
173
+ // `indeterminate` fails loud (QB2 — never silently "no conflict").
176
174
  const inspection = await io.inspectPath(controlSocketPath(gid, controlSocketDir));
177
175
  const { socketGids, symlinkedGids } = conflictSetsFor(gid, inspection);
178
- const preProbeAddressConflict = isNonPiGardenIdSocketConflict(identity.backend, gid, socketGids, symlinkedGids);
176
+ const preProbeAddressConflict = isOutOfSocketDomainGardenIdConflict(identity.backend, gid, socketGids, symlinkedGids);
179
177
  return { identity, preProbeAddressConflict };
180
178
  };
181
179
  const inspectSocket = (gid) => io.inspectSocket(gid, controlSocketDir);
@@ -90,7 +90,9 @@ function requireProductionChild(child) {
90
90
  export function resolveResumeLaunchIdentity(plan) {
91
91
  const record = readAddressableMetaIdentity(plan.sessionId);
92
92
  if (record.backend !== "pi") {
93
- throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} is a ${record.backend} citizen — spawn-bg resume is the pi rail.`);
93
+ throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} is a ${record.backend} citizen — ` +
94
+ `spawn-bg resume is a host-adapter capability, and its domain currently contains backend pi only. ` +
95
+ `This is a relaunch capability, not the control-socket rail and not citizen rank.`);
94
96
  }
95
97
  const sessionFile = record.transcriptPath;
96
98
  if (!sessionFile) {