@junghanacs/entwurf 0.23.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/BASELINE.md +1 -0
  2. package/CHANGELOG.md +628 -0
  3. package/DELIVERY.md +24 -1
  4. package/FAQ.md +47 -0
  5. package/README.md +5 -3
  6. package/VERIFY.md +20 -2
  7. package/demo/README.md +5 -2
  8. package/docs/acp-backend-rail.md +49 -2
  9. package/docs/setup-clean-host.md +8 -7
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +38 -5
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +19 -5
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/callback-env.js +68 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-send-receipt.js +101 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +17 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +4 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +53 -6
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +38 -3
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +54 -22
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +20 -6
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +24 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +19 -10
  23. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +35 -42
  24. package/mcp/entwurf-bridge/dist/protocol.js +0 -7
  25. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +40 -3
  26. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +72 -0
  27. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  28. package/mcp/entwurf-bridge/src/index.ts +46 -5
  29. package/mcp/entwurf-bridge/test.sh +2 -2
  30. package/package.json +11 -9
  31. package/pi-extensions/entwurf-control.ts +83 -154
  32. package/pi-extensions/lib/acp/backend.ts +16 -6
  33. package/pi-extensions/lib/acp/context.ts +30 -10
  34. package/pi-extensions/lib/acp/session-store.ts +2 -2
  35. package/pi-extensions/lib/callback-env.ts +78 -0
  36. package/pi-extensions/lib/compaction-send-guard.ts +6 -1
  37. package/pi-extensions/lib/control-send-receipt.ts +108 -0
  38. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  39. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -2
  40. package/pi-extensions/lib/entwurf-v2-production.ts +19 -1
  41. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -1
  42. package/pi-extensions/lib/entwurf-v2-send.ts +82 -6
  43. package/pi-extensions/lib/entwurf-v2-surface.ts +42 -3
  44. package/pi-extensions/lib/fresh-call-composition.ts +56 -22
  45. package/pi-extensions/lib/herdr-fresh-call.ts +25 -7
  46. package/pi-extensions/lib/herdr-placement.ts +24 -4
  47. package/pi-extensions/lib/mux-fresh-call.ts +23 -8
  48. package/pi-extensions/meta-bridge-omp.ts +35 -48
  49. package/protocol.js +0 -8
  50. package/run.sh +306 -53
  51. package/scripts/check-acp-backend-preflight.ts +117 -9
  52. package/scripts/check-acp-carrier-augment.ts +6 -4
  53. package/scripts/check-acp-event-mapper.ts +17 -6
  54. package/scripts/check-acp-prompt-builder.ts +17 -9
  55. package/scripts/check-acp-prompt-lifecycle.ts +23 -6
  56. package/scripts/check-acp-session-reuse.ts +64 -51
  57. package/scripts/check-acp-session-store.ts +31 -8
  58. package/scripts/check-acp-stop-reason.ts +20 -2
  59. package/scripts/check-acp-stream-hooks.ts +22 -5
  60. package/scripts/check-acp-usage-accounting.ts +33 -9
  61. package/scripts/check-entwurf-bridge-boot.ts +16 -1
  62. package/scripts/check-entwurf-v2-surface.ts +289 -10
  63. package/scripts/check-fresh-call-dispatch.ts +4 -5
  64. package/scripts/check-gate-qualification.ts +51 -9
  65. package/scripts/check-herdr-activation.ts +73 -0
  66. package/scripts/check-herdr-fresh-call.ts +30 -6
  67. package/scripts/check-herdr-placement.ts +51 -0
  68. package/scripts/check-herdr-plugin-build.ts +11 -4
  69. package/scripts/check-herdr-plugin.ts +745 -38
  70. package/scripts/check-herdr-sandbox.ts +14 -2
  71. package/scripts/check-install-container.sh +1 -1
  72. package/scripts/check-mux-launcher-fence.ts +37 -0
  73. package/scripts/check-pi-launch.ts +8 -2
  74. package/scripts/check-release-gate-outcomes.ts +165 -0
  75. package/scripts/doctor-pi-provider.ts +44 -4
  76. package/scripts/herdr-rails.mjs +75 -0
  77. package/scripts/lib/mutation-qualify.ts +1 -1
  78. package/scripts/meta-bridge-state.py +18 -1
  79. package/scripts/mutants/acp-backend-preflight.json +20 -0
  80. package/scripts/mutants/bridge-boot-resume.json +26 -0
  81. package/scripts/mutants/bridge-command-boot.json +38 -0
  82. package/scripts/mutants/control-send-receipt.json +235 -0
  83. package/scripts/mutants/herdr-activation.json +16 -0
  84. package/scripts/mutants/herdr-fresh-call.json +15 -4
  85. package/scripts/mutants/herdr-plugin-build.json +7 -11
  86. package/scripts/mutants/herdr-plugin.json +177 -9
  87. package/scripts/mutants/meta-retire.json +22 -0
  88. package/scripts/mutants/mux-fresh-call.json +21 -8
  89. package/scripts/mutants/omp-fresh.json +5 -9
  90. package/scripts/mutants/pack-install.json +4 -4
  91. package/scripts/mutants/pi-floor.json +17 -0
  92. package/scripts/mutants/release-gate.json +48 -0
  93. package/scripts/mutants/retired-sent-surface.json +32 -0
  94. package/scripts/mutants/v2-surface.json +7 -7
  95. package/scripts/probe-bridge-command.ts +1 -0
  96. package/scripts/smoke-acp-session-reuse-live.ts +23 -5
  97. package/scripts/smoke-agy-install-state.sh +2 -2
  98. package/scripts/smoke-codex-fresh-live.ts +94 -13
  99. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  100. package/scripts/smoke-herdr-fresh-call-live.ts +71 -36
  101. package/scripts/smoke-herdr-raw-install-live.sh +90 -5
  102. package/scripts/smoke-meta-install-state.sh +107 -1
  103. package/scripts/smoke-mux-lifecycle-live.ts +9 -2
  104. package/scripts/smoke-omp-fresh-live.ts +4 -2
  105. package/scripts/smoke-omp-mcp-state.sh +1 -1
  106. package/scripts/smoke-pi-provider-state.sh +108 -2
  107. package/scripts/smoke-setup-verdict.sh +18 -2
