@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
@@ -14,6 +14,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
14
14
  import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import type { Context } from "@earendil-works/pi-ai";
17
+ import { normalizeContext } from "@earendil-works/pi-ai";
17
18
 
18
19
  const sha256 = (v: string) => createHash("sha256").update(v).digest("hex");
19
20
  const isSha256Hex = (v: string) => /^[0-9a-f]{64}$/.test(v);
@@ -114,13 +115,16 @@ const baseInput = (): BridgeConfigInput => ({
114
115
  stopReason: "stop" as const,
115
116
  timestamp: 0,
116
117
  }) satisfies Context["messages"][number];
117
- const ctx: Context = {
118
+ // Built through `normalizeContext` — the 0.86 provider-path shape. With no
119
+ // systemPrompt and no tools it adds no leading system message, so the exact
120
+ // three-entry expectation below is the same list production hashes.
121
+ const ctx = normalizeContext({
118
122
  messages: [
119
123
  { role: "user", content: "hello", timestamp: 0 },
120
124
  assistant("hi"),
121
125
  { role: "user", content: [{ type: "image", data: "RAWIMAGEBYTES", mimeType: "image/png" }], timestamp: 0 },
122
126
  ],
123
- };
127
+ });
124
128
  const sigs = contextMessageSignatures(ctx);
125
129
  // every entry is a sha256 digest of the pre-hash role:content form.
126
130
  assert.ok(sigs.every(isSha256Hex), "every message signature is a sha256 digest");
