@junghanacs/entwurf 0.12.8 → 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 (96) hide show
  1. package/AGENTS.md +134 -248
  2. package/BASELINE.md +1 -1
  3. package/CHANGELOG.md +39 -1
  4. package/DELIVERY.md +5 -4
  5. package/README.md +148 -23
  6. package/VERIFY.md +3 -2
  7. package/demo/README.md +2 -2
  8. package/demo/demo.sh +7 -1
  9. package/docs/setup-clean-host.md +24 -7
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +168 -81
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +12 -12
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +5 -3
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +16 -18
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +14 -13
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +11 -3
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +15 -4
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +500 -54
  26. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +8 -3
  27. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +14 -2
  28. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +155 -29
  29. package/mcp/entwurf-bridge/src/index.ts +200 -94
  30. package/package.json +11 -9
  31. package/pi-extensions/entwurf-control.ts +81 -39
  32. package/pi-extensions/lib/acp/models.ts +12 -12
  33. package/pi-extensions/lib/acp/overlay.ts +5 -3
  34. package/pi-extensions/lib/entwurf-core.ts +26 -9
  35. package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
  36. package/pi-extensions/lib/entwurf-fact-provider.ts +24 -19
  37. package/pi-extensions/lib/entwurf-facts.ts +13 -13
  38. package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
  39. package/pi-extensions/lib/entwurf-self-address.ts +8 -9
  40. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
  41. package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
  42. package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
  43. package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
  44. package/pi-extensions/lib/entwurf-v2-production.ts +22 -13
  45. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +11 -3
  46. package/pi-extensions/lib/meta-sender-identity.ts +15 -5
  47. package/pi-extensions/lib/meta-session.ts +543 -58
  48. package/pi-extensions/meta-bridge-hook.ts +8 -2
  49. package/run.sh +78 -67
  50. package/scripts/agy-bridge-config.py +249 -49
  51. package/scripts/agy-bridge.sh +59 -14
  52. package/scripts/agy-imprint.ts +15 -1
  53. package/scripts/check-acp-carrier-augment.ts +34 -2
  54. package/scripts/check-acp-sdk-surface.ts +22 -11
  55. package/scripts/check-acp-session-reuse.ts +1 -1
  56. package/scripts/check-acp-session-store.ts +3 -3
  57. package/scripts/check-agy-permission-matrix.py +655 -0
  58. package/scripts/check-agy-sender-identity.ts +83 -0
  59. package/scripts/check-entwurf-control-rpc.ts +2 -2
  60. package/scripts/check-entwurf-deliverability.ts +9 -6
  61. package/scripts/check-entwurf-fact-provider.ts +16 -8
  62. package/scripts/check-entwurf-facts.ts +13 -13
  63. package/scripts/check-entwurf-resume-args.ts +25 -63
  64. package/scripts/check-entwurf-self-address.ts +187 -4
  65. package/scripts/check-entwurf-session-identity.ts +7 -6
  66. package/scripts/check-entwurf-v2-contract.ts +2 -2
  67. package/scripts/check-entwurf-v2-production.ts +9 -7
  68. package/scripts/check-entwurf-v2-spawn-production.ts +8 -4
  69. package/scripts/check-entwurf-v2-surface.ts +327 -14
  70. package/scripts/check-fresh-cut-gate.sh +305 -4
  71. package/scripts/check-gate-qualification.ts +785 -0
  72. package/scripts/check-meta-identity-consumers.ts +501 -1
  73. package/scripts/check-meta-listing.ts +91 -9
  74. package/scripts/check-meta-receiver-marker.ts +54 -0
  75. package/scripts/check-model-lock.ts +1 -1
  76. package/scripts/check-shell-quote.ts +2 -1
  77. package/scripts/lib/mutation-qualify.ts +794 -0
  78. package/scripts/meta-bridge-fresh-cut.ts +164 -28
  79. package/scripts/mutants/acp-augment.json +30 -0
  80. package/scripts/mutants/agy-permission.json +144 -0
  81. package/scripts/mutants/meta-identity.json +17 -0
  82. package/scripts/mutants/self-address.json +59 -0
  83. package/scripts/mutants/v2-surface.json +87 -0
  84. package/scripts/pi_settings_io.py +65 -0
  85. package/scripts/register-pi-package.py +183 -37
  86. package/scripts/register-pi-provider.py +68 -10
  87. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  88. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  89. package/scripts/smoke-acp-v2-send-live.ts +33 -18
  90. package/scripts/smoke-agy-install-state.sh +205 -20
  91. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
  92. package/scripts/smoke-meta-install-state.sh +1 -1
  93. package/scripts/smoke-pi-attach.ts +7 -2
  94. package/scripts/smoke-user-scope-citizen.sh +177 -0
  95. package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
  96. package/scripts/check-entwurf-mailbox-guard.ts +0 -262