@@ -0,0 +1,108 @@
1
+ /**
2
+ * control-send-receipt — the ONE place the control-socket's acceptance boundary is named,
3
+ * on both ends of the wire (#120 P2).
4
+ *
5
+ * WHY A BOUNDARY AXIS AT ALL, AND WHY IT IS NOT THE ROUTE AXIS.
6
+ * `SendFinalOutcome` (`entwurf-v2-send.ts`) answers WHICH LEG delivered — first socket,
7
+ * re-resolved socket, mailbox — and it is what the release reducer keys on. It is an INTERNAL
8
+ * route/release algebra and it is NOT the sender-visible delivery boundary: `sent` and
9
+ * `fallback-sent` say a receiver accepted the bytes, and nothing whatever about what the receiver
10
+ * then did with them. Answering "what happened to my message" with a route word is how
11
+ * `control-socket → sent` came to mean four different things, one of which was
12
+ * "queued behind ten steers and dropped when the operator hit ESC".
13
+ *
14
+ * `[측정 2026-09-22, pi-agent-core 0.87.0; 재측정 2026-09-23, 0.87.1 — 좌표 동일]` the receiver's two queues are independent and each is
15
+ * `one-at-a-time` (`dist/agent.js:60-71,96-97,137-138`); steering is drained after every turn
16
+ * (`dist/agent-loop.js:85,186`) while follow-ups are drained ONLY when the inner loop has ended
17
+ * (`:191-197`). There is no global FIFO across them. So `steer` and `follow_up` are not two
18
+ * spellings of one queue — they are two different promises about when, and a receipt that cannot
19
+ * tell them apart cannot be used to reason about either.
20
+ *
21
+ * WHAT EACH BOUNDARY CLAIMS, EXACTLY:
22
+ * sent the receiver was idle and the message was accepted as a DIRECT
23
+ * turn trigger. It is an accepted REQUEST, not proof that the model
24
+ * saw it and not proof the turn completed.
25
+ * queued-steer accepted into the receiver's in-process steering queue. Volatile
26
+ * process memory: no durability, no ordering against the other
27
+ * queue, and an abort clears it.
28
+ * queued-follow-up accepted into the receiver's in-process follow-up queue. Same
29
+ * volatility, and it waits for the inner loop to end — a steer that
30
+ * arrives later still goes first.
31
+ * accepted-unknown-boundary the receiver ACCEPTED (its RPC answered success) but did not name
32
+ * a boundary this build understands. Version skew, not failure: the
33
+ * message is gone from our hands either way, so this must never be
34
+ * rendered as a failure to retry — and never as `sent`, which would
35
+ * put back the exact false claim this axis exists to remove.
36
+ *
37
+ * NO LEGACY MAPPER LIVES HERE. The previous wire carried `delivered:true` plus a `deliveredAs`
38
+ * producer nothing consumed; both are gone rather than translated. A new receiver answers an old
39
+ * sender fine (it keys on `response.success` and ignores `data`), and an old receiver answers a
40
+ * new sender as `accepted-unknown-boundary` — which is the honest name for it. A dual route would
41
+ * be a second address for one fact.
42
+ */
43
+
44
+ /** The closed boundary set, in the order a reader should learn them. */
45
+ export const ACCEPTANCE_BOUNDARIES = ["sent", "queued-steer", "queued-follow-up", "accepted-unknown-boundary"] as const;
46
+
47
+ export type AcceptanceBoundary = (typeof ACCEPTANCE_BOUNDARIES)[number];
48
+
49
+ /** What a receiver can actually observe about itself. `accepted-unknown-boundary` is a READER's
50
+ * verdict about an answer it could not classify, so a receiver may never produce it. */
51
+ export type ObservedBoundary = Exclude<AcceptanceBoundary, "accepted-unknown-boundary">;
52
+
53
+ /**
54
+ * RECEIVER SIDE. The whole classification, from the two facts the receiver already has at the
55
+ * moment it accepts: was it idle, and which queue did the caller ask for. Pure — it reads no
56
+ * clock, no socket and no session, so the same inputs always name the same boundary.
57
+ *
58
+ * `idle` wins over `mode`: an idle receiver takes the message as a direct trigger and neither
59
+ * queue is involved, which is why `mode` is meaningless there (`entwurf-control.ts` passes no
60
+ * `deliverAs` on that branch).
61
+ */
62
+ export function sendReceiptBoundary(input: { idle: boolean; mode: "steer" | "follow_up" }): ObservedBoundary {
63
+ if (input.idle) return "sent";
64
+ return input.mode === "follow_up" ? "queued-follow-up" : "queued-steer";
65
+ }
66
+
67
+ /** Is this an acceptance a receiver could have observed about itself? */
68
+ export function isObservedBoundary(value: unknown): value is ObservedBoundary {
69
+ return value === "sent" || value === "queued-steer" || value === "queued-follow-up";
70
+ }
71
+
72
+ /**
73
+ * SENDER SIDE. Read the receiver's typed boundary off an RPC `data` payload.
74
+ *
75
+ * Anything this build does not recognise — a missing field, an older receiver's shape, a value
76
+ * outside the set — is `accepted-unknown-boundary` and NOT an error: by the time `data` exists the
77
+ * RPC has already answered success, so the message has been accepted. Inventing a failure here
78
+ * would invite a retry that double-delivers, and answering `sent` would claim a direct injection
79
+ * nobody reported.
80
+ */
81
+ export function parseAcceptanceBoundary(data: unknown): AcceptanceBoundary {
82
+ if (typeof data !== "object" || data === null) return "accepted-unknown-boundary";
83
+ const value = (data as { boundary?: unknown }).boundary;
84
+ return isObservedBoundary(value) ? value : "accepted-unknown-boundary";
85
+ }
86
+
87
+ /** How much of an unrecognised answer may be repeated back. A receiver's payload is untrusted
88
+ * input from another process; a full object on a status line is an injection surface and an
89
+ * unbounded one at that. */
90
+ const UNKNOWN_DIAGNOSTIC_MAX = 80;
91
+
92
+ /**
93
+ * A BOUNDED, single-line description of an answer we could not classify — for the operator who has
94
+ * to decide whether this is version skew or a broken peer. Returns `undefined` when there is
95
+ * nothing worth saying, so a caller renders the bare boundary rather than an empty parenthesis.
96
+ *
97
+ * It describes the SHAPE and never trusts the content: the raw value is stringified, stripped of
98
+ * newlines and truncated. Never the whole object.
99
+ */
100
+ export function describeUnknownBoundary(data: unknown): string | undefined {
101
+ if (typeof data !== "object" || data === null) return data === undefined ? undefined : `data: ${typeof data}`;
102
+ const value = (data as { boundary?: unknown }).boundary;
103
+ if (value === undefined) return "no boundary field";
104
+ const raw = typeof value === "string" ? value : typeof value;
105
+ const flat = raw.replace(/\s+/g, " ").trim();
106
+ if (flat === "") return "empty boundary";
107
+ return flat.length > UNKNOWN_DIAGNOSTIC_MAX ? `${flat.slice(0, UNKNOWN_DIAGNOSTIC_MAX)}…` : flat;
108
+ }
@@ -51,7 +51,7 @@ export const EntwurfV2InputSchema = Type.Object(
51
51
  mode: Type.Optional(
52
52
  StringEnum(ENTWURF_V2_MODES, {
53
53
  description:
54
- "delivery mode (steer = interrupt current turn, follow_up = queue) — NOT the ownership axis (F1) nor liveness routing. MEANINGLESS on the meta-mailbox transport (F-mailbox): a mailbox ack is enqueue+doorbell, not a turn injection, so steer/follow_up does not apply when the verdict transport is meta-mailbox.",
54
+ "delivery mode — NOT the ownership axis (F1) nor liveness routing. steer (ask the receiver's steering queue) or follow_up (ask its follow-up queue). NEITHER is an interrupt: a busy receiver drains steering after each turn and follow-ups only when its inner loop ends, so a later steer overtakes every earlier follow_up and there is no order between the two. Both queues are volatile process memory — an abort drops what is in them. The receipt names which queue accepted the message (`queued-steer` / `queued-follow-up`); an idle receiver answers `sent`, meaning the turn was TRIGGERED, not that the model has seen the text. MEANINGLESS on the meta-mailbox transport (F-mailbox): a mailbox ack is enqueue+doorbell, not a turn injection, so steer/follow_up does not apply when the verdict transport is meta-mailbox.",
55
55
  }),
56
56
  ),
57
57
  wantsReply: Type.Optional(
@@ -265,8 +265,12 @@ export type EntwurfV2Transport = (typeof ENTWURF_V2_TRANSPORTS)[number];
265
265
  // and a future visible-resume verb would speak them again.
266
266
  export const ENTWURF_V2_ACTIONS = ["send"] as const;
267
267
  export const ENTWURF_V2_OWNERSHIPS = ["ack-only"] as const;
268
- // Delivery mode of the message to the target (how it is injected) — steer =
269
- // interrupt the current turn, follow_up = queue after it. A SEPARATE axis from
268
+ // Delivery mode of the message to the target: WHICH QUEUE a busy receiver is asked for.
269
+ // `[측정 2026-09-22, pi-agent-core 0.87.0; 재측정 2026-09-23, 0.87.1 — 좌표 동일]` neither is an interrupt — `dist/agent-loop.js:186`
270
+ // drains steering after each turn and `:191-197` drains follow-ups only once the inner loop has
271
+ // ended, and the two queues (`dist/agent.js:96-97,137-138`) have no FIFO between them, so a later
272
+ // steer overtakes every earlier follow_up. Both are volatile process memory and an abort drops
273
+ // them. A SEPARATE axis from
270
274
  // both the intent/ownership axis (F1) and the liveness-routing axis. The removed
271
275
  // v1 `entwurf_send` carried the same steer|follow_up surface, so this axis is
272
276
  // inherited vocabulary, not a second live delivery verb.
@@ -29,6 +29,7 @@
29
29
  * claiming "no conflict". The decider's later `inspectSocket` probe is a SEPARATE step.
30
30
  */
31
31
 
32
+ import { describeUnknownBoundary, parseAcceptanceBoundary } from "./control-send-receipt.ts";
32
33
  import {
33
34
  type RpcClientOptions,
34
35
  type RpcCommand,
@@ -318,7 +319,24 @@ export function makeProductionEntwurfV2Deps(opts: ProductionEntwurfV2Opts): Entw
318
319
  wants_reply: plan.wantsReply,
319
320
  sender: opts.senderProvider(),
320
321
  });
321
- return { success: response.success, error: response.error };
322
+ // #120 P2 THE WIRE SEAM. `response.data` is another process's payload, so it is
323
+ // classified here by one total function and never carried raw any further: a shape we
324
+ // do not recognise becomes `accepted-unknown-boundary`, an ACCEPTANCE we could not
325
+ // name rather than a failure — the RPC already answered success, so the message is out
326
+ // of our hands and inventing a failure here would invite a double delivery. Only a
327
+ // SUCCESSFUL answer gets a boundary; a refusal carries its error instead.
328
+ const boundary = response.success ? parseAcceptanceBoundary(response.data) : undefined;
329
+ if (boundary === "accepted-unknown-boundary") {
330
+ // The operator has to be able to tell version skew from a broken peer, and the
331
+ // receipt itself may not carry another process's payload onto a status line.
332
+ // Hard Rule 15: diagnostics go to stderr, bounded and single-line.
333
+ const shape = describeUnknownBoundary(response.data);
334
+ process.stderr.write(
335
+ `[entwurf_v2] ${plan.targetGardenId} accepted the message but named no boundary this build knows` +
336
+ `${shape === undefined ? "" : ` (${shape})`}\n`,
337
+ );
338
+ }
339
+ return { success: response.success, error: response.error, boundary };
322
340
  },
