@junghanacs/entwurf 0.13.0 → 0.14.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 (193) hide show
  1. package/AGENTS.md +52 -17
  2. package/BASELINE.md +60 -206
  3. package/CHANGELOG.md +64 -0
  4. package/CONTRIBUTING.md +14 -10
  5. package/DELIVERY.md +118 -261
  6. package/README.md +91 -431
  7. package/VERIFY.md +74 -95
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +236 -0
  12. package/docs/external-mcp-host.md +131 -0
  13. package/docs/fresh-cut-policy.md +99 -0
  14. package/docs/setup-clean-host.md +124 -328
  15. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  38. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  39. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  40. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  41. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  42. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  43. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  44. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  45. package/mcp/entwurf-bridge/src/index.ts +176 -67
  46. package/mcp/entwurf-bridge/start.sh +2 -2
  47. package/mcp/entwurf-bridge/test.sh +23 -9
  48. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  49. package/package.json +25 -11
  50. package/pi-extensions/entwurf-control.ts +220 -71
  51. package/pi-extensions/lib/acp/acp-client.ts +22 -0
  52. package/pi-extensions/lib/acp/augment.ts +42 -3
  53. package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
  54. package/pi-extensions/lib/acp/backend.ts +366 -38
  55. package/pi-extensions/lib/acp/config.ts +1 -1
  56. package/pi-extensions/lib/acp/engraving.ts +43 -1
  57. package/pi-extensions/lib/acp/event-mapper.ts +16 -13
  58. package/pi-extensions/lib/acp/models.ts +4 -4
  59. package/pi-extensions/lib/acp/overlay.ts +11 -9
  60. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  61. package/pi-extensions/lib/entwurf-core.ts +15 -15
  62. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  63. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  64. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  65. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  66. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  67. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  68. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  69. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  70. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  71. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  72. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  73. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  74. package/pi-extensions/lib/meta-session.ts +93 -5
  75. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  76. package/pi-extensions/lib/mux-launch.ts +267 -0
  77. package/pi-extensions/lib/mux-placement.ts +387 -0
  78. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  79. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  80. package/pi-extensions/lib/session-id.js +8 -5
  81. package/pi-extensions/lib/socket-discovery.ts +3 -3
  82. package/prompts/engraving.md +13 -5
  83. package/run.sh +688 -282
  84. package/scripts/agy-bridge-config.py +5 -1
  85. package/scripts/check-acp-backend-preflight.ts +1 -1
  86. package/scripts/check-acp-carrier-augment.ts +246 -10
  87. package/scripts/check-acp-cortex.ts +5 -5
  88. package/scripts/check-acp-overlay.ts +13 -3
  89. package/scripts/check-acp-prompt-lifecycle.ts +565 -0
  90. package/scripts/check-acp-stop-reason.ts +342 -0
  91. package/scripts/check-acp-stream-hooks.ts +504 -0
  92. package/scripts/check-elapsed.sh +25 -0
  93. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  94. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  95. package/scripts/check-entwurf-control-rpc.ts +4 -3
  96. package/scripts/check-entwurf-resume-args.ts +72 -70
  97. package/scripts/check-entwurf-session-identity.ts +14 -10
  98. package/scripts/check-entwurf-v2-contract.ts +34 -59
  99. package/scripts/check-entwurf-v2-decider.ts +17 -177
  100. package/scripts/check-entwurf-v2-lock.ts +5 -2
  101. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  102. package/scripts/check-entwurf-v2-production.ts +2 -91
  103. package/scripts/check-entwurf-v2-release.ts +10 -105
  104. package/scripts/check-entwurf-v2-runner.ts +4 -85
  105. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  106. package/scripts/check-entwurf-v2-send.ts +0 -28
  107. package/scripts/check-entwurf-v2-surface.ts +198 -128
  108. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  109. package/scripts/check-fresh-cut-gate.sh +1 -1
  110. package/scripts/check-gate-qualification.ts +101 -7
  111. package/scripts/check-install-container.sh +10 -2
  112. package/scripts/check-install-surface.ts +1 -1
  113. package/scripts/check-keyset-overlap.py +1 -1
  114. package/scripts/check-meta-facts.ts +249 -0
  115. package/scripts/check-meta-identity-consumers.ts +1 -1
  116. package/scripts/check-meta-session.ts +169 -0
  117. package/scripts/check-mux-launch-tmux.ts +316 -0
  118. package/scripts/check-mux-launch.ts +288 -0
  119. package/scripts/check-mux-launcher-fence.ts +264 -0
  120. package/scripts/check-mux-parent-artifact.ts +195 -0
  121. package/scripts/check-mux-placement-tmux.ts +322 -0
  122. package/scripts/check-mux-placement.ts +323 -0
  123. package/scripts/check-mux-resume-call.ts +282 -0
  124. package/scripts/check-probe-cli-shim.ts +25 -22
  125. package/scripts/check-probe-ordering.ts +121 -81
  126. package/scripts/check-release-gate-outcomes.ts +452 -0
  127. package/scripts/check-resume-launch-identity.ts +244 -0
  128. package/scripts/check-socket-discovery.ts +1 -1
  129. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  130. package/scripts/inventory-verification-surface.ts +349 -0
  131. package/scripts/lib/claude-launcher-fence.ts +322 -0
  132. package/scripts/lib/live-skip.ts +33 -0
  133. package/scripts/lib/mutation-qualify.ts +109 -3
  134. package/scripts/lib/probe-acp-turn.ts +33 -12
  135. package/scripts/lib/step-outcome.sh +88 -0
  136. package/scripts/meta-bridge-doctor.sh +6 -8
  137. package/scripts/meta-facts.ts +60 -0
  138. package/scripts/mutants/acp-augment.json +106 -0
  139. package/scripts/mutants/acp-cortex.json +2 -2
  140. package/scripts/mutants/acp-overlay.json +17 -0
  141. package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
  142. package/scripts/mutants/acp-stop-reason.json +80 -0
  143. package/scripts/mutants/acp-stream-hooks.json +158 -0
  144. package/scripts/mutants/bridge-boot-resume.json +45 -0
  145. package/scripts/mutants/meta-facts.json +50 -0
  146. package/scripts/mutants/meta-identity.json +36 -0
  147. package/scripts/mutants/mux-boundary.json +196 -0
  148. package/scripts/mutants/mux-fresh-call.json +185 -0
  149. package/scripts/mutants/mux-launcher-fence.json +123 -0
  150. package/scripts/mutants/mux-parent-artifact.json +39 -0
  151. package/scripts/mutants/mux-resume-call.json +148 -0
  152. package/scripts/mutants/probe-ordering.json +6 -1021
  153. package/scripts/mutants/release-gate.json +140 -0
  154. package/scripts/mutants/resume-args.json +76 -0
  155. package/scripts/mutants/resume-launch-identity.json +96 -0
  156. package/scripts/mutants/v2-surface.json +74 -23
  157. package/scripts/mutants/v2-visible-resume.json +215 -0
  158. package/scripts/new-session-id.ts +9 -4
  159. package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
  160. package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
  161. package/scripts/smoke-acp-cortex-live.ts +8 -14
  162. package/scripts/smoke-acp-long-turn-live.ts +185 -0
  163. package/scripts/smoke-acp-mcp-live.ts +2 -2
  164. package/scripts/smoke-acp-memory-containment-live.ts +2 -2
  165. package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
  166. package/scripts/smoke-acp-overlay-live.ts +2 -2
  167. package/scripts/smoke-acp-provider-live.ts +2 -2
  168. package/scripts/smoke-acp-raw-turn-live.ts +3 -3
  169. package/scripts/smoke-acp-session-reuse-live.ts +2 -2
  170. package/scripts/smoke-acp-skill-live.ts +2 -2
  171. package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
  172. package/scripts/smoke-acp-v2-send-live.ts +4 -3
  173. package/scripts/smoke-agy-native-push-live.ts +10 -20
  174. package/scripts/smoke-claude-native-resume-live.sh +13 -3
  175. package/scripts/smoke-entwurf-chain-live.ts +352 -0
  176. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
  177. package/scripts/smoke-meta-honesty.sh +1 -1
  178. package/scripts/smoke-meta-install-state.sh +4 -0
  179. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  180. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  181. package/scripts/smoke-pi-attach.ts +1 -1
  182. package/scripts/smoke-user-scope-citizen.sh +1 -1
  183. package/scripts/tsconfig.json +1 -0
  184. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  185. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  186. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  187. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  188. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  189. package/scripts/check-acp-sdk-surface.ts +0 -236
  190. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  191. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  192. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  193. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