@@ -1,40 +1,40 @@
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
- export type ResumeArgsVariant = "legacy" | "v2-control";
29
+ /** The one shipped launch posture: a resident citizen (`--entwurf-control`, extensions
30
+ * loaded). The `legacy` one-shot variant was removed with its launcher (2026-07-27) — an
31
+ * exported branch no product path took was what let this module's prose claim a second
32
+ * live consumer for months. Reviving a one-shot posture means adding it back deliberately,
33
+ * with a consumer, not un-deleting a dead enum member. */
34
+ export type ResumeArgsVariant = "v2-control";
34
35
 
35
36
  export interface ResumePiArgsInput {
36
- /** legacy = one-shot worker (`--no-extensions`, no control socket); v2-control =
37
- * resident citizen (`--entwurf-control`, extensions loaded). */
37
+ /** v2-control = resident citizen (`--entwurf-control`, extensions loaded). */
38
38
  variant: ResumeArgsVariant;
39
39
  /** ABSOLUTE path of the session JSONL to resume — pi's `--session <path>` (#50 C2).
40
40
  * It replaced `--session-id <gardenId>`, which did two jobs that are no longer the
@@ -46,7 +46,7 @@ export interface ResumePiArgsInput {
46
46
  * instead of resuming one. A path cannot do that. */
47
47
  sessionFile: string;
48
48
  /** The explicit `--extension …` re-injection (ACP bridge / provider resolution).
49
- * Preserved verbatim in BOTH variants — load-bearing for a entwurf resume. */
49
+ * Preserved verbatim — load-bearing for a entwurf resume. */
50
50
  explicitExtensionArgs: readonly string[];
51
51
  /** Recorded provider (may be null/undefined — then no `--provider` flag is emitted). */
52
52
  provider: string | null | undefined;
@@ -54,37 +54,31 @@ export interface ResumePiArgsInput {
54
54
  model: string;
55
55
  /** The resume prompt — the final positional, run as the model turn under `-p`. */
56
56
  prompt: string;
57
- /** v2-control ONLY: the decider's `plan.launchArgs` (`["--approve"]` or `[]`). Ignored
58
- * for legacy (the legacy path computes its own preflight elsewhere). */
57
+ /** The decider's `plan.launchArgs` (`["--approve"]` or `[]`). */
59
58
  launchArgs?: readonly string[];
60
59
  }
61
60
 
62
61
  /**
63
- * Build the `pi` argv for a resume spawn. The SHARED prefix is `--mode json -p` (headless
64
- * JSON child, prompt-as-turn); the variant then chooses the extension/socket posture; the
65
- * SHARED suffix is `[…ext args] --session <file> [--provider <p>] --model <m> <prompt>`.
62
+ * Build the `pi` argv for a resume spawn. The prefix is `--mode json -p` (headless JSON
63
+ * child, prompt-as-turn); then the resident posture; then
64
+ * `[…ext args] --session <file> [--provider <p>] --model <m> <prompt>`.
66
65
  *
67
66
  * Invariants the gate pins:
68
- * - legacy carries `--no-extensions` and NO `--entwurf-control`.
69
67
  * - v2-control carries `--entwurf-control` and NO `--no-extensions`, plus `-p` + prompt.
70
- * - `explicitExtensionArgs` appears exactly once in both.
71
- * - `launchArgs` is included for v2-control (before the suffix) and ignored for legacy.
72
- * - provider/model/prompt identity is laid out identically in both.
68
+ * - `explicitExtensionArgs` appears exactly once.
69
+ * - `launchArgs` is included before the suffix.
70
+ * - provider/model/prompt identity layout is fixed.
73
71
  */
74
72
  export function buildResumePiArgs(input: ResumePiArgsInput): string[] {
75
73
  const args: string[] = ["--mode", "json", "-p"];
76
74
 
77
- if (input.variant === "legacy") {
78
- // One-shot worker: no extensions, no control socket (so `pi -p` can exit).
79
- args.push("--no-extensions");
80
- } else {
81
- // Resident citizen: stand the control socket up (A1) and keep extensions loaded.
82
- // The keep-alive legacy avoided is the goal here. `--approve`/launchArgs ride along.
83
- args.push("--entwurf-control");
84
- args.push(...(input.launchArgs ?? []));
85
- }
75
+ // Resident citizen: stand the control socket up (A1) and keep extensions loaded. The
76
+ // keep-alive the removed one-shot launcher had to avoid is precisely the goal here
77
+ // the resumed session must stay addressable. `--approve`/launchArgs ride along.
78
+ args.push("--entwurf-control");
79
+ args.push(...(input.launchArgs ?? []));
86
80
 
87
- // Shared suffix identical identity layout in both variants.
81
+ // Suffixthe fixed identity layout.
88
82
  args.push(...input.explicitExtensionArgs);
89
83
  args.push("--session", input.sessionFile);
90
84
  if (input.provider) args.push("--provider", 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
@@ -9,7 +9,12 @@
9
9
  * from the bridge boot closure would re-couple the harness-neutral meta-bridge to
10
10
  * pi (the `check-entwurf-bridge-pi-free` gate fails if it does). The bridge needs
11
11
  * the contract CONSTANTS + `resolveDispatch` (pi-free core), never these schemas.
12
- * Consumers: the pi MCP-tool param surface and `check-entwurf-v2-contract`.
12
+ * Consumer: `check-entwurf-v2-contract` ONLY. This is a gate-side REPRESENTATION of the
13
+ * frozen contract, not a shipped tool schema — measured 2026-07-27, nothing else in the
14
+ * import graph reaches it. The two surfaces a model actually reads build their own:
15
+ * `pi-extensions/entwurf-control.ts` (TypeBox `entwurfV2Parameters` + tool description) and
16
+ * `mcp/entwurf-bridge/src/index.ts` (Zod). Change a model-facing description THERE; changing
17
+ * it here reaches no model, and `check-entwurf-v2-surface` is what pins those two.
13
18
  *
14
19
  * StringEnum (typebox 1.x) inside Type.Object (typebox 0.34) — the same mix the
15
20
  * existing entwurf tools use. The logic types in the core are hand-written unions,
@@ -37,7 +42,7 @@ export const EntwurfV2InputSchema = Type.Object(
37
42
  target: Type.String({
38
43
  pattern: SESSION_ID_RE.source,
39
44
  description:
40
- "garden-id of an EXISTING citizen (pattern-enforced). spawn-new is out of v2 scope (legacy entwurf keeps it); a malformed/typo gid is bad-target.",
45
+ "garden-id of an EXISTING citizen (pattern-enforced). Fresh sibling creation is out of scope on every current surface there is no fallback verb that still spawns. A malformed/typo gid is bad-target, never a new spawn.",
41
46
  }),
42
47
  intent: StringEnum(ENTWURF_INTENTS, {
43
48
  description:
@@ -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).
@@ -67,16 +70,16 @@ export const DISPATCH_LIVENESSES = ["live", "dormant", "indeterminate"] as const
67
70
  export type DispatchLiveness = (typeof DISPATCH_LIVENESSES)[number];
68
71
 
69
72
  // ── Backend liveness domain (R1 + F4) ──────────────────────────────────────
70
- // Backends whose SOCKET liveness predicate is DEFINED the pi control-socket
71
- // domain ONLY (connect + RPC `get_info`, entwurf-control.ts). It stays ["pi"].
73
+ // Backends whose SOCKET liveness predicate is DEFINED. The control-socket
74
+ // capability domain currently contains `pi` (connect + RPC `get_info`).
72
75
  // claude-code (self-fetch, no socket) has no liveness predicate at all → `unsupported`.
73
76
  // codex/antigravity are direct-inject; antigravity's liveness IS measured, but by the
74
77
  // SEPARATE native-push adapter rail (a live app-server conversation probe), NOT this
75
78
  // pi-socket domain — so it must NEVER be added here. Adding it would pull agy into the
76
- // pi socket table (inspectSocket/probeSocket are socket-only); the fact layer keeps
77
- // reporting agy `unsupported` = "outside the pi-socket liveness domain", NOT
79
+ // control-socket table (inspectSocket/probeSocket are socket-only); the fact layer keeps
80
+ // reporting agy `unsupported` = "outside the control-socket liveness domain", NOT
78
81
  // unreachable (the native-push rail measures it — entwurf-v2-decider.ts). Widening
79
- // THIS set is a deliberate future decision (Stage 1+), gated by a REAL pi-shaped
82
+ // THIS set is a deliberate future decision, gated by a real compatible
80
83
  // control-socket predicate — never by silently mapping sessions to dead/indeterminate
81
84
  // (R1 핵심). check-entwurf-facts pins this == ["pi"] and asserts the native-push
82
85
  // domain is disjoint from it.
@@ -89,12 +92,12 @@ export function isLivenessSupported(backend: string): boolean {
89
92
 
90
93
  // ── Native-push backend domain (봉인 2/4) ───────────────────────────────────
91
94
  // A backend whose liveness is measured by the SEPARATE native-push adapter rail (a
92
- // live app-server conversation probe — antigravity's LS gRPC), NOT the pi control
93
- // socket. This domain is DISJOINT from LIVENESS_DOMAIN_BACKENDS (pi socket): an agy
94
- // session is `unsupported` at the pi-socket FACT level (entwurf_peers) yet fully
95
+ // live app-server conversation probe — antigravity's LS gRPC), NOT a control
96
+ // socket. This domain is DISJOINT from LIVENESS_DOMAIN_BACKENDS: an agy session
97
+ // is `unsupported` on the socket FACT axis (entwurf_peers) yet fully
95
98
  // measured + deliverable on the native-push axis. The two are separate rails on
96
99
  // purpose — check-entwurf-facts pins both sets and asserts their intersection is ∅
97
- // (a backend can never be in both a socket-liveness domain and a native-push domain).
100
+ // (a backend can never be in both domains).
98
101
  export const NATIVE_PUSH_BACKENDS = ["antigravity"] as const;
99
102
  export type NativePushBackend = (typeof NATIVE_PUSH_BACKENDS)[number];
100
103
 
@@ -239,8 +242,9 @@ export const ENTWURF_V2_ACTIONS = ["send", "resume"] as const;
239
242
  export const ENTWURF_V2_OWNERSHIPS = ["ack-only", "owned"] as const;
240
243
  // Delivery mode of the message to the target (how it is injected) — steer =
241
244
  // interrupt the current turn, follow_up = queue after it. A SEPARATE axis from
242
- // both the intent/ownership axis (F1) and the liveness-routing axis; the legacy
243
- // entwurf_send carries the same steer|follow_up surface.
245
+ // both the intent/ownership axis (F1) and the liveness-routing axis. The removed
246
+ // v1 `entwurf_send` carried the same steer|follow_up surface, so this axis is
247
+ // inherited vocabulary, not a second live delivery verb.
244
248
  export const ENTWURF_V2_MODES = ["steer", "follow_up"] as const;
245
249
 
246
250
  export type DispatchVerdict =
@@ -252,7 +256,10 @@ export type DispatchVerdict =
252
256
  // intent × dispatch-liveness → exactly one verdict (Q2). v2-initial ALLOWS
253
257
  // exactly two cells (fire-and-forget+live = send; owned-outcome+dormant =
254
258
  // resume); the other four reject. The reject cells are honest "지금은 없음"
255
- // locks (N2) the legacy 3-verb surface still covers those flows unchanged.
259
+ // locks (N2). They were written while the legacy 3-verb surface still covered
260
+ // those flows; that surface is GONE, so a reject cell is now a real absence with
261
+ // no fallback verb behind it — reopening one takes a new contract, never a
262
+ // quiet re-admission.
256
263
  export const DISPATCH_TABLE: Record<EntwurfIntent, Record<DispatchLiveness, DispatchVerdict>> = {
257
264
  "fire-and-forget": {
258
265
  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
@@ -47,7 +47,7 @@
47
47
  */
48
48
 
49
49
  import type { MailboxDeliverabilityResult } from "./entwurf-deliverability.ts";
50
- import { isNonPiGardenIdSocketConflict } from "./entwurf-facts.ts";
50
+ import { isOutOfSocketDomainGardenIdConflict } from "./entwurf-facts.ts";
51
51
  import type { PreflightOutcome } from "./entwurf-preflight.ts";
52
52
  import {
53
53
  type EntwurfIntent,
@@ -75,7 +75,7 @@ import type { SocketLiveness } from "./socket-probe.ts";
75
75
 
76
76
  // Re-export the shared conflict predicate so producers of a TargetResolution have a
77
77
  // single import site for it (it is the SAME fn the fact-provider listing uses).
78
- export { isNonPiGardenIdSocketConflict };
78
+ export { isOutOfSocketDomainGardenIdConflict };
79
79
 
80
80
  // ── observe timeout (?3) ───────────────────────────────────────────────────
81
81
  // The bounded wait 5c's release-watcher gives a spawned child to surface its
@@ -172,8 +172,8 @@ export type DispatchDecision =
172
172
 
173
173
  // ── target resolution (E: single-target, not a whole-store scan) ────────────
174
174
  // A non-null identity is an existing citizen. `preProbeAddressConflict` is the
175
- // PROBE-FREE, record-side conflict (non-pi gid sharing a real/symlinked socket
176
- // isNonPiGardenIdSocketConflict). The production wrapper computes it with a single
175
+ // PROBE-FREE record-side conflict: a backend outside the control-socket domain
176
+ // sharing a real/symlinked socket (`isOutOfSocketDomainGardenIdConflict`). Production uses one
177
177
  // readMetaIdentityByGardenId + a target socket/symlink check; the gate injects it.
178
178
  // Do NOT call listEntwurfFacts here to find the target — its socket probe would run
179
179
  // before the lock (the 1C TOCTOU). The shared predicate is the only thing the
@@ -377,12 +377,12 @@ export async function decideDispatch(input: DispatchInput, deps: DispatchDecider
377
377
  return { kind: "execute", receipt, plan, lock: null };
378
378
  }
379
379
 
380
- // 4-5. in-domain (record-backed pi): lock → inspect → route (cwd from the record).
380
+ // 4-5. control-socket domain (currently backend pi): lock → inspect → route.
381
381
  return decideInDomain(gardenId, input, deps, ctx, identity.cwd);
382
382
  }
383
383
 
384
- // ── in-domain probe (steps 4-5) record-backed pi ONLY (#50 C4: a record-less
385
- // socket rejects pre-probe and never reaches here) ──────────────────────────────
384
+ // ── control-socket-domain probe (steps 4-5; currently backend pi). Every target
385
+ // is record-backed; a record-less socket rejects before this branch. ────────────
386
386
  // The lock lifecycle (B2) lives here: acquire BEFORE lstat/connect, every reject path
387
387
  // releases explicitly (rejectAfterRelease), every execute path that keeps the lock sets
388
388
  // retainLock=true, and a thrown IO error releases the still-held lock before rethrowing so
@@ -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
@@ -21,6 +21,9 @@
21
21
  *
22
22
  * `resolveTarget` (QB1): a MISSING meta-record is a soft `bad-target` (identity:null); a
23
23
  * PRESENT-but-corrupt record fails LOUD (the read throws drift) — never matched by string.
24
+ * "Corrupt" includes a record that does not hold its `nativeSessionId` alone (#52): the read
25
+ * here is the ADDRESSABLE one, so the store-wide uniqueness half runs at this boundary and
26
+ * nowhere cheaper. Absent stays soft; ambiguous never does.
24
27
  * `preProbeAddressConflict` (QB2): a single record-side `lstat` (NO connect) of the target's
25
28
  * canonical socket path; `indeterminate` (EACCES/unknown) fails loud rather than silently
26
29
  * claiming "no conflict". The decider's later `inspectSocket` probe is a SEPARATE step.
@@ -39,7 +42,7 @@ import {
39
42
  mailboxConversationalDeliverable,
40
43
  receiverMarkerMatchesIdentity,
41
44
  } from "./entwurf-deliverability.ts";
42
- import { isNonPiGardenIdSocketConflict } from "./entwurf-facts.ts";
45
+ import { isOutOfSocketDomainGardenIdConflict } from "./entwurf-facts.ts";
43
46
  // 0.12.1 B-2: TYPE-ONLY import — `entwurf-preflight.ts` value-imports
44
47
  // `@earendil-works/pi-coding-agent` (ProjectTrustStore), so a static value-import
45
48
  // here would re-couple the harness-neutral MCP bridge to pi at boot
@@ -84,7 +87,7 @@ import {
84
87
  type MetaReceiverMarker,
85
88
  metaCapabilityFor,
86
89
  metaRecordExistsByGardenId,
87
- readMetaIdentityByGardenId,
90
+ readAddressableMetaIdentity,
88
91
  readMetaReceiverMarker,
89
92
  } from "./meta-session.ts";
90
93
  import {
@@ -110,6 +113,9 @@ import { classifyConnectError, probeSocketLiveness, type SocketLiveness } from "
110
113
  */
111
114
  export interface ProductionEntwurfV2Seams {
112
115
  metaRecordExists: (gid: string, sessionsDir: string) => boolean;
116
+ /** The ADDRESSABLE read (#52): the per-entry contract plus store-wide `nativeSessionId`
117
+ * uniqueness. This is a dispatch, so the id it returns is about to become an address —
118
+ * a duplicate here would direct-inject one native session under two garden ids. */
113
119
  readIdentity: (gid: string, sessionsDir: string) => MetaIdentity;
114
120
  /** Read the target's receiver presence marker (null = absent / dead owner / corrupt). The
115
121
  * SE-2 2d-3 active-receiver source; the factory's `mailboxDeliverabilityFor` closure verifies
@@ -183,7 +189,7 @@ async function lazyProductionPreflight(input: PreflightInput): Promise<Preflight
183
189
  }
184
190
 
185
191
  /** Map a record-side socket inspection to the singleton (socketGids, symlinkedGids) the
186
- * `isNonPiGardenIdSocketConflict` predicate consumes. `indeterminate` fails LOUD (QB2): an
192
+ * `isOutOfSocketDomainGardenIdConflict` predicate consumes. `indeterminate` fails LOUD (QB2): an
187
193
  * unprovable conflict must NOT be folded to "no conflict" — that would silently allow an
188
194
  * unsupported-backend mailbox send onto a quarantined address. */
189
195
  function conflictSetsFor(
@@ -222,7 +228,7 @@ export function makeProductionEntwurfV2Deps(opts: ProductionEntwurfV2Opts): Entw
222
228
  const s = opts.seams ?? {};
223
229
  const io: ProductionEntwurfV2Seams = {
224
230
  metaRecordExists: s.metaRecordExists ?? metaRecordExistsByGardenId,
225
- readIdentity: s.readIdentity ?? readMetaIdentityByGardenId,
231
+ readIdentity: s.readIdentity ?? readAddressableMetaIdentity,
226
232
  readReceiverMarker: s.readReceiverMarker ?? ((gid: string) => readMetaReceiverMarker({ gardenId: gid })),
227
233
  inspectPath: s.inspectPath ?? inspectControlSocketPath,
228
234
  acquireLock: s.acquireLock ?? realAcquireLock,
@@ -288,20 +294,23 @@ export function makeProductionEntwurfV2Deps(opts: ProductionEntwurfV2Opts): Entw
288
294
  };
289
295
  }
290
296
  const identity = io.readIdentity(gid, sessionsDir);
291
- // `preProbeAddressConflict` is the record-side NON-PI conflict ONLY (B1). An in-domain
292
- // (pi) target's socket lstat/connect MUST run UNDER the lock in the decider's later
293
- // `inspectSocket` step (1C: lock BEFORE lstat/connect) so a pi target short-circuits
294
- // here with NO pre-lock lstat. Doing the lstat here would (a) be a needless pre-lock IO
295
- // and (b) turn a pi target's `indeterminate` lstat into a top-level throw, stealing it
296
- // from the under-lock `inspectSocket → indeterminate → indeterminate-no-spawn` path.
297
+ // This pre-probe conflict applies only OUTSIDE the control-socket capability domain.
298
+ // An in-domain target's lstat/connect MUST run under the later per-target lock, so it
299
+ // short-circuits here with no pre-lock IO. Otherwise an indeterminate lstat would be
300
+ // stolen from the under-lock `inspectSocket indeterminate-no-spawn` path.
297
301
  if (isLivenessSupported(identity.backend)) {
298
302
  return { identity, preProbeAddressConflict: false };
299
303
  }
300
- // Only an unsupported (non-pi) citizen reaches the record-side lstat: a single lstat (no
301
- // connect) of the canonical path; `indeterminate` fails loud (QB2 — never "no conflict").
304
+ // Only a citizen outside the control-socket domain reaches this record-side lstat;
305
+ // `indeterminate` fails loud (QB2 — never silently "no conflict").
302
306
  const inspection = await io.inspectPath(controlSocketPath(gid, controlSocketDir));
303
307
  const { socketGids, symlinkedGids } = conflictSetsFor(gid, inspection);
304
- const preProbeAddressConflict = isNonPiGardenIdSocketConflict(identity.backend, gid, socketGids, symlinkedGids);
308
+ const preProbeAddressConflict = isOutOfSocketDomainGardenIdConflict(
309
+ identity.backend,
310
+ gid,
311
+ socketGids,
312
+ symlinkedGids,
313
+ );
305
314
  return { identity, preProbeAddressConflict };
306
315
  };
307
316
 
@@ -35,7 +35,7 @@ import { buildResumePiArgs } from "./entwurf-resume-args.ts";
35
35
  import type { LockClaim } from "./entwurf-v2-lock.ts";
36
36
  import { releaseLock } from "./entwurf-v2-lock.ts";
37
37
  import type { SpawnBgPlan, SpawnBgResumeDeps, SpawnedChild } from "./entwurf-v2-spawn.ts";
38
- import { readMetaIdentityByGardenId } from "./meta-session.ts";
38
+ import { readAddressableMetaIdentity } from "./meta-session.ts";
39
39
  import { inspectControlSocketPath, type LstatLike, mapInspectionToLiveness } from "./socket-discovery.ts";
40
40
  import { probeSocketLiveness, type SocketLiveness } from "./socket-probe.ts";
41
41
 
@@ -120,16 +120,24 @@ export interface LaunchIdentity {
120
120
  * record remembers whose it is — a mismatch means the transcriptPath is stale or
121
121
  * foreign, and resuming it would put a turn into a different being's session).
122
122
  *
123
+ * That check is per-record, and per-record is not enough: the v2 lock domain is keyed on
124
+ * GARDEN ID, so two records sharing one `nativeSessionId` would each pass their own
125
+ * integrity check and resume the SAME transcript concurrently under two different locks.
126
+ * Hence {@link readAddressableMetaIdentity} rather than the plain targeted read (#52) —
127
+ * a resume is exactly the moment a record stops being data and becomes an address.
128
+ *
123
129
  * Everything else is unchanged authority: readSessionIdentity (first model_change) for
124
130
  * provider/model/cwd, getEntwurfExplicitExtensions for bridge re-injection (#29 fail-fast).
125
131
  * Throws on anything that makes a resume impossible; each throw becomes the watcher's
126
132
  * `spawn-start-failed` (no child to watch → release), never a silent no-op.
127
133
  */
128
134
  export function resolveResumeLaunchIdentity(plan: SpawnBgPlan): LaunchIdentity {
129
- const record = readMetaIdentityByGardenId(plan.sessionId);
135
+ const record = readAddressableMetaIdentity(plan.sessionId);
130
136
  if (record.backend !== "pi") {
131
137
  throw new Error(
132
- `entwurf-v2-spawn-production: ${plan.sessionId} is a ${record.backend} citizen — spawn-bg resume is the pi rail.`,
138
+ `entwurf-v2-spawn-production: ${plan.sessionId} is a ${record.backend} citizen — ` +
139
+ `spawn-bg resume is a host-adapter capability, and its domain currently contains backend pi only. ` +
140
+ `This is a relaunch capability, not the control-socket rail and not citizen rank.`,
133
141
  );
134
142
  }
135
143
  const sessionFile = record.transcriptPath;
@@ -16,8 +16,13 @@
16
16
  * remains compatibility for an MCP host wrapper; the hook never writes a blind grandparent
17
17
  * marker because that may be the long-lived login shell.
18
18
  *
19
- * Two guards make a marker an IDENTITY rather than a hint, and a candidate is only trusted after
20
- * BOTH pass:
19
+ * Three guards make a marker an IDENTITY rather than a hint, and a candidate is only trusted
20
+ * after ALL of them pass:
21
+ * 0. a PLAUSIBLE owner (isPlausibleOwnerPid, shared with both writers and the generation cut):
22
+ * pid <= 1 cannot own a session, and guard 1 cannot catch it — for as long as the host
23
+ * is up, init IS still the very process the marker named. This is a rule about the
24
+ * marker's CLAIM, not about the owner's state, which is why it is asked first and is
25
+ * not opt-out (#53 A).
21
26
  * 1. pid + start-key (readMetaSenderMarker): the owner is still the very process that wrote it,
22
27
  * so a dead session's pid, reused by something else, cannot inherit its garden-id.
23
28
  * 2. the backing meta-record: the record store is the authority — a marker whose record was
@@ -32,6 +37,7 @@
32
37
  */
33
38
 
34
39
  import {
40
+ isPlausibleOwnerPid,
35
41
  type MetaBackend,
36
42
  type MetaIdentity,
37
43
  type MetaSenderMarker,
@@ -148,9 +154,13 @@ export function resolveTrustedMetaSenderIdentity(opts: ResolveTrustedMetaSenderO
148
154
  const marker = readMetaSenderMarker({ markerPath: opts.markerPath });
149
155
  if (marker) markers.push(marker);
150
156
  } else {
151
- const ownerPids = (opts.ownerPids ?? [process.ppid, parentPid(process.ppid) ?? 0]).filter(
152
- (p): p is number => typeof p === "number" && p > 0,
153
- );
157
+ // The candidate filter asks the same question the marker readers ask — CAN this
158
+ // pid own a session so it uses the same predicate rather than a second `> 0`
159
+ // literal. The default set is only the bridge's parent and grandparent, so init
160
+ // enters it just when the native host itself was reparented (a detached/daemonized
161
+ // Claude): narrow, but reachable, and a candidate that cannot be an owner has no
162
+ // business reaching the read at all (#53 A).
163
+ const ownerPids = (opts.ownerPids ?? [process.ppid, parentPid(process.ppid) ?? 0]).filter(isPlausibleOwnerPid);
154
164
  for (const ownerPid of [...new Set(ownerPids)]) {
155
165
  for (const backend of META_SENDER_BACKENDS) {
156
166
  const marker = readMetaSenderMarker({ backend, ownerPid, sendersDir: opts.sendersDir });