323
341
  classifyConnect: io.classifyConnect,
324
342
  releaseLock: release,
@@ -28,6 +28,7 @@
28
28
  * lets the hand's `decideReleasePolicy` fail loud on a mis-pairing.
29
29
  */
30
30
 
31
+ import type { AcceptanceBoundary } from "./control-send-receipt.ts";
31
32
  import type {
32
33
  DispatchDecision,
33
34
  DispatchInput,
@@ -71,7 +72,15 @@ export interface DispatchExecutorDeps {
71
72
  * and it is the ONLY enqueue-side datum carried (never a read timestamp; see
72
73
  * `RpcSendResult`). */
73
74
  export type ExecutedOutcome =
74
- | { transport: "control-socket"; outcome: SendFinalOutcome; rejectReason?: string; messagePath?: string }
75
+ | {
76
+ transport: "control-socket";
77
+ outcome: SendFinalOutcome;
78
+ rejectReason?: string;
79
+ messagePath?: string;
80
+ /** #120 P2: the receiver's acceptance boundary. The SURFACE renders this, not
81
+ * `outcome` — that one names the route/release leg, which an operator cannot act on. */
82
+ boundary?: AcceptanceBoundary;
83
+ }
75
84
  | { transport: "meta-mailbox"; success: true; messagePath?: string }
76
85
  // native-push carries `retried` so the surface can note the 1-shot re-probe retry fired.
77
86
  | { transport: "native-push"; success: true; retried: boolean };
@@ -89,6 +98,15 @@ export type EntwurfV2RunResult =
89
98
  receipt: SuccessReceipt;
90
99
  transport: ExecutionPlan["transport"];
91
100
  error: string;
101
+ /** #120 P2, N1: the ACCEPTANCE the send reached before the release failed. Without it
102
+ * the dirty-lock line is the one surface left that answers "what happened to my
103
+ * message" with an internal route word. Absent on a `rejected` finalization (nothing
104
+ * was accepted) and on the mailbox fallback leg (which carries a file instead). */
105
+ finalizedBoundary?: AcceptanceBoundary;
106
+ /** #98 R, same case: the `.msg` a mailbox fallback wrote before the release failed. */
107
+ finalizedMessagePath?: string;
108
+ /** The receiver's own refusal text, when the finalization was a `rejected` one. */
109
+ finalizedRejectReason?: string;
92
110
  /** Present ONLY for the N1 case: the delivery/refusal reached a terminal outcome
93
111
  * but `releaseLock` then threw (lock dirty). A re-send would double-deliver. */
94
112
  finalizedOutcome?: Exclude<SendFinalOutcome, "failed">;
@@ -129,6 +147,7 @@ export async function executeDispatch(
129
147
  outcome: r.outcome,
130
148
  rejectReason: r.rejectReason,
131
149
  messagePath: r.messagePath,
150
+ boundary: r.boundary,
132
151
  },
133
152
  };
134
153
  } catch (err) {
@@ -140,6 +159,9 @@ export async function executeDispatch(
140
159
  transport,
141
160
  error: errorMessage(err),
142
161
  finalizedOutcome: err.finalizedOutcome,
162
+ finalizedBoundary: err.finalizedBoundary,
163
+ finalizedMessagePath: err.finalizedMessagePath,
164
+ finalizedRejectReason: err.finalizedRejectReason,
143
165
  releaseFailed: true,
144
166
  retrySafe: false,
145
167
  };
@@ -32,6 +32,7 @@
32
32
  * `releaseLock` throw never MASKS a send failure — the original error wins (5b).
33
33
  */
34
34
 
35
+ import type { AcceptanceBoundary } from "./control-send-receipt.ts";
35
36
  import type { ExecutionPlan } from "./entwurf-v2-decider.ts";
36
37
  import type { LockClaim } from "./entwurf-v2-lock.ts";
37
38
  import {
@@ -50,6 +51,12 @@ export type MetaMailboxPlan = Extract<ExecutionPlan, { transport: "meta-mailbox"
50
51
  * `send-final` outcomes. `fallback-sent` is a REAL final outcome (legacy parity: a
51
52
  * re-resolved delivery is not a hidden retry). */
52
53
  export type SendFinalOutcome = "sent" | "fallback-sent" | "rejected" | "failed";
54
+ // NOTE (#120 P2): the type above is the ROUTE/RELEASE algebra and is NOT the sender-visible
55
+ // delivery boundary. It answers WHICH LEG carried the bytes — first socket, re-resolved socket,
56
+ // mailbox — which is exactly what the release reducer needs and exactly what an operator cannot
57
+ // act on: `sent` here says a receiver accepted, never what the receiver then did with it. The
58
+ // boundary is a SEPARATE axis (`AcceptanceBoundary`, control-send-receipt.ts) riding beside this
59
+ // one, so neither can overwrite the other.
53
60
 
54
61
  /** What a single RPC / mailbox enqueue reports. `success:false` is an in-band reject
55
62
  * (the receiver answered and refused) — distinct from a thrown connect error.
@@ -66,6 +73,12 @@ export interface RpcSendResult {
66
73
  success: boolean;
67
74
  error?: string;
68
75
  messagePath?: string;
76
+ /** #120 P2: the receiver's OWN acceptance boundary, already classified by
77
+ * `control-send-receipt.ts` at the wire seam. Present on every SUCCESSFUL control-socket
78
+ * answer — an answer we could not classify is `accepted-unknown-boundary`, not an absence —
79
+ * and absent on the mailbox rail, which has no turn-injection boundary to report and answers
80
+ * with a durable file receipt instead. */
81
+ boundary?: AcceptanceBoundary;
69
82
  }
70
83
 
71
84
  /** The same-lock one-shot re-resolve result (5c-2b implements the resolver; 5c-2a
@@ -116,6 +129,12 @@ export interface ControlSocketSendResult {
116
129
  * mailbox delivery whose sender gets no per-message identifier — the same letter the
117
130
  * primary mailbox rail names. */
118
131
  messagePath?: string;
132
+ /** #120 P2: the acceptance boundary the receiver reported, on EVERY successful
133
+ * control-socket leg including the re-resolved one — a `fallback-sent` that dropped it would
134
+ * say "delivered" about a message sitting in a volatile queue behind whatever else is in it.
135
+ * Absent on the mailbox fallback leg (no turn-injection boundary exists there) and on
136
+ * rejected/failed. */
137
+ boundary?: AcceptanceBoundary;
119
138
  }
120
139
 
121
140
  // A drive step's verdict: the terminal outcome, plus the original error to RETHROW on
@@ -127,6 +146,24 @@ interface SendDrive {
127
146
  rejectReason?: string;
128
147
  /** #98 R receipt from the mailbox fallback leg (see `ControlSocketSendResult`). */
129
148
  messagePath?: string;
149
+ /** #120 P2 acceptance boundary from a socket leg (see `ControlSocketSendResult`). */
150
+ boundary?: AcceptanceBoundary;
151
+ }
152
+
153
+ /**
154
+ * A successful SOCKET answer always carries a boundary: the wire seam classifies every answer with
155
+ * one total function, and an answer it cannot place is `accepted-unknown-boundary` rather than
156
+ * nothing. So `undefined` here is not a host state, it is a bypassed seam — a dep wired straight
157
+ * past the classifier. Fail loud. A silent default would make the one shape that must never be
158
+ * mistaken for a direct trigger — delivered, boundary unknown to us — look exactly like one.
159
+ */
160
+ function requireBoundary(result: RpcSendResult): AcceptanceBoundary {
161
+ if (result.boundary === undefined) {
162
+ throw new Error(
163
+ "entwurf-v2-send: a successful control-socket RPC reported no acceptance boundary — the wire seam that classifies one was bypassed (contract violation).",
164
+ );
165
+ }
166
+ return result.boundary;
130
167
  }
131
168
 
132
169
  /**
@@ -159,7 +196,12 @@ export async function executeControlSocketSend(
159
196
  drive = { outcome: "failed", error: err };
160
197
  }
161
198
  finalizeRelease(policy, deps, held, drive);
162
- return { outcome: drive.outcome, rejectReason: drive.rejectReason, messagePath: drive.messagePath };
199
+ return {
200
+ outcome: drive.outcome,
201
+ rejectReason: drive.rejectReason,
202
+ messagePath: drive.messagePath,
203
+ boundary: drive.boundary,
204
+ };
163
205
  }
164
206
 
165
207
  /** Drive the 1차 send and route a connect failure through the F3 split. */
@@ -179,7 +221,7 @@ async function driveSend(plan: ControlSocketPlan, lock: LockClaim, deps: Control
179
221
  }
180
222
  // A completed RPC: ack ⇒ sent; in-band refusal ⇒ rejected, NO fallback. Preserve
181
223
  // its receiver error verbatim when supplied; do not make a reason up when absent.
182
- return result.success ? { outcome: "sent" } : inBandRejected(result);
224
+ return result.success ? { outcome: "sent", boundary: requireBoundary(result) } : inBandRejected(result);
183
225
  }
184
226
 
185
227
  /**
@@ -224,7 +266,7 @@ async function driveDeadFallback(
224
266
  // connect failure finalizes as failed (no further fallback).
225
267
  try {
226
268
  const r = await deps.sendOverSocket(rePlan);
227
- return r.success ? { outcome: "fallback-sent" } : inBandRejected(r);
269
+ return r.success ? { outcome: "fallback-sent", boundary: requireBoundary(r) } : inBandRejected(r);
228
270
  } catch (err) {
229
271
  return { outcome: "failed", error: err };
230
272
  }
@@ -261,14 +303,42 @@ async function driveDeadFallback(
261
303
  */
262
304
  export class SendDeliveredReleaseFailedError extends Error {
263
305
  readonly finalizedOutcome: Exclude<SendFinalOutcome, "failed">;
306
+ /** #120 P2: the ACCEPTANCE the send actually reached, carried through the failure so the
307
+ * dirty-lock line can say what happened instead of a bare route word. Absent on a `rejected`
308
+ * finalization (nothing was accepted) and on the mailbox fallback leg (which carries a file
309
+ * instead) — those two are exactly why this is optional rather than defaulted. */
310
+ readonly finalizedBoundary?: AcceptanceBoundary;
311
+ /** #98 R receipt, same reason: a mailbox fallback that then failed to release still wrote a
312
+ * `.msg`, and the operator has to be told WHICH one before touching the lock. */
313
+ readonly finalizedMessagePath?: string;
314
+ /** The receiver's own refusal text on a `rejected` finalization. Without it the dirty-lock line
315
+ * is the ONE place a refusal loses its reason — and it is the place an operator reads while
316
+ * deciding by hand whether the message still needs sending. */
317
+ readonly finalizedRejectReason?: string;
264
318
  readonly releaseError: unknown;
265
- constructor(finalizedOutcome: Exclude<SendFinalOutcome, "failed">, releaseError: unknown) {
319
+ constructor(
320
+ finalizedOutcome: Exclude<SendFinalOutcome, "failed">,
321
+ releaseError: unknown,
322
+ accepted: { boundary?: AcceptanceBoundary; messagePath?: string; rejectReason?: string } = {},
323
+ ) {
266
324
  const detail = releaseError instanceof Error ? releaseError.message : String(releaseError);
325
+ // NOT "delivered". The word was wrong on two of the three shapes that reach here — a
326
+ // `rejected` finalization delivered nothing at all, and a `queued-*` one is sitting in
327
+ // volatile memory — and DELIVERY.md refuses a bare `delivered` for exactly that reason.
328
+ // What is true of all three is that the send REACHED A TERMINAL RESULT and must not be
329
+ // repeated.
330
+ const what =
331
+ finalizedOutcome === "rejected"
332
+ ? `rejected${accepted.rejectReason ? ` (${accepted.rejectReason})` : ""}`
333
+ : (accepted.boundary ?? "mailbox-enqueued");
267
334
  super(
268
- `entwurf-v2-send: ${finalizedOutcome} delivered but releaseLock failed (lock dirty, do NOT re-send): ${detail}`,
335
+ `entwurf-v2-send: the send finalized as \`${what}\` and releaseLock then failed (lock dirty, do NOT re-send): ${detail}`,
269
336
  );
270
337
  this.name = "SendDeliveredReleaseFailedError";
271
338
  this.finalizedOutcome = finalizedOutcome;
339
+ this.finalizedBoundary = accepted.boundary;
340
+ this.finalizedMessagePath = accepted.messagePath;
341
+ this.finalizedRejectReason = accepted.rejectReason;
272
342
  this.releaseError = releaseError;
273
343
  }
274
344
  }
@@ -294,7 +364,13 @@ function finalizeRelease(policy: ReleasePolicy, deps: ControlSocketSendDeps, loc
294
364
  if (drive.outcome === "failed") throw original;
295
365
  // N1: the delivery/refusal already happened — surface it as a structured,
296
366
  // retry-unsafe error rather than a bare release throw.
297
- throw new SendDeliveredReleaseFailedError(drive.outcome, releaseErr);
367
+ // The acceptance truth rides along: without it the dirty-lock line is the ONE place
368
+ // left that still answers "what happened to my message" with a route word.
369
+ throw new SendDeliveredReleaseFailedError(drive.outcome, releaseErr, {
370
+ boundary: drive.boundary,
371
+ messagePath: drive.messagePath,
372
+ rejectReason: drive.rejectReason,
373
+ });
298
374
  }
299
375
  }