@@ -0,0 +1,504 @@
1
+ // Deterministic gate for the pi 0.84 streamSimple HOOK contract on the ACP rail
2
+ // (#63; upstream pi-mono #7372 → doc-only PR #7576).
3
+ //
4
+ // What the contract is, measured, not assumed:
5
+ //
6
+ // onPayload maps TRUTHFULLY: the provider request on this rail is the ACP
7
+ // `session/prompt` params, so the hook receives the EXACT
8
+ // `{sessionId, prompt}` object after the wire content is fully
9
+ // built (augment / reuse delta included) and immediately before
10
+ // `connection.prompt`; a returned replacement becomes the wire
11
+ // params. Integrity is fail-closed: a replacement must be a
12
+ // non-null, non-array object keeping the bootstrapped sessionId
13
+ // and carrying a non-empty prompt array — prompt rewriting is
14
+ // upstream-granted power, session identity and this rail's own
15
+ // non-empty-prompt invariant are not.
16
+ // onResponse is an EXPLICIT LOCAL NON-HTTP EXEMPTION: pi hard-types it as
17
+ // HTTP `{status, headers}` and ACP's terminal result arrives only
18
+ // after the session-update body was consumed — any invocation
19
+ // would fabricate HTTP evidence. This gate pins the ABSENCE
20
+ // behaviorally across success, error, and abort turns.
21
+ //
22
+ // Every cell drives `streamAcpTurn` against a fake ACP child/connection with
23
+ // handlers registered for BOTH hooks, and judges what actually reached the wire.
24
+ //
25
+ // backend.ts imports its siblings with `.js` suffixes (the root/jiti runtime
26
+ // convention), so — like check-acp-prompt-lifecycle — we tsc-emit the project
27
+ // and import the COMPILED backend.js whose `.js` imports resolve to real siblings.
28
+
29
+ import { strict as assert } from "node:assert";
30
+ import { execFileSync } from "node:child_process";
31
+ import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs";
32
+ import { tmpdir } from "node:os";
33
+ import { resolve } from "node:path";
34
+ import { pathToFileURL } from "node:url";
35
+ import type { Api, AssistantMessageEvent, Context, Message, Model } from "@earendil-works/pi-ai";
36
+
37
+ const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
38
+
39
+ type Stream = AsyncIterable<AssistantMessageEvent>;
40
+
41
+ interface Deferred<T> {
42
+ promise: Promise<T>;
43
+ resolve: (value: T) => void;
44
+ reject: (err: unknown) => void;
45
+ }
46
+
47
+ function deferred<T>(): Deferred<T> {
48
+ let res!: (value: T) => void;
49
+ let rej!: (err: unknown) => void;
50
+ const promise = new Promise<T>((a, b) => {
51
+ res = a;
52
+ rej = b;
53
+ });
54
+ return { promise, resolve: res, reject: rej };
55
+ }
56
+
57
+ function makeFakeChild() {
58
+ const pipe = () => ({ destroy() {}, unref() {} });
59
+ return {
60
+ pid: undefined as number | undefined,
61
+ exitCode: null as number | null,
62
+ signalCode: null as NodeJS.Signals | null,
63
+ stdin: pipe(),
64
+ stdout: pipe(),
65
+ stderr: { on() {}, destroy() {}, unref() {} },
66
+ kill() {
67
+ return true;
68
+ },
69
+ unref() {},
70
+ once() {},
71
+ };
72
+ }
73
+
74
+ const EMPTY_MCP_HASH = "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945";
75
+ const DEFAULT_RESOLVED_CONFIG: any = {
76
+ settingSources: [],
77
+ strictMcpConfig: true,
78
+ showToolNotifications: true,
79
+ mcpServers: [],
80
+ mcpServersHash: EMPTY_MCP_HASH,
81
+ tools: ["Read"],
82
+ skillPlugins: [],
83
+ permissionAllow: ["Read(*)"],
84
+ disallowedTools: [],
85
+ };
86
+
87
+ /** One backend world whose fake connection CAPTURES the raw wire prompt params. */
88
+ function makeHarness(recordDir: string) {
89
+ const children: ReturnType<typeof makeFakeChild>[] = [];
90
+ const promptCalls: Array<{ sessionId: string; prompt: Array<{ type: string; text: string }> }> = [];
91
+
92
+ const makeConnection = (_handlers: any) => ({
93
+ initialize: async () => ({ agentCapabilities: {} }),
94
+ newSession: async () => ({ sessionId: "ACP-1" }),
95
+ setSessionConfigOption: async () => ({}),
96
+ prompt: async (params: any) => {
97
+ // Deep-cloned: the capture must be the bytes that crossed the seam, not a
98
+ // live reference a later mutation could rewrite.
99
+ promptCalls.push(structuredClone(params));
100
+ return { stopReason: "end_turn" };
101
+ },
102
+ cancel: async () => {},
103
+ close: () => {},
104
+ });
105
+
106
+ return {
107
+ children,
108
+ promptCalls,
109
+ deps: {
110
+ resolveLaunch: () => ({ command: "node", args: ["fake"] }),
111
+ ensureOverlay: () => {},
112
+ spawnChild: () => {
113
+ const c = makeFakeChild();
114
+ children.push(c);
115
+ return c;
116
+ },
117
+ createConnection: (_child: any, handlers: any) => makeConnection(handlers),
118
+ lifecyclePolicy: () => "process-scoped",
119
+ loadConfig: () => DEFAULT_RESOLVED_CONFIG,
120
+ now: () => "2026-08-07T00:00:00Z",
121
+ sessionDir: recordDir,
122
+ abortGraceMs: 40,
123
+ },
124
+ };
125
+ }
126
+
127
+ async function collect(stream: Stream): Promise<AssistantMessageEvent[]> {
128
+ const events: AssistantMessageEvent[] = [];
129
+ for await (const ev of stream) events.push(ev);
130
+ return events;
131
+ }
132
+
133
+ const sealed = (events: AssistantMessageEvent[]) =>
134
+ events.filter((e) => e.type === "done" || e.type === "error") as any[];
135
+
136
+ const userCtx = (text: string): Context => ({ messages: [{ role: "user", content: text, timestamp: 0 }] }) as Context;
137
+
138
+ function reuseCtx(prior: string, latest: string): Context {
139
+ return {
140
+ messages: [
141
+ { role: "user", content: prior, timestamp: 0 },
142
+ {
143
+ role: "assistant",
144
+ content: [{ type: "text", text: "ok" }],
145
+ api: "x",
146
+ provider: "x",
147
+ model: "x",
148
+ usage: {
149
+ input: 0,
150
+ output: 0,
151
+ cacheRead: 0,
152
+ cacheWrite: 0,
153
+ totalTokens: 0,
154
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
155
+ },
156
+ stopReason: "stop",
157
+ timestamp: 0,
158
+ } as unknown as Message,
159
+ { role: "user", content: latest, timestamp: 0 },
160
+ ],
161
+ } as Context;
162
+ }
163
+
164
+ // EVERY cell registers onResponse; the exemption claim at the end judges the sum.
165
+ let onResponseCalls = 0;
166
+ const onResponse = () => {
167
+ onResponseCalls++;
168
+ };
169
+
170
+ const TMP_EMIT = ".tmp-verify/acp-stream-hooks";
171
+ rmSync(TMP_EMIT, { recursive: true, force: true });
172
+ const recordDir = mkdtempSync(resolve(tmpdir(), "acp-stream-hooks-"));
173
+
174
+ try {
175
+ execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
176
+ stdio: "pipe",
177
+ });
178
+ const promptsOut = resolve(TMP_EMIT, "pi-extensions/lib/acp/prompts");
179
+ mkdirSync(promptsOut, { recursive: true });
180
+ copyFileSync("pi-extensions/lib/acp/prompts/engraving.md", resolve(promptsOut, "engraving.md"));
181
+ const backend = (await import(pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend.js")).href)) as any;
182
+
183
+ // ----------------------------------------------------------------------
184
+ // CELL 1 — NEW turn: the hook sees the EXACT wire params; undefined keeps them.
185
+ // ----------------------------------------------------------------------
186
+ {
187
+ const h = makeHarness(recordDir);
188
+ const seen: any[] = [];
189
+ const events = await collect(
190
+ backend.streamAcpTurn(
191
+ sonnet,
192
+ userCtx("NONCE-FIRST-TURN"),
193
+ {
194
+ sessionId: "hooks-new",
195
+ onPayload: (payload: unknown) => {
196
+ seen.push(structuredClone(payload));
197
+ return undefined;
198
+ },
199
+ onResponse,
200
+ },
201
+ h.deps,
202
+ ) as Stream,
203
+ );
204
+ assert.equal(sealed(events)[0]?.type, "done", "the pass-through turn completes normally");
205
+ assert.ok(
206
+ seen.length === 1 && JSON.stringify(seen[0]) === JSON.stringify(h.promptCalls[0]),
207
+ "[QK:ACPHOOK-PAYLOAD-EXACT-WIRE] before_provider_request must run exactly once and receive the EXACT ACP " +
208
+ "session/prompt params that then cross the wire — same bootstrapped sessionId, same fully-built prompt " +
209
+ "(augment included) — and an undefined return must leave those params untouched; a skipped hook or a " +
210
+ "divergent payload means extensions observe a fiction",
211
+ );
212
+ assert.ok(
213
+ JSON.stringify(seen[0].prompt).includes("NONCE-FIRST-TURN"),
214
+ "the observed payload really carries this turn's user content",
215
+ );
216
+ }
217
+
218
+ // ----------------------------------------------------------------------
219
+ // CELL 2 — NEW turn: a returned replacement becomes the wire params.
220
+ // ----------------------------------------------------------------------
221
+ {
222
+ const h = makeHarness(recordDir);
223
+ const events = await collect(
224
+ backend.streamAcpTurn(
225
+ sonnet,
226
+ userCtx("NONCE-ORIGINAL"),
227
+ {
228
+ sessionId: "hooks-replace",
229
+ onPayload: (payload: any) => ({
230
+ sessionId: payload.sessionId,
231
+ prompt: [{ type: "text", text: "NONCE-REPLACED-WIRE" }],
232
+ }),
233
+ onResponse,
234
+ },
235
+ h.deps,
236
+ ) as Stream,
237
+ );
238
+ assert.equal(sealed(events)[0]?.type, "done", "the replaced turn completes normally");
239
+ assert.equal(h.promptCalls.length, 1, "exactly one prompt crossed the wire");
240
+ const wire = JSON.stringify(h.promptCalls[0]);
241
+ assert.ok(
242
+ wire.includes("NONCE-REPLACED-WIRE") && !wire.includes("NONCE-ORIGINAL"),
243
+ "[QK:ACPHOOK-REPLACEMENT-ON-WIRE] a replacement returned by before_provider_request must BE the wire params — " +
244
+ "the upstream contract says 'use any returned replacement payload', and ignoring it is exactly the " +
245
+ "pi-vertex defect (#7372) that silently breaks payload-hooking extensions",
246
+ );
247
+ }
248
+
249
+ // ----------------------------------------------------------------------
250
+ // CELL 3 — REUSE turn parity: same boundary, delta params, replacement honored.
251
+ // ----------------------------------------------------------------------
252
+ {
253
+ const h = makeHarness(recordDir);
254
+ const seen: any[] = [];
255
+ let replaceReuse = false;
256
+ const options = {
257
+ sessionId: "hooks-reuse",
258
+ onPayload: (payload: any) => {
259
+ seen.push(structuredClone(payload));
260
+ if (!replaceReuse) return undefined;
261
+ return { sessionId: payload.sessionId, prompt: [{ type: "text", text: "NONCE-REUSE-REPLACED" }] };
262
+ },
263
+ onResponse,
264
+ };
265
+ const first = await collect(backend.streamAcpTurn(sonnet, userCtx("NONCE-TURN-ONE"), options, h.deps) as Stream);
266
+ assert.equal(sealed(first)[0]?.type, "done", "turn 1 (new) completes");
267
+ replaceReuse = true;
268
+ const second = await collect(
269
+ backend.streamAcpTurn(sonnet, reuseCtx("NONCE-TURN-ONE", "NONCE-TURN-TWO"), options, h.deps) as Stream,
270
+ );
271
+ assert.equal(sealed(second)[0]?.type, "done", "turn 2 (reuse) completes");
272
+ assert.equal(h.children.length, 1, "turn 2 reused the live session — no second child");
273
+ const reuseSeen = JSON.stringify(seen[1] ?? null);
274
+ const reuseWire = JSON.stringify(h.promptCalls[1] ?? null);
275
+ assert.ok(
276
+ seen.length === 2 &&
277
+ reuseSeen.includes("NONCE-TURN-TWO") &&
278
+ !reuseSeen.includes("NONCE-TURN-ONE") &&
279
+ reuseWire.includes("NONCE-REUSE-REPLACED") &&
280
+ !reuseWire.includes("NONCE-TURN-TWO"),
281
+ "[QK:ACPHOOK-REUSE-PARITY] the reuse turn owes the hook the SAME truthful boundary as a new turn — the payload " +
282
+ "it sees is the actual delta-only wire params for the live session, and its replacement crosses the wire; " +
283
+ "a hook that only fires on cold turns would hide every resident-session request from extensions",
284
+ );
285
+ }
286
+
287
+ // ----------------------------------------------------------------------
288
+ // CELL 4 — a rejecting hook fails the turn LOUD, nothing is sent.
289
+ // (Ordered before the refusal cells: a silent-fallback defect at the call
290
+ // site must die here, at its own claim, before an integrity cell can trip.)
291
+ // ----------------------------------------------------------------------
292
+ {
293
+ const h = makeHarness(recordDir);
294
+ const events = await collect(
295
+ backend.streamAcpTurn(
296
+ sonnet,
297
+ userCtx("hook rejection"),
298
+ {
299
+ sessionId: "hooks-reject",
300
+ onPayload: () => Promise.reject(new Error("NONCE-HOOK-EXPLODED")),
301
+ onResponse,
302
+ },
303
+ h.deps,
304
+ ) as Stream,
305
+ );
306
+ const seal = sealed(events)[0];
307
+ assert.ok(
308
+ seal?.type === "error" &&
309
+ String(seal.error?.errorMessage ?? "").includes("NONCE-HOOK-EXPLODED") &&
310
+ h.promptCalls.length === 0,
311
+ "[QK:ACPHOOK-REJECTION-FAILS-LOUD] pi's own runner already swallows extension handler errors, so a rejection " +
312
+ "reaching this seam is a real failure — it must seal the turn loudly with the original message and send " +
313
+ "nothing, never be silently caught into a fallback send",
314
+ );
315
+ }
316
+
317
+ // ----------------------------------------------------------------------
318
+ // CELL 5 — a non-object replacement is refused LOUD, nothing is sent.
319
+ // ----------------------------------------------------------------------
320
+ {
321
+ const h = makeHarness(recordDir);
322
+ const events = await collect(
323
+ backend.streamAcpTurn(
324
+ sonnet,
325
+ userCtx("invalid replacement"),
326
+ { sessionId: "hooks-invalid", onPayload: () => 42, onResponse },
327
+ h.deps,
328
+ ) as Stream,
329
+ );
330
+ const seal = sealed(events)[0];
331
+ assert.ok(
332
+ seal?.type === "error" &&
333
+ String(seal.error?.errorMessage ?? "").includes("non-object replacement") &&
334
+ h.promptCalls.length === 0,
335
+ "[QK:ACPHOOK-INVALID-REPLACEMENT-REFUSED] a replacement that is not a non-null, non-array object must seal " +
336
+ "the turn as a loud error BEFORE anything reaches the wire — silently falling back to the original params " +
337
+ "would hide a broken extension exactly where it thinks it is rewriting requests",
338
+ );
339
+ }
340
+
341
+ // ----------------------------------------------------------------------
342
+ // CELL 6 — a sessionId-tampering replacement is refused LOUD, nothing is sent.
343
+ // ----------------------------------------------------------------------
344
+ {
345
+ const h = makeHarness(recordDir);
346
+ const events = await collect(
347
+ backend.streamAcpTurn(
348
+ sonnet,
349
+ userCtx("tampered sessionId"),
350
+ {
351
+ sessionId: "hooks-tamper",
352
+ onPayload: (payload: any) => ({ sessionId: "SOMEONE-ELSE", prompt: payload.prompt }),
353
+ onResponse,
354
+ },
355
+ h.deps,
356
+ ) as Stream,
357
+ );
358
+ const seal = sealed(events)[0];
359
+ assert.ok(
360
+ seal?.type === "error" &&
361
+ String(seal.error?.errorMessage ?? "").includes("changed the ACP sessionId") &&
362
+ h.promptCalls.length === 0,
363
+ "[QK:ACPHOOK-SESSIONID-TAMPER-REFUSED] a replacement that changes the bootstrapped sessionId must refuse the " +
364
+ "turn before the wire — entwurf cannot truthfully deliver to an ACP session it did not open, and silently " +
365
+ "sending there would corrupt session identity while reporting success",
366
+ );
367
+ }
368
+
369
+ // ----------------------------------------------------------------------
370
+ // CELL 7 — a replacement that empties the prompt is refused LOUD, nothing sent.
371
+ // ----------------------------------------------------------------------
372
+ {
373
+ const h = makeHarness(recordDir);
374
+ const events = await collect(
375
+ backend.streamAcpTurn(
376
+ sonnet,
377
+ userCtx("emptied prompt"),
378
+ {
379
+ sessionId: "hooks-empty",
380
+ onPayload: (payload: any) => ({ sessionId: payload.sessionId, prompt: [] }),
381
+ onResponse,
382
+ },
383
+ h.deps,
384
+ ) as Stream,
385
+ );
386
+ const seal = sealed(events)[0];
387
+ assert.ok(
388
+ seal?.type === "error" &&
389
+ String(seal.error?.errorMessage ?? "").includes("non-empty prompt array") &&
390
+ h.promptCalls.length === 0,
391
+ "[QK:ACPHOOK-EMPTY-PROMPT-REFUSED] a replacement whose prompt is missing, non-array, or empty must refuse " +
392
+ "the turn by name before the wire — this rail already owns a non-empty-prompt invariant on the built " +
393
+ "params, and a replacement must not be able to undo it into an empty ACP send",
394
+ );
395
+ }
396
+
397
+ // ----------------------------------------------------------------------
398
+ // CELL 8 — abort raised WHILE the hook is awaited wins before the wire (NEW turn).
399
+ // ----------------------------------------------------------------------
400
+ {
401
+ const h = makeHarness(recordDir);
402
+ const gate = deferred<undefined>();
403
+ const controller = new AbortController();
404
+ const turn = collect(
405
+ backend.streamAcpTurn(
406
+ sonnet,
407
+ userCtx("abort during hook"),
408
+ {
409
+ sessionId: "hooks-abort",
410
+ signal: controller.signal,
411
+ onPayload: () => gate.promise,
412
+ onResponse,
413
+ },
414
+ h.deps,
415
+ ) as Stream,
416
+ );
417
+ // Let the turn reach the awaited hook, abort, THEN let the hook resolve.
418
+ await new Promise((r) => setTimeout(r, 25));
419
+ controller.abort();
420
+ gate.resolve(undefined);
421
+ const events = await turn;
422
+ const seal = sealed(events)[0];
423
+ assert.ok(
424
+ seal?.type === "error" && seal.reason === "aborted" && h.promptCalls.length === 0,
425
+ "[QK:ACPHOOK-ABORT-DURING-HOOK-WINS] an abort raised while onPayload is awaited must win before the wire " +
426
+ "write — the turn seals aborted and NO prompt is sent, because a slow handler must never turn a cancelled " +
427
+ "turn into a delivered one",
428
+ );
429
+ }
430
+
431
+ // ----------------------------------------------------------------------
432
+ // CELL 9 — abort during the awaited hook wins on the REUSE path too: the
433
+ // resident session's bootstrap prompt is the control, and no SECOND prompt
434
+ // may cross the wire.
435
+ // ----------------------------------------------------------------------
436
+ {
437
+ const h = makeHarness(recordDir);
438
+ const gate = deferred<undefined>();
439
+ const controller = new AbortController();
440
+ let deferReuseHook = false;
441
+ const options: Record<string, unknown> = {
442
+ sessionId: "hooks-reuse-abort",
443
+ signal: controller.signal,
444
+ onPayload: () => (deferReuseHook ? gate.promise : undefined),
445
+ onResponse,
446
+ };
447
+ const first = await collect(
448
+ backend.streamAcpTurn(sonnet, userCtx("NONCE-REUSE-ABORT-ONE"), options, h.deps) as Stream,
449
+ );
450
+ assert.equal(sealed(first)[0]?.type, "done", "turn 1 (new) establishes the resident session");
451
+ assert.equal(h.promptCalls.length, 1, "the bootstrap prompt is the control send");
452
+ deferReuseHook = true;
453
+ const turn = collect(
454
+ backend.streamAcpTurn(
455
+ sonnet,
456
+ reuseCtx("NONCE-REUSE-ABORT-ONE", "NONCE-REUSE-ABORT-TWO"),
457
+ options,
458
+ h.deps,
459
+ ) as Stream,
460
+ );
461
+ await new Promise((r) => setTimeout(r, 25));
462
+ controller.abort();
463
+ gate.resolve(undefined);
464
+ const events = await turn;
465
+ const seal = sealed(events)[0];
466
+ assert.ok(
467
+ seal?.type === "error" && seal.reason === "aborted" && h.promptCalls.length === 1 && h.children.length === 1,
468
+ "[QK:ACPHOOK-REUSE-ABORT-WINS] an abort raised while the REUSE turn's onPayload is awaited must win before " +
469
+ "the wire write — the turn seals aborted and the resident session's bootstrap prompt stays the ONLY send; " +
470
+ "the reuse path owes the same abort-over-slow-handler guarantee as a cold turn",
471
+ );
472
+ }
473
+
474
+ // ----------------------------------------------------------------------
475
+ // CELL 10 — the onResponse exemption, judged across every cell above.
476
+ // ----------------------------------------------------------------------
477
+ assert.equal(
478
+ onResponseCalls,
479
+ 0,
480
+ "[QK:ACPHOOK-ONRESPONSE-NEVER-CALLED] onResponse was registered on every turn above — success, replacement, " +
481
+ "reuse, refusals, rejection, and both abort paths — and must have been invoked ZERO times: ACP has no " +
482
+ "truthful HTTP {status, headers} and its terminal result arrives after the body was consumed, so any " +
483
+ "invocation is fabricated evidence; the exemption is deliberate and this is its behavioral pin",
484
+ );
485
+ } finally {
486
+ rmSync(TMP_EMIT, { recursive: true, force: true });
487
+ try {
488
+ // A leftover EMPTY parent dir reads as IMPURE tree drift in the
489
+ // qualification harness; a concurrent sibling gate's emit keeps it alive
490
+ // and this rmdir simply fails.
491
+ rmdirSync(".tmp-verify");
492
+ } catch {
493
+ // non-empty or already gone — fine either way
494
+ }
495
+ rmSync(recordDir, { recursive: true, force: true });
496
+ }
497
+
498
+ console.log(
499
+ "[check-acp-stream-hooks] ok — before_provider_request receives the exact ACP session/prompt wire params on new " +
500
+ "AND reuse turns and its replacement is honored fail-closed (non-null non-array object, sessionId invariant, " +
501
+ "non-empty prompt array, loud refusal with zero sends otherwise, rejection loud, abort-during-hook wins on " +
502
+ "BOTH paths), and after_provider_response is a deliberate non-HTTP exemption invoked zero times across " +
503
+ "success/error/abort",
504
+ );
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # check-elapsed — total wall-time reporter for the tiered check entrypoints (#70).
3
+ #
4
+ # Usage: bash scripts/check-elapsed.sh <label> <pnpm-script>...
5
+ #
6
+ # Runs each named pnpm script IN ORDER (fail-fast), then prints exactly one
7
+ # summary line — total elapsed wall seconds plus the exit code — on success AND
8
+ # on failure, and exits with the first failing script's code. Tier MEMBERSHIP
9
+ # lives in package.json (the executable SSOT); this wrapper owns only the
10
+ # wall-clock line. It is deliberately not a profiler: no per-step timing, no
11
+ # cache, no history, no thresholds. The core tier's ≤60s acceptance is an
12
+ # operator measurement on the reference host (oracle), never a hard fail here —
13
+ # an arbitrary CI host's variance must not turn wall time into a flaky gate.
14
+ set -u
15
+
16
+ label="$1"
17
+ shift
18
+ start=$(date +%s)
19
+ code=0
20
+ for script in "$@"; do
21
+ pnpm run "$script" || { code=$?; break; }
22
+ done
23
+ elapsed=$(($(date +%s) - start))
24
+ echo "[${label}] total elapsed ${elapsed}s exit ${code}"
25
+ exit "$code"
@@ -5,7 +5,7 @@
5
5
  * runtime contract that the source-shape gate `check-entwurf-v2-surface` cannot:
6
6
  *
7
7
  * - G1a: the server actually BOOTS. start.sh static-imports the whole v2 fence
8
- * graph (entwurf-v2-surface → runner → production → decider/lock/send/spawn/
8
+ * graph (entwurf-v2-surface → runner → production → decider/lock/send/
9
9
  * mailbox/control-rpc) at top level under strip-types; a strip-types-hostile
10
10
  * construct (enum / namespace / parameter property / `import =`) anywhere in
11
11
  * that graph would crash the server at boot. A parseable tools/list (id:2)
@@ -19,7 +19,7 @@
19
19
  * Scope boundary (D1=A안): this gate owns boot + current tool registration/schema.
20
20
  * The broad protocol/negative suite stays in check-bridge/test.sh. Only a
21
21
  * `tools/list` is sent — no `tools/call` — so there is NO lock/filesystem side
22
- * effect and no auth/model is needed; it lives in `pnpm check`.
22
+ * effect and no auth/model is needed; it lives in `pnpm run check:full`.
23
23
  */
24
24
 
25
25
  import { spawn } from "node:child_process";
@@ -175,8 +175,8 @@ async function main(): Promise<void> {
175
175
  ok(`G1b: entwurf_v2 schema requires '${r}'`, required.includes(r), `--- inputSchema ---\n${rawSchema}`);
176
176
  }
177
177
  ok(
178
- "G1b: entwurf_v2 intent enum == {fire-and-forget, owned-outcome}",
179
- setEq(props.intent?.enum, ["fire-and-forget", "owned-outcome"]),
178
+ "G1b: entwurf_v2 intent enum == {fire-and-forget} (owned-outcome withdrawn: visible-first cut)",
179
+ setEq(props.intent?.enum, ["fire-and-forget"]),
180
180
  `--- inputSchema ---\n${rawSchema}`,
181
181
  );
182
182
  ok(
@@ -190,6 +190,30 @@ async function main(): Promise<void> {
190
190
  `--- inputSchema ---\n${rawSchema}`,
191
191
  );
192
192
 
193
+ // G1e — the S1 resume verb, judged on the RUNTIME surface rather than on source wiring. A
194
+ // source-level check would still pass if the registration were deleted and only the import
195
+ // remained; this is the oracle that says the tool is actually registered and its schema has
196
+ // the shape claimed.
197
+ //
198
+ // What it does NOT prove: that a host will ACCEPT that schema. This gate reads the emitted
199
+ // JSON, it does not run a provider's validator — which is exactly why 7-M-fix's unescaped `[`
200
+ // got past every gate here while breaking real sessions. Host/provider acceptance is an
201
+ // uncovered axis, tracked as issue #61, and nothing below should be read as standing in for it.
202
+ const resume = tools.find((t) => t?.name === "entwurf_resume_call");
203
+ ok(
204
+ "[QK:BRIDGEBOOT-RESUME-CALL-REGISTERED] G1e: entwurf_resume_call is registered on the runtime tools/list surface — not merely imported",
205
+ !!resume,
206
+ );
207
+ const resumeSchema = resume?.inputSchema ?? {};
208
+ const rawResume = JSON.stringify(resumeSchema);
209
+ const resumeProps = resumeSchema.properties ?? {};
210
+ const resumeRequired = Array.isArray(resumeSchema.required) ? resumeSchema.required.map(String) : [];
211
+ ok(
212
+ "[QK:BRIDGEBOOT-RESUME-CALL-TARGET-ONLY] G1e: entwurf_resume_call takes EXACTLY one required input, target — a model, task or prompt knob here would be a second way to decide what a resumed citizen is, when the record already decided",
213
+ setEq(Object.keys(resumeProps), ["target"]) && setEq(resumeRequired, ["target"]),
214
+ `--- inputSchema ---\n${rawResume}`,
215
+ );
216
+
193
217
  ok(
194
218
  "G1c: native registration fallback is present",
195
219
  tools.some((t) => t?.name === "entwurf_register_native"),
@@ -199,6 +223,29 @@ async function main(): Promise<void> {
199
223
  !tools.some((t) => ["entwurf", "entwurf_resume", "entwurf_send"].includes(String(t?.name))),
200
224
  );
201
225
 
226
+ // G1f — the public surface is an EXACT set, judged last so the named assertions above
227
+ // keep their own diagnosis. Every check before this one is existential: each names one
228
+ // verb it cares about, so a verb nobody named could be dropped (entwurf_fresh_call was
229
+ // exactly that) or an undecided one added, with the gate still green. "A narrow tool
230
+ // surface is discipline" (AGENTS North Star) is a statement about the WHOLE set, and
231
+ // only equality can carry it. The expected list is written here rather than derived
232
+ // from the server: an oracle computed from the subject proves nothing.
233
+ const publicSurface = tools.map((t) => String(t?.name)).sort();
234
+ const expectedSurface = [
235
+ "entwurf_fresh_call",
236
+ "entwurf_inbox_read",
237
+ "entwurf_peers",
238
+ "entwurf_register_native",
239
+ "entwurf_resume_call",
240
+ "entwurf_self",
241
+ "entwurf_v2",
242
+ ];
243
+ ok(
244
+ "[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",
245
+ publicSurface.length === expectedSurface.length && expectedSurface.every((n, i) => publicSurface[i] === n),
246
+ `--- want ---\n${expectedSurface.join(",")}\n--- got ---\n${publicSurface.join(",")}`,
247
+ );
248
+
202
249
  console.log(`\ncheck-entwurf-bridge-boot: ${passed} checks passed`);
203
250
  }
204
251
 
@@ -21,9 +21,11 @@
21
21
  // - `await import("x")` (dynamic) → INTENDED lazy boundary → NOT followed
22
22
  //
23
23
  // The dynamic-import exemption is deliberate: B-2 makes the pi-coding-agent
24
- // `preflight` a lazy `await import("./entwurf-preflight.ts")` reached ONLY on an
25
- // owned-outcome spawn-bg resume, so it is allowed to pull pi at runtime in that
26
- // one branch. The runtime boot smoke (separate half) is the final authority that
24
+ // `preflight` a lazy `await import("./entwurf-preflight.ts")`, so it is allowed to
25
+ // pull pi at runtime behind that lazy edge. The branch that used to reach it — the
26
+ // owned-outcome resume verdict is gone, which makes the exemption WIDER than its
27
+ // current need, not narrower: nothing on the v2 dispatch path pulls pi now. The
28
+ // runtime boot smoke (separate half) is the final authority that
27
29
  // peers/self/list/mailbox-deliver come up pi-free.
28
30
 
29
31
  import { existsSync, readFileSync } from "node:fs";
@@ -135,8 +137,7 @@ if (violations.length > 0) {
135
137
  }
136
138
  console.error(
137
139
  "\n The entwurf-bridge MCP server must boot pi-free. Move the pi value-import behind a\n" +
138
- " type-only import, a pi-side module the bridge does not reach, or a lazy `await import()`\n" +
139
- " on the owned-outcome resume branch only.",
140
+ " type-only import, a pi-side module the bridge does not reach, or a lazy `await import()`.",
140
141
  );
141
142
  process.exit(1);
142
143
  }
@@ -17,7 +17,7 @@
17
17
  * receiver-stuck backstop the settled-guard preserves).
18
18
  * 5. get_info runtime helper parses/formats cwd/model/idle once for every caller.
19
19
  *
20
- * No model / auth / pi process — only `net.Server` on a tmp socket, so it rides `pnpm check`.
20
+ * No model / auth / pi process — only `net.Server` on a tmp socket, so it rides `pnpm run check:full`.
21
21
  */
22
22
 
23
23
  import assert from "node:assert/strict";
@@ -191,8 +191,9 @@ async function main(): Promise<void> {
191
191
  }
192
192
 
193
193
  // ── 6. formatSenderInfoBlock — THE <sender_info> synthesis (#50 C3) ─────────
194
- // One formatter feeds BOTH rails (live receiver + dormant resume prompt), so
195
- // the exact string shape is a contract, not an implementation detail.
194
+ // ONE formatter, and since the visible-first cut exactly one consumer (the live
195
+ // receiver) — the dormant resume prompt that shared it is gone. The exact string
196
+ // shape stays a contract so a future VISIBLE resume renders through it, not beside it.
196
197
  {
197
198
  const base = {
198
199
  sessionId: "20260613T091000-98363c",