@@ -139,21 +143,21 @@ const baseInput = (): BridgeConfigInput => ({
139
143
 
140
144
  // toolResult folds in toolName + isError → same text, different tool/flag = different sig.
141
145
  const trContent = [{ type: "text" as const, text: "out" }];
142
- const trBase: Context = {
146
+ const trBase = normalizeContext({
143
147
  messages: [
144
148
  { role: "toolResult", toolCallId: "t1", toolName: "bash", isError: false, content: trContent, timestamp: 0 },
145
149
  ],
146
- };
147
- const trErr: Context = {
150
+ });
151
+ const trErr = normalizeContext({
148
152
  messages: [
149
153
  { role: "toolResult", toolCallId: "t1", toolName: "bash", isError: true, content: trContent, timestamp: 0 },
150
154
  ],
151
- };
152
- const trOther: Context = {
155
+ });
156
+ const trOther = normalizeContext({
153
157
  messages: [
154
158
  { role: "toolResult", toolCallId: "t1", toolName: "edit", isError: false, content: trContent, timestamp: 0 },
155
159
  ],
156
- };
160
+ });
157
161
  assert.notEqual(
158
162
  contextMessageSignatures(trBase)[0],
159
163
  contextMessageSignatures(trErr)[0],
@@ -164,6 +168,25 @@ const baseInput = (): BridgeConfigInput => ({
164
168
  contextMessageSignatures(trOther)[0],
165
169
  "toolResult toolName changes the signature",
166
170
  );
171
+
172
+ // 0.86 shape: `normalizeContext` folds systemPrompt+tools into a LEADING
173
+ // `role:"system"` message, so the carrier now rides INSIDE `messages` and is
174
+ // hashed like any other turn. Two consequences this cell locks:
175
+ // - a systemPrompt drift changes signature[0] → the prefix check declares the
176
+ // candidate incompatible → no reuse of a session built under the old prompt;
177
+ // - the same user turn under a system prompt is NOT a prefix of the same turn
178
+ // without one, so a 0.85-shaped record cannot silently be reused at 0.86.
179
+ const sameUser = [{ role: "user" as const, content: "hello", timestamp: 0 }];
180
+ const bare = contextMessageSignatures(normalizeContext({ messages: [...sameUser] }));
181
+ const withPrompt = contextMessageSignatures(normalizeContext({ systemPrompt: "carrier A", messages: [...sameUser] }));
182
+ const withOtherPrompt = contextMessageSignatures(
183
+ normalizeContext({ systemPrompt: "carrier B", messages: [...sameUser] }),
184
+ );
185
+ assert.equal(bare.length, 1, "no systemPrompt and no tools → no leading system message");
186
+ assert.equal(withPrompt.length, 2, "a systemPrompt becomes a leading system message that is hashed too");
187
+ assert.notEqual(withPrompt[0], withOtherPrompt[0], "a systemPrompt drift changes the leading signature");
188
+ assert.ok(!hasPrefix(withPrompt, bare), "a prompt-carrying transcript is not a prefix of a bare one");
189
+ assert.ok(!hasPrefix(withPrompt, withOtherPrompt), "a drifted carrier breaks prefix compatibility");
167
190
  }
168
191
 
169
192
  // ---------------------------------------------------------------------------
@@ -52,7 +52,8 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs
52
52
  import { tmpdir } from "node:os";
53
53
  import { resolve } from "node:path";
54
54
  import { pathToFileURL } from "node:url";
55
- import type { Api, AssistantMessageEvent, Context, Model } from "@earendil-works/pi-ai";
55
+ import type { Api, AssistantMessageEvent, Model } from "@earendil-works/pi-ai";
56
+ import { normalizeContext } from "@earendil-works/pi-ai";
56
57
 
57
58
  const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
58
59
 
@@ -83,6 +84,20 @@ function makeFakeChild() {
83
84
  };
84
85
  }
85
86
 
87
+ // The pi built-ins the Claude child also exposes natively. pi declares its tool surface on
88
+ // every turn, and since 0.86 that declaration reaches a provider as the leading system
89
+ // message's `toolsAdded` (`normalizeContext`), which backend.ts replays for the exclude-tools
90
+ // truthfulness preflight. A fixture that declares NO tools is therefore not "a turn with the
91
+ // defaults" — it is a turn where the operator excluded everything, and the preflight rejects it
92
+ // before the prompt is ever sent. So every provider-path context here is built through
93
+ // `normalizeContext`, never cast `as Context`: the cast is exactly what hid this shape from
94
+ // typecheck when the 0.86 pin landed.
95
+ const PI_DECLARED_TOOLS = ["read", "bash", "edit", "write"].map((name) => ({
96
+ name,
97
+ description: "",
98
+ parameters: {} as never,
99
+ }));
100
+
86
101
  const EMPTY_MCP_HASH = "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945";
87
102
  const DEFAULT_RESOLVED_CONFIG: any = {
88
103
  settingSources: [],
@@ -267,7 +282,10 @@ try {
267
282
  const events = await collect(
268
283
  backend.streamAcpTurn(
269
284
  sonnet,
270
- { messages: [{ role: "user", content: `drive ${label}`, timestamp: 0 }] } as Context,
285
+ normalizeContext({
286
+ tools: PI_DECLARED_TOOLS,
287
+ messages: [{ role: "user", content: `drive ${label}`, timestamp: 0 }],
288
+ }),
271
289
  { sessionId: `stop-${label}` },
272
290
  makeHarness(recordDir, cell.wire),
273
291
  ) as Stream,
@@ -32,7 +32,8 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs
32
32
  import { tmpdir } from "node:os";
33
33
  import { resolve } from "node:path";
34
34
  import { pathToFileURL } from "node:url";
35
- import type { Api, AssistantMessageEvent, Context, Message, Model } from "@earendil-works/pi-ai";
35
+ import type { Api, AssistantMessageEvent, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
36
+ import { normalizeContext } from "@earendil-works/pi-ai";
36
37
 
37
38
  const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
38
39
 
@@ -133,10 +134,26 @@ async function collect(stream: Stream): Promise<AssistantMessageEvent[]> {
133
134
  const sealed = (events: AssistantMessageEvent[]) =>
134
135
  events.filter((e) => e.type === "done" || e.type === "error") as any[];
135
136
 
136
- const userCtx = (text: string): Context => ({ messages: [{ role: "user", content: text, timestamp: 0 }] }) as Context;
137
+ // The pi built-ins the Claude child also exposes natively. pi declares its tool surface on
138
+ // every turn, and since 0.86 that declaration reaches a provider as the leading system
139
+ // message's `toolsAdded` (`normalizeContext`), which backend.ts replays for the exclude-tools
140
+ // truthfulness preflight. A fixture that declares NO tools is therefore not "a turn with the
141
+ // defaults" — it is a turn where the operator excluded everything, and the preflight rejects it
142
+ // before the prompt is ever sent. So every provider-path context here is built through
143
+ // `normalizeContext`, never cast `as Context`: the cast is exactly what hid this shape from
144
+ // typecheck when the 0.86 pin landed.
145
+ const PI_DECLARED_TOOLS = ["read", "bash", "edit", "write"].map((name) => ({
146
+ name,
147
+ description: "",
148
+ parameters: {} as never,
149
+ }));
137
150
 
138
- function reuseCtx(prior: string, latest: string): Context {
139
- return {
151
+ const userCtx = (text: string): TranscriptContext =>
152
+ normalizeContext({ tools: PI_DECLARED_TOOLS, messages: [{ role: "user", content: text, timestamp: 0 }] });
153
+
154
+ function reuseCtx(prior: string, latest: string): TranscriptContext {
155
+ return normalizeContext({
156
+ tools: PI_DECLARED_TOOLS,
140
157
  messages: [
141
158
  { role: "user", content: prior, timestamp: 0 },
142
159
  {
@@ -158,7 +175,7 @@ function reuseCtx(prior: string, latest: string): Context {
158
175
  } as unknown as Message,
159
176
  { role: "user", content: latest, timestamp: 0 },
160
177
  ],
161
- } as Context;
178
+ });
162
179
  }
163
180
 
164
181
  // EVERY cell registers onResponse; the exemption claim at the end judges the sum.
@@ -50,8 +50,15 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs
50
50
  import { tmpdir } from "node:os";
51
51
  import { resolve } from "node:path";
52
52
  import { pathToFileURL } from "node:url";
53
- import type { Api, AssistantMessage, AssistantMessageEvent, Context, Message, Model } from "@earendil-works/pi-ai";
54
- import { isContextOverflow } from "@earendil-works/pi-ai";
53
+ import type {
54
+ Api,
55
+ AssistantMessage,
56
+ AssistantMessageEvent,
57
+ Message,
58
+ Model,
59
+ TranscriptContext,
60
+ } from "@earendil-works/pi-ai";
61
+ import { isContextOverflow, normalizeContext } from "@earendil-works/pi-ai";
55
62
  import { calculateContextTokens } from "@earendil-works/pi-coding-agent";
56
63
 
57
64
  const claude = { id: "claude-sonnet-5" } as unknown as Model<Api>;
@@ -216,7 +223,22 @@ function sealedMessage(events: AssistantMessageEvent[]): AssistantMessage {
216
223
  return seal[0].message as AssistantMessage;
217
224
  }
218
225
 
219
- const userCtx = (text: string): Context => ({ messages: [{ role: "user", content: text, timestamp: 0 }] }) as Context;
226
+ // The pi built-ins the Claude child also exposes natively. pi declares its tool surface on
227
+ // every turn, and since 0.86 that declaration reaches a provider as the leading system
228
+ // message's `toolsAdded` (`normalizeContext`), which backend.ts replays for the exclude-tools
229
+ // truthfulness preflight. A fixture that declares NO tools is therefore not "a turn with the
230
+ // defaults" — it is a turn where the operator excluded everything, and the preflight rejects it
231
+ // before the prompt is ever sent. So every provider-path context here is built through
232
+ // `normalizeContext`, never cast `as Context`: the cast is exactly what hid this shape from
233
+ // typecheck when the 0.86 pin landed.
234
+ const PI_DECLARED_TOOLS = ["read", "bash", "edit", "write"].map((name) => ({
235
+ name,
236
+ description: "",
237
+ parameters: {} as never,
238
+ }));
239
+
240
+ const userCtx = (text: string): TranscriptContext =>
241
+ normalizeContext({ tools: PI_DECLARED_TOOLS, messages: [{ role: "user", content: text, timestamp: 0 }] });
220
242
 
221
243
  const zeroUsage = () => ({
222
244
  input: 0,
@@ -228,8 +250,9 @@ const zeroUsage = () => ({
228
250
  });
229
251
 
230
252
  /** A reuse-shaped context: prior user, assistant, new user. */
231
- function reuseCtx(prior: string, latest: string): Context {
232
- return {
253
+ function reuseCtx(prior: string, latest: string): TranscriptContext {
254
+ return normalizeContext({
255
+ tools: PI_DECLARED_TOOLS,
233
256
  messages: [
234
257
  { role: "user", content: prior, timestamp: 0 },
235
258
  {
@@ -244,11 +267,11 @@ function reuseCtx(prior: string, latest: string): Context {
244
267
  } as unknown as Message,
245
268
  { role: "user", content: latest, timestamp: 0 },
246
269
  ],
247
- } as Context;
270
+ });
248
271
  }
249
272
 
250
273
  /** A third turn on the same reused session. */
251
- function reuseCtx3(a: string, b: string, c: string): Context {
274
+ function reuseCtx3(a: string, b: string, c: string): TranscriptContext {
252
275
  const assistant = {
253
276
  role: "assistant",
254
277
  content: [{ type: "text", text: "ok" }],
@@ -259,7 +282,8 @@ function reuseCtx3(a: string, b: string, c: string): Context {
259
282
  stopReason: "stop",
260
283
  timestamp: 0,
261
284
  } as unknown as Message;
262
- return {
285
+ return normalizeContext({
286
+ tools: PI_DECLARED_TOOLS,
263
287
  messages: [
264
288
  { role: "user", content: a, timestamp: 0 },
265
289
  assistant,
@@ -267,7 +291,7 @@ function reuseCtx3(a: string, b: string, c: string): Context {
267
291
  assistant,
268
292
  { role: "user", content: c, timestamp: 0 },
269
293
  ],
270
- } as Context;
294
+ });
271
295
  }
272
296
 
273
297
  const TMP_EMIT = ".tmp-verify/acp-usage-accounting";
@@ -224,6 +224,20 @@ async function main(): Promise<void> {
224
224
  !tools.some((t) => ["entwurf", "entwurf_resume", "entwurf_send"].includes(String(t?.name))),
225
225
  );
226
226
 
227
+ const callback = tools.find((t) => t?.name === "entwurf_callback");
228
+ ok(
229
+ "[QK:BRIDGEBOOT-CALLBACK-REGISTERED] G1h: entwurf_callback is registered on the runtime tools/list surface — not merely imported",
230
+ !!callback,
231
+ );
232
+ const callbackSchema = callback?.inputSchema ?? {};
233
+ const callbackProps = callbackSchema.properties ?? {};
234
+ const callbackRequired = Array.isArray(callbackSchema.required) ? callbackSchema.required.map(String) : [];
235
+ ok(
236
+ "[QK:BRIDGEBOOT-CALLBACK-ZERO-ARG] G1h: entwurf_callback takes EXACTLY zero inputs — a target or nonce parameter here would be a model-supplied address, which Hard Rule 5 refuses",
237
+ Object.keys(callbackProps).length === 0 && callbackRequired.length === 0,
238
+ `--- inputSchema ---\n${JSON.stringify(callbackSchema)}`,
239
+ );
240
+
227
241
  // G1f — the public surface is an EXACT set, judged last so the named assertions above
228
242
  // keep their own diagnosis. Every check before this one is existential: each names one
229
243
  // verb it cares about, so a verb nobody named could be dropped (entwurf_fresh_call was
@@ -233,6 +247,7 @@ async function main(): Promise<void> {
233
247
  // from the server: an oracle computed from the subject proves nothing.
234
248
  const publicSurface = tools.map((t) => String(t?.name)).sort();
235
249
  const expectedSurface = [
250
+ "entwurf_callback",
236
251
  "entwurf_fresh_call",
237
252
  "entwurf_inbox_read",
238
253
  "entwurf_peers",
@@ -242,7 +257,7 @@ async function main(): Promise<void> {
242
257
  "entwurf_v2",
243
258
  ];
244
259
  ok(
245
- "[QK:BRIDGEBOOT-PUBLIC-SURFACE-EXACT-SET] G1f: the runtime tools/list surface is EXACTLY the seven shipped garden verbs — no missing verb, no undecided extra, no duplicate",
260
+ "[QK:BRIDGEBOOT-PUBLIC-SURFACE-EXACT-SET] G1f: the runtime tools/list surface is EXACTLY the eight shipped garden verbs — no missing verb, no undecided extra, no duplicate",
246
261
  publicSurface.length === expectedSurface.length && expectedSurface.every((n, i) => publicSurface[i] === n),
247
262
  `--- want ---\n${expectedSurface.join(",")}\n--- got ---\n${publicSurface.join(",")}`,
248
263
  );
@@ -282,6 +282,22 @@ const SUCCESS_RECEIPT = {
282
282
  observedLiveness: "alive" as const,
283
283
  };
284
284
 
285
+ /** Collect `const X = "literal"` bindings out of a parsed source, so a customType that travels
286
+ * through a named constant resolves to the same string a literal would. */
287
+ function visitConstsInto(
288
+ src: import("typescript").SourceFile,
289
+ into: Map<string, string>,
290
+ ts: typeof import("typescript"),
291
+ ): void {
292
+ const walk = (node: import("typescript").Node): void => {
293
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer !== undefined) {
294
+ if (ts.isStringLiteral(node.initializer)) into.set(node.name.text, node.initializer.text);
295
+ }
296
+ ts.forEachChild(node, walk);
297
+ };
298
+ walk(src);
299
+ }
300
+
285
301
  async function main(): Promise<void> {
286
302
  // ── 1: toDispatchInput mapping ────────────────────────────────────────────
287
303
  {
@@ -336,16 +352,50 @@ async function main(): Promise<void> {
336
352
  rr.isError && rr.text.includes("target-locked") && rr.text.includes("pid 999"),
337
353
  );
338
354
 
339
- // control sent → delivered
340
- const sent: EntwurfV2RunResult = {
355
+ // #120 P2 — THE BOUNDARY IS WHAT THIS LINE SAYS, and the route is an annotation beside it.
356
+ // The delivered branch renders what the RECEIVER observed, never which leg carried the
357
+ // bytes: for 78 minutes `control-socket → sent` was printed about messages lying in a
358
+ // queue, and the word `sent` is the whole reason nobody could tell.
359
+ const ctl = (
360
+ outcome: "sent" | "fallback-sent",
361
+ boundary?: "sent" | "queued-steer" | "queued-follow-up" | "accepted-unknown-boundary",
362
+ messagePath?: string,
363
+ ): EntwurfV2RunResult => ({
341
364
  kind: "executed",
342
365
  receipt: SUCCESS_RECEIPT,
343
366
  transport: "control-socket",
344
- outcome: { transport: "control-socket", outcome: "sent" },
345
- };
367
+ outcome: { transport: "control-socket", outcome, boundary, messagePath },
368
+ });
369
+ const idle = renderEntwurfV2Result(ctl("sent", "sent"));
370
+ ok("2: an idle direct trigger renders `sent`, and is not an error", !idle.isError && idle.text.endsWith("→ sent"));
371
+ const steered = renderEntwurfV2Result(ctl("sent", "queued-steer"));
372
+ const followed = renderEntwurfV2Result(ctl("sent", "queued-follow-up"));
373
+ ok(
374
+ "[QK:SEND-SURFACE-RENDERS-BOUNDARY] 2: a BUSY receiver renders its queue by name, as a success — and never the word `sent`. The route word answers WHICH LEG carried the bytes; this line has to answer what the receiver did with them, because for 78 minutes it said `sent` about messages lying in a queue",
375
+ !steered.isError &&
376
+ steered.text.endsWith("→ queued-steer") &&
377
+ !followed.isError &&
378
+ followed.text.endsWith("→ queued-follow-up") &&
379
+ !/→ sent/.test(steered.text) &&
380
+ !/→ sent/.test(followed.text),
381
+ );
382
+ const unknown = renderEntwurfV2Result(ctl("sent", "accepted-unknown-boundary"));
383
+ ok(
384
+ "[QK:SEND-SURFACE-UNKNOWN-NOT-SENT] 2: an answer we cannot classify renders `accepted-unknown-boundary` — a success (the receiver accepted; a retry would double-deliver) and NOT spelled with `sent`, which would put the removed false claim straight back",
385
+ !unknown.isError && unknown.text.endsWith("→ accepted-unknown-boundary") && !/sent/.test(unknown.text),
386
+ );
387
+ const fbQueued = renderEntwurfV2Result(ctl("fallback-sent", "queued-steer"));
346
388
  ok(
347
- "2: control sent → not error",
348
- !renderEntwurfV2Result(sent).isError && renderEntwurfV2Result(sent).text.includes("sent"),
389
+ "[QK:SEND-SURFACE-FALLBACK-KEEPS-BOUNDARY] 2: a re-resolved SOCKET keeps the boundary and annotates the route — a dead first socket re-resolving to a BUSY one must not come out reading as a plain delivery",
390
+ !fbQueued.isError && fbQueued.text.includes("queued-steer") && fbQueued.text.includes("re-resolved"),
391
+ );
392
+ const fbMail = renderEntwurfV2Result(ctl("fallback-sent", undefined, "/m/20260903T134455-e55e87/x.msg"));
393
+ ok(
394
+ "2: a re-resolved MAILBOX renders the durable receipt, not a bare route word",
395
+ !fbMail.isError &&
396
+ fbMail.text.includes("mailbox-enqueued") &&
397
+ fbMail.text.includes("enqueued x.msg") &&
398
+ !/fallback-sent/.test(fbMail.text),
349
399
  );
350
400
 
351
401
  // control in-band rejected with N3 rejectReason → non-delivery
@@ -375,7 +425,12 @@ async function main(): Promise<void> {
375
425
  },
376
426
  };
377
427
  const fb = renderEntwurfV2Result(fallback);
378
- ok("2: fallback-sent is a delivery (not an error)", !fb.isError && fb.text.includes("fallback-sent"));
428
+ // #120 P2: it is still a delivery, and it is now named by what it actually was — a durable
429
+ // mailbox enqueue reached through a re-resolved route — rather than by the internal leg word.
430
+ ok(
431
+ "2: a re-resolved mailbox delivery is not an error, and says mailbox-enqueued rather than the leg word",
432
+ !fb.isError && fb.text.includes("mailbox-enqueued") && fb.text.includes("re-resolved"),
433
+ );
379
434
  ok(
380
435
  "2: fallback-sent names the enqueued FILE (#98 R, fallback leg)",
381
436
  fb.text.includes("2026-09-03T09-58-02-114Z-ab12cd.msg"),
@@ -393,12 +448,12 @@ async function main(): Promise<void> {
393
448
  kind: "executed",
394
449
  receipt: { ...SUCCESS_RECEIPT, transport: "control-socket" },
395
450
  transport: "control-socket",
396
- outcome: { transport: "control-socket", outcome: "fallback-sent" },
451
+ outcome: { transport: "control-socket", outcome: "fallback-sent", boundary: "sent" },
397
452
  };
398
453
  const fbs = renderEntwurfV2Result(fallbackSocket);
399
454
  ok(
400
- "2: socket-retry fallback-sent degrades to the bare outcome (no file to name)",
401
- fbs.text === "entwurf_v2 control-socket → fallback-sent" && !fbs.isError,
455
+ "2: a socket-to-socket retry names no file (none was written) and still carries its boundary",
456
+ fbs.text === "entwurf_v2 control-socket → sent (via a re-resolved route)" && !fbs.isError,
402
457
  );
403
458
 
404
459
  // The two spawn-bg render cells (lock-retained / socket-alive) were deleted with the
@@ -489,6 +544,53 @@ async function main(): Promise<void> {
489
544
  n1r.isError && n1r.text.includes("DIRTY") && n1r.text.includes("do NOT retry"),
490
545
  );
491
546
 
547
+ // #120 P2 — A DIRTY LOCK IS A REASON TO BE MORE PRECISE, NOT LESS. This line used to read
548
+ // `DELIVERED (<route>)` for all three shapes that reach it, and it was wrong about two: a
549
+ // `rejected` finalization delivered nothing at all, and a `queued-*` one is sitting in
550
+ // volatile receiver memory. The operator reading it is about to decide BY HAND whether
551
+ // anything still needs sending, which is the worst possible moment for the one word on
552
+ // screen to be the internal route.
553
+ const dirty = (
554
+ finalizedOutcome: "sent" | "fallback-sent" | "rejected",
555
+ finalizedBoundary?: "sent" | "queued-steer" | "queued-follow-up" | "accepted-unknown-boundary",
556
+ finalizedMessagePath?: string,
557
+ finalizedRejectReason?: string,
558
+ ): { text: string; isError: boolean } =>
559
+ renderEntwurfV2Result({
560
+ kind: "execution-failed",
561
+ receipt: SUCCESS_RECEIPT,
562
+ transport: "control-socket",
563
+ error: "release boom",
564
+ finalizedOutcome,
565
+ finalizedBoundary,
566
+ finalizedMessagePath,
567
+ finalizedRejectReason,
568
+ releaseFailed: true,
569
+ retrySafe: false,
570
+ });
571
+ const dirtyBusy = dirty("sent", "queued-steer");
572
+ const dirtyMailbox = dirty("fallback-sent", undefined, "/m/20260903T134455-e55e87/x.msg");
573
+ const dirtyRefused = dirty("rejected", undefined, undefined, "compacting");
574
+ ok(
575
+ "[QK:SEND-DIRTY-LOCK-NAMES-ACCEPTANCE] a release failure after a terminal send names the ACCEPTANCE it reached — the receiver's queue, the enqueued file, or the refusal — and never a bare `DELIVERED (<route>)`, while the retry-unsafe dirty-lock meaning stays intact on all three",
576
+ // the queue, by name, and not the word delivered
577
+ dirtyBusy.text.includes("queued-steer") &&
578
+ !/DELIVERED|\bdelivered\b/.test(dirtyBusy.text) &&
579
+ // the mailbox leg names its durable file rather than a route word
580
+ dirtyMailbox.text.includes("mailbox-enqueued") &&
581
+ dirtyMailbox.text.includes("enqueued x.msg") &&
582
+ !/DELIVERED|\bdelivered\b/.test(dirtyMailbox.text) &&
583
+ // a REFUSAL is never dressed as a delivery
584
+ dirtyRefused.text.includes("rejected") &&
585
+ // the receiver's OWN reason survives to the line the operator acts on
586
+ dirtyRefused.text.includes("compacting") &&
587
+ !/DELIVERED|\bdelivered\b/.test(dirtyRefused.text) &&
588
+ // and every one of them keeps the thing the operator must act on
589
+ [dirtyBusy, dirtyMailbox, dirtyRefused].every(
590
+ (r) => r.isError && r.text.includes("DIRTY") && r.text.includes("do NOT retry"),
591
+ ),
592
+ );
593
+
492
594
  // plain execution-failed → error
493
595
  const failed: EntwurfV2RunResult = {
494
596
  kind: "execution-failed",
@@ -624,6 +726,25 @@ async function main(): Promise<void> {
624
726
  "4: pi-native — NO static import of the self-address fence (TS5097 stays closed)",
625
727
  !/import[^;]*from\s*"\.\/lib\/entwurf-self-address\.(js|ts)"/.test(code),
626
728
  );
729
+ // #120 P2: the RECEIVER's own answer. The sender can only render a boundary the receiver
730
+ // actually names, so this is the far end of the same contract — and the bare `delivered`
731
+ // it replaced is the one DELIVERY.md refuses by name, because it was equally true of a
732
+ // direct trigger and of a message rotting in a queue nobody drains.
733
+ ok(
734
+ "[QK:SEND-RECEIVER-EMITS-BOUNDARY] the control-socket `send` answers with the typed boundary its own state produces, and with no bare `delivered` / unconsumed `deliveredAs` beside it — a field that is true of every outcome tells a sender nothing, and there is no sender left that reads those two",
735
+ (() => {
736
+ // Scoped to the RPC ANSWER, not the whole file: `deliveredAs` also appears in the
737
+ // (separately owned, #120 Part 2) sent-message renderer, and a whole-file ban here
738
+ // would make this claim fail for a reason it is not about.
739
+ const at = code.indexOf('respond(true, "send"');
740
+ const answer = at < 0 ? "" : code.slice(at, code.indexOf(");", at));
741
+ return (
742
+ /boundary:\s*sendReceiptBoundary\(\{\s*idle:\s*isIdle,\s*mode\s*\}\)/.test(answer) &&
743
+ !/\bdelivered:\s*true/.test(answer) &&
744
+ !/\bdeliveredAs\b/.test(answer)
745
+ );
746
+ })(),
747
+ );
627
748
  // Every SHIPPED rail must appear in the model-facing text. Measured 2026-07-27: both
628
749
  // surfaces described only control-socket/mailbox and told the model that an
629
750
  // `unsupported` citizen is reached "→ mailbox" — false for Antigravity, whose native-push
@@ -847,6 +968,164 @@ async function main(): Promise<void> {
847
968
  );
848
969
  }
849
970
 
971
+ // ── #120 P3: a renderer nobody can produce for, and the retired surface it drew ──
972
+ // PARSED, NOT GREPPED. The defect being closed here is a renderer registered for a customType
973
+ // that no code emits — and the thing that hid it for two releases was PROSE: `protocol.js` said
974
+ // "the bridge emits it", `entwurf-control.ts` named a producer in `streamShellAcp`, and neither
975
+ // was true. A regex oracle would have been satisfied by exactly those sentences. So the
976
+ // registration and the producer are both read out of the TypeScript AST: a `registerMessageRenderer`
977
+ // CALL, and an object literal property `customType` whose value resolves to the same string.
978
+ //
979
+ // SCOPE, stated: this is the pi-native file's own contract — registrations here must have a
980
+ // producer here. A future cross-file producer is a reason to widen this gate WITH the evidence,
981
+ // never a reason to let a comment satisfy it.
982
+ {
983
+ const ts = (await import("typescript")).default;
984
+ const file = path.join(REPO, "pi-extensions", "entwurf-control.ts");
985
+ const src = ts.createSourceFile(file, await fs.readFile(file, "utf8"), ts.ScriptTarget.Latest, true);
986
+
987
+ // `const X = "literal"` — the only indirection a customType is allowed to take here.
988
+ const consts = new Map<string, string>();
989
+ const visitConsts = (node: import("typescript").Node): void => {
990
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer !== undefined) {
991
+ if (ts.isStringLiteral(node.initializer)) consts.set(node.name.text, node.initializer.text);
992
+ }
993
+ ts.forEachChild(node, visitConsts);
994
+ };
995
+ visitConsts(src);
996
+ // Imported constants resolve through `protocol.js`, which is a string-literal module too.
997
+ const protocolSrc = ts.createSourceFile(
998
+ "protocol.js",
999
+ await fs.readFile(path.join(REPO, "protocol.js"), "utf8"),
1000
+ ts.ScriptTarget.Latest,
1001
+ true,
1002
+ );
1003
+ visitConstsInto(protocolSrc, consts, ts);
1004
+
1005
+ const resolve = (expr: import("typescript").Expression): string | null => {
1006
+ if (ts.isStringLiteral(expr)) return expr.text;
1007
+ if (ts.isIdentifier(expr)) return consts.get(expr.text) ?? null;
1008
+ return null;
1009
+ };
1010
+
1011
+ const registered: { type: string | null; text: string }[] = [];
1012
+ const produced = new Set<string>();
1013
+ const visit = (node: import("typescript").Node): void => {
1014
+ if (
1015
+ ts.isCallExpression(node) &&
1016
+ ts.isPropertyAccessExpression(node.expression) &&
1017
+ node.expression.name.text === "registerMessageRenderer" &&
1018
+ node.arguments.length > 0
1019
+ ) {
1020
+ registered.push({ type: resolve(node.arguments[0]), text: node.arguments[0].getText(src) });
1021
+ }
1022
+ if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && node.name.text === "customType") {
1023
+ const value = resolve(node.initializer);
1024
+ if (value !== null) produced.add(value);
1025
+ }
1026
+ ts.forEachChild(node, visit);
1027
+ };
1028
+ visit(src);
1029
+
1030
+ const orphans = registered.filter((r) => r.type === null || !produced.has(r.type));
1031
+ ok(
1032
+ `[QK:NO-RENDERER-WITHOUT-PRODUCER] every message renderer this extension registers is registered for a customType this extension actually EMITS — read from the AST, because the producer-less one that prompted this claim was covered by two comments naming producers that do not exist. A renderer nobody can reach is a box the operator will never see and a maintenance surface that answers no question (registered ${JSON.stringify(registered.map((r) => r.text))}, produced ${JSON.stringify([...produced])}, orphans ${JSON.stringify(orphans.map((o) => o.text))})`,
1033
+ registered.length > 0 && orphans.length === 0,
1034
+ );
1035
+
1036
+ // The retired surface itself, sealed in the two LIVE files it lived in. History keeps its
1037
+ // own record — CHANGELOG, ROADMAP and the demo note are deliberately NOT scanned.
1038
+ // The PHRASES too, not just the identifiers: what kept this surface alive through two
1039
+ // releases was prose — a comment promising "the [entwurf sent →] box exists for every
1040
+ // session that DOES send" outlived the code by one removal and would have been read as a
1041
+ // live guarantee. Sealing the identifiers alone leaves the lie behind.
1042
+ const retired =
1043
+ /ENTWURF_SENT_MESSAGE_TYPE|entwurf-sent|renderSentMessage|buildSentMessageBox|SentBoxData|entwurf sent|sender-side UI box/i;
1044
+ const live = [
1045
+ { rel: "protocol.js", text: await fs.readFile(path.join(REPO, "protocol.js"), "utf8") },
1046
+ { rel: "pi-extensions/entwurf-control.ts", text: await fs.readFile(file, "utf8") },
1047
+ ];
1048
+ const survivors = live.filter((f) => retired.test(f.text)).map((f) => f.rel);
1049
+ ok(
1050
+ `[QK:RETIRED-SENT-SURFACE-ABSENT] the sender-side echo surface is gone from the live bytes — no constant, no renderer, no box builder, no payload type — rather than left as a public export nothing produces. It was shipped in \`files\`, so an unreachable name there is a promise to a consumer that no code could ever keep (survivors: ${JSON.stringify(survivors)})`,
1051
+ survivors.length === 0,
1052
+ );
1053
+ }
1054
+
1055
+ // ── #120 P2 (D8): the OUTCOME list in both tool descriptions names every rail ──
1056
+ // A caller reads one of these two blurbs and nothing else before dispatching. The control
1057
+ // rail's four boundaries are the ONLY receiver-acceptance values; a mailbox answers with an
1058
+ // ENQUEUE receipt and native-push with an INJECTION, and folding those into the boundary union
1059
+ // would tell a caller that `mailbox-enqueued` is something a receiver observed about its own
1060
+ // queues. Missing either rail is red — the earlier draft listed `mailbox-enqueued` beside the
1061
+ // four and left native-push out entirely, and nothing noticed.
1062
+ {
1063
+ const outcomeSites = ["mcp/entwurf-bridge/src/index.ts", "pi-extensions/entwurf-control.ts"];
1064
+ const missing: string[] = [];
1065
+ for (const rel of outcomeSites) {
1066
+ const text = await fs.readFile(path.join(REPO, rel), "utf8");
1067
+ const at = text.indexOf("reports ONE");
1068
+ const blurb = at < 0 ? "" : text.slice(at, at + 900);
1069
+ if (!/acceptance boundary/i.test(blurb)) missing.push(`${rel}: control receiver acceptance boundary`);
1070
+ for (const b of ["sent", "queued-steer", "queued-follow-up", "accepted-unknown-boundary"]) {
1071
+ if (!blurb.includes(b)) missing.push(`${rel}: boundary ${b}`);
1072
+ }
1073
+ if (!/those four only/i.test(blurb)) missing.push(`${rel}: the four-only scope of the boundary union`);
1074
+ if (!/mailbox ENQUEUE/i.test(blurb)) missing.push(`${rel}: mailbox enqueue receipt`);
1075
+ if (!/native-push INJECTION/i.test(blurb)) missing.push(`${rel}: native-push injection`);
1076
+ if (!/rejection|rejected/i.test(blurb)) missing.push(`${rel}: rejection`);
1077
+ if (!/lock-dirty/i.test(blurb)) missing.push(`${rel}: accepted-but-lock-dirty`);
1078
+ if (/mailbox-enqueued`? \/|\/ `?mailbox-enqueued/i.test(blurb)) {
1079
+ missing.push(`${rel}: mailbox-enqueued is listed INSIDE the receiver-boundary union`);
1080
+ }
1081
+ }
1082
+ ok(
1083
+ `[QK:SEND-OUTCOME-LIST-NAMES-EVERY-RAIL] both tool descriptions name each rail's own result — the control receiver's four acceptance boundaries (and that those four are the whole union), the mailbox ENQUEUE receipt, the native-push INJECTION, a rejection, and accepted-but-lock-dirty — so a caller is never told that one rail's vocabulary describes another's (missing: ${JSON.stringify(missing)})`,
1084
+ missing.length === 0,
1085
+ );
1086
+ }
1087
+
1088
+ // ── #120 P2: the four caller-facing prose sites, checked for what they SAY ──
1089
+ // `[측정 2026-09-22, pi-agent-core 0.87.0; 재측정 2026-09-23, 0.87.1 — 좌표 동일]` neither mode interrupts anything: `agent-loop.js:186`
1090
+ // drains steering after a turn ENDS and `:191-197` drains follow-ups only once the inner loop
1091
+ // has ended. A caller who reads "interrupt the current turn" picks `steer` for urgency and gets
1092
+ // a queue with no order against the other one — the decision #120 was opened about.
1093
+ //
1094
+ // POSITIVE, not merely a banned-word sweep. The first cut of this claim only looked for the old
1095
+ // wording, which meant a site could pass by deleting its description entirely — the assertion
1096
+ // would have said four sites explain the queues while one of them explained nothing. Each site
1097
+ // now has to carry all four facts a caller needs before choosing a mode, and the old wording is
1098
+ // still banned beside them.
1099
+ {
1100
+ const sites = [
1101
+ "mcp/entwurf-bridge/src/index.ts",
1102
+ "pi-extensions/entwurf-control.ts",
1103
+ "pi-extensions/lib/entwurf-v2-contract.ts",
1104
+ "pi-extensions/lib/entwurf-v2-contract-schema.ts",
1105
+ ];
1106
+ const axes: { name: string; holds: (t: string) => boolean }[] = [
1107
+ { name: "names both queues", holds: (t) => /steering/i.test(t) && /follow-?ups?\b/i.test(t) },
1108
+ { name: "says neither is an interrupt", holds: (t) => /(neither|not)\s+(is\s+)?an?\s+interrupt/i.test(t) },
1109
+ { name: "says a later steer overtakes an earlier follow_up", holds: (t) => /overtakes/i.test(t) },
1110
+ {
1111
+ name: "says the queues are volatile and an abort drops them",
1112
+ holds: (t) => /volatile/i.test(t) && /abort/i.test(t),
1113
+ },
1114
+ ];
1115
+ const gaps: string[] = [];
1116
+ for (const rel of sites) {
1117
+ const text = await fs.readFile(path.join(REPO, rel), "utf8");
1118
+ for (const axis of axes) if (!axis.holds(text)) gaps.push(`${rel}: ${axis.name}`);
1119
+ if (/steer \((?:interrupt|immediate)|interrupt the current turn|steer = interrupt/i.test(text)) {
1120
+ gaps.push(`${rel}: still claims steer interrupts`);
1121
+ }
1122
+ }
1123
+ ok(
1124
+ `[QK:SEND-PROSE-NO-INTERRUPT] every caller-facing description of \`mode\` names BOTH queues, says neither is an interrupt, says a later steer overtakes an earlier follow_up, and says both are volatile so an abort drops them — and none of them still claims an interrupt. A caller chooses a mode from this text alone (gaps: ${JSON.stringify(gaps)})`,
1125
+ gaps.length === 0,
1126
+ );
1127
+ }
1128
+
850
1129
  console.log(`\ncheck-entwurf-v2-surface: ${passed} checks passed`);
851
1130
  }
852
1131