300
376
  if (drive.outcome === "failed") throw original;
@@ -159,9 +159,35 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
159
159
  // socket-to-socket retry (no file) and on every non-mailbox outcome, so the
160
160
  // line degrades to the bare outcome rather than printing "undefined".
161
161
  const enqueued = o.messagePath ? ` (enqueued ${basename(o.messagePath)})` : "";
162
+ if (delivered) {
163
+ // #120 P2 — THE BOUNDARY IS THE RECEIPT, and the route is an annotation.
164
+ // `sent` / `fallback-sent` name which LEG carried the bytes. An operator
165
+ // cannot act on that: for 78 minutes this line said `sent` about a message
166
+ // lying in a queue. So the visible word is what the RECEIVER observed —
167
+ // `sent` only for an idle direct trigger, `queued-steer` /
168
+ // `queued-follow-up` for the two volatile in-process queues, and
169
+ // `accepted-unknown-boundary` when a peer answered success in a shape this
170
+ // build cannot classify. That last one is never spelled with the word
171
+ // "sent": it was accepted, and saying "sent (unknown)" would put back the
172
+ // exact false claim this axis exists to remove.
173
+ //
174
+ // A re-resolved MAILBOX leg has no turn-injection boundary at all — it
175
+ // wrote a durable file — so it says `mailbox-enqueued` rather than a bare
176
+ // route word. The route survives as a separate annotation either way,
177
+ // because "your first socket was dead" is a real thing to have been told.
178
+ const route = o.outcome === "fallback-sent" ? " (via a re-resolved route)" : "";
179
+ // A re-resolved leg with no boundary IS the mailbox leg — that rail reports a
180
+ // durable file instead. A PRIMARY leg with no boundary is structurally
181
+ // impossible (the send hand throws on it), so if one ever arrives here it is
182
+ // named as the acceptance we could not classify rather than dressed as a
183
+ // mailbox file that was never written.
184
+ const boundary =
185
+ o.boundary ?? (o.outcome === "fallback-sent" ? "mailbox-enqueued" : "accepted-unknown-boundary");
186
+ return { text: `entwurf_v2 control-socket → ${boundary}${route}${enqueued}`, isError: false };
187
+ }
162
188
  return {
163
189
  text: `entwurf_v2 control-socket → ${o.outcome}${reason}${enqueued}`,
164
- isError: !delivered,
190
+ isError: true,
165
191
  };
166
192
  }
167
193
  if (o.transport === "native-push") {
@@ -185,10 +211,23 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
185
211
  }
186
212
  case "execution-failed": {
187
213
  if (result.releaseFailed && result.finalizedOutcome) {
188
- // N1: the delivery/refusal reached a terminal outcome but releaseLock then threw.
214
+ // N1: the send reached a terminal result and `releaseLock` then threw.
215
+ //
216
+ // #120 P2 — THIS LINE USED TO SAY `DELIVERED (<route>)` ABOUT ALL THREE SHAPES THAT
217
+ // REACH IT, and it was wrong about two of them: a `rejected` finalization delivered
218
+ // nothing at all, and a `queued-*` one is sitting in volatile receiver memory. So
219
+ // the same acceptance vocabulary the ordinary receipt uses is named here too — a
220
+ // dirty lock is a reason to be MORE precise about what happened, not less, because
221
+ // the operator is about to decide by hand whether anything needs re-sending.
222
+ const what =
223
+ result.finalizedOutcome === "rejected"
224
+ ? `rejected${result.finalizedRejectReason ? ` (${result.finalizedRejectReason})` : ""} — the receiver refused it; nothing was accepted`
225
+ : `${result.finalizedBoundary ?? "mailbox-enqueued"}${
226
+ result.finalizedMessagePath ? ` (enqueued ${basename(result.finalizedMessagePath)})` : ""
227
+ }`;
189
228
  return {
190
229
  text:
191
- `entwurf_v2 ${result.transport} DELIVERED (${result.finalizedOutcome}) but the lock is DIRTY ` +
230
+ `entwurf_v2 ${result.transport} finalized as ${what}, and the lock is DIRTY ` +
192
231
  `(release failed) — do NOT retry, a re-send would double-deliver. Clear the lock by hand.` +
193
232
  `\n error: ${result.error}`,
194
233
  isError: true,
@@ -54,6 +54,17 @@ export type FreshCallBackend = (typeof FRESH_CALL_BACKENDS)[number];
54
54
  * permission dialect: omp's approval layer consults the same minted string (`source-audit.md`).
55
55
  */
56
56
  export const FRESH_CALL_CALLBACK_TOOL: Record<FreshCallBackend, string> = {
57
+ pi: "entwurf_callback",
58
+ "claude-code": "mcp__entwurf-bridge__entwurf_callback",
59
+ copilot: "entwurf-bridge-entwurf_callback",
60
+ omp: "mcp__entwurf_bridge_entwurf_callback",
61
+ // Codex's tool process is the operator app-server, not the pane: the no-arg
62
+ // verb refuses there by name. First action stays the delivery verb with args.
63
+ codex: "mcp__entwurf_bridge__entwurf_v2",
64
+ };
65
+
66
+ /** Where a sibling SENDS the task result. Always the delivery verb, never the birth callback. */
67
+ export const FRESH_CALL_DELIVERY_TOOL: Record<FreshCallBackend, string> = {
57
68
  pi: "entwurf_v2",
58
69
  "claude-code": "mcp__entwurf-bridge__entwurf_v2",
59
70
  copilot: "entwurf-bridge-entwurf_v2",
@@ -101,8 +112,11 @@ export const FRESH_CALL_PEERS_TOOL: Record<FreshCallBackend, string> = {
101
112
  * `[측정, n=3, same model]` adding the sentence below produced 3/3 callbacks. It is a FACT about the
102
113
  * runtime, in the register the rest of this framing uses since the prohibitions came out: it grants
103
114
  * nothing, forbids nothing, and names the one call that turns a listed name into a callable tool.
104
- * Both tools ride ONE `ToolSearch` select, so the corroboration the framing offers stays reachable
105
- * for a child that takes the offer.
115
+ * ALL THREE tools ride ONE `ToolSearch` select — the birth callback, the DELIVERY verb the last
116
+ * line of the framing names, and the peers listing the corroboration sentence offers. The delivery
117
+ * verb has to be in the same select or the deferral simply moves from the first action to the last:
118
+ * a sibling that finishes its task and cannot call `entwurf_v2` is the same production silence,
119
+ * spent instead of saved.
106
120
  *
107
121
  * Every other backend gets an EMPTY array, deliberately: pi has no deferred-tool surface, and the
108
122
  * other three were never measured to need it. A hint invented for a runtime nobody probed would be
@@ -112,7 +126,7 @@ export const FRESH_CALL_TOOL_LOAD_HINT: Record<FreshCallBackend, readonly string
112
126
  pi: [],
113
127
  "claude-code": [
114
128
  "If ${callbackTool} is not callable yet, the entwurf-bridge server is still connecting or its",
115
- 'tools are deferred: load them first with ToolSearch("select:${callbackTool},${peersTool}") —',
129
+ 'tools are deferred: load them first with ToolSearch("select:${callbackTool},${deliveryTool},${peersTool}") —',
116
130
  "it waits for the server — then call ${callbackTool}.",
117
131
  ],
118
132
  copilot: [],
@@ -144,22 +158,22 @@ export const OMP_BOOTSTRAP_FLAG = "entwurf-bootstrap";
144
158
 
145
159
  /** Payload grammar version, matched exactly by the decoder. A bump means a stale installed
146
160
  * unit, which is the one thing `doctor-omp-bridge` exists to say out loud. */
147
- export const OMP_BOOTSTRAP_VERSION = 1;
161
+ export const OMP_BOOTSTRAP_VERSION = 2;
148
162
 
149
163
  /**
150
164
  * The whole of what a fresh omp sibling is launched with.
151
165
  *
152
- * THREE FIELDS, CLOSED. The decoder refuses an unknown key, so this object is the entire
153
- * contract: who to call back, the nonce that proves it is this call, and the task that is
154
- * released only after that callback succeeds. There is no command here, no path, no env name
155
- * and no model — the model is already an explicit argv token, and a second copy of it inside
156
- * a payload would be a second place for it to disagree with the launch.
166
+ * TWO FIELDS, CLOSED. Address (target + nonce) rides process env, not this payload — a second
167
+ * copy here would be a second address axis. The decoder refuses an unknown key, so this object
168
+ * is the entire remaining contract: the task released after the no-arg callback succeeds.
169
+ * `callerGardenId` / `nonce` stay on the function so launcher call sites do not grow a second
170
+ * shape; they are not serialised.
157
171
  */
158
172
  export function buildOmpBootstrapPayload(params: { callerGardenId: string; nonce: string; task: string }): string {
173
+ void params.callerGardenId;
174
+ void params.nonce;
159
175
  return JSON.stringify({
160
176
  v: OMP_BOOTSTRAP_VERSION,
161
- target: params.callerGardenId,
162
- nonce: params.nonce,
163
177
  task: params.task,
164
178
  });
165
179
  }
@@ -298,7 +312,11 @@ export function composeBackendArgs(
298
312
  case "pi":
299
313
  return [composition.prompt, "--entwurf-control", "--model", model];
300
314
  case "claude-code":
301
- return [composition.prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
315
+ return [
316
+ composition.prompt,
317
+ `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]},${FRESH_CALL_DELIVERY_TOOL["claude-code"]}`,
318
+ `--model=${model}`,
319
+ ];
302
320
  case "copilot":
303
321
  return ["copilot", "--interactive", composition.prompt, "--model", model, "--yolo"];
304
322
  case "omp":
@@ -354,28 +372,44 @@ export function composeFreshCallFraming(params: {
354
372
  * default: a sibling told nothing about where it is would be told something false by silence. */
355
373
  openingLine: string;
356
374
  }): string[] {
357
- const tool = FRESH_CALL_CALLBACK_TOOL[params.backend];
375
+ const birthTool = FRESH_CALL_CALLBACK_TOOL[params.backend];
376
+ const deliveryTool = FRESH_CALL_DELIVERY_TOOL[params.backend];
358
377
  const peersTool = FRESH_CALL_PEERS_TOOL[params.backend];
359
378
  // The hint is a per-backend TEMPLATE so the two tool dialects stay the single source of their
360
379
  // own spelling here too — a hint that hard-coded Claude's name would go stale the day a dialect
361
380
  // moves, and it would go stale silently, in the one sentence a stuck child depends on.
362
381
  const loadHint = FRESH_CALL_TOOL_LOAD_HINT[params.backend].map((line) =>
363
- line.replaceAll("${callbackTool}", tool).replaceAll("${peersTool}", peersTool),
382
+ line
383
+ .replaceAll("${callbackTool}", birthTool)
384
+ .replaceAll("${deliveryTool}", deliveryTool)
385
+ .replaceAll("${peersTool}", peersTool),
364
386
  );
365
387
  const hintBlock = loadHint.length === 0 ? [] : ["", ...loadHint];
366
388
  if (params.openingLine.length === 0) {
367
389
  throw new Error("fresh-call composition: openingLine is empty — the rail must state where it placed the sibling");
368
390
  }
391
+ const firstAction =
392
+ params.backend === "codex"
393
+ ? [
394
+ `FIRST ACTION, before reading files or anything else: call ${birthTool} with ` +
395
+ `target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
396
+ `message set to exactly ${params.nonce} — that string alone, nothing added.`,
397
+ `${params.nonce} is a correlation tag this caller minted for this one launch: it names no`,
398
+ "secret and grants no access. Sending it is how the caller learns the garden id you were born",
399
+ "with, which it has no other way to know. Your own record does not carry the caller's address,",
400
+ "so reporting your identity here does not reach it — that tool call does.",
401
+ "entwurf_callback is refused on this rail: the tool process is the operator app-server, not this pane.",
402
+ ]
403
+ : [
404
+ `FIRST ACTION, before reading files or anything else: call ${birthTool} with no arguments.`,
405
+ "That call is how the caller learns the garden id you were born with. There are no",
406
+ "parameters: a target or message you type would be a second address axis.",
407
+ "Your own record does not carry the caller's address, so reporting your identity here does not reach it — that tool call does.",
408
+ ];
369
409
  return [
370
410
  params.openingLine,
371
411
  "",
372
- `FIRST ACTION, before reading files or anything else: call ${tool} with ` +
373
- `target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
374
- `message set to exactly ${params.nonce} — that string alone, nothing added.`,
375
- `${params.nonce} is a correlation tag this caller minted for this one launch: it names no`,
376
- "secret and grants no access. Sending it is how the caller learns the garden id you were born",
377
- "with, which it has no other way to know. Your own record does not carry the caller's address,",
378
- "so reporting your identity here does not reach it — that tool call does.",
412
+ ...firstAction,
379
413
  "",
380
414
  `You can corroborate the caller first if you want to: ${peersTool} is a read-only listing of`,
381
415
  `this garden's citizens and ${params.callerGardenId} is one of them. That listing is capped, so`,
@@ -393,7 +427,7 @@ export function composeFreshCallFraming(params: {
393
427
  // its window is not the delivery. Nothing here watches for completion or sends on the
394
428
  // sibling's behalf — this rail launches and lets go (Hard Rule 16, and the reason there is no
395
429
  // supervisor anywhere in it).
396
- `When the task reaches its requested final result, send that result to the same target with ${tool}.`,
430
+ `When the task reaches its requested final result, send that result to the same target with ${deliveryTool}.`,
397
431
  "Output in this sibling window is not delivered to the caller.",
398
432
  ];
399
433
  }