@junghanacs/entwurf 0.23.0 → 0.24.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 (102) hide show
  1. package/AGENTS.md +3 -1
  2. package/BASELINE.md +2 -0
  3. package/CHANGELOG.md +307 -0
  4. package/FAQ.md +47 -0
  5. package/README.md +5 -3
  6. package/VERIFY.md +43 -2
  7. package/demo/README.md +1 -1
  8. package/docs/acp-backend-rail.md +97 -18
  9. package/docs/setup-clean-host.md +8 -7
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +33 -1
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +19 -5
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/callback-env.js +68 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +54 -22
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +7 -3
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +13 -4
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +19 -10
  21. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +35 -42
  22. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  23. package/mcp/entwurf-bridge/src/index.ts +41 -1
  24. package/mcp/entwurf-bridge/test.sh +2 -2
  25. package/package.json +15 -13
  26. package/pi-extensions/entwurf-control.ts +56 -0
  27. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  28. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  29. package/pi-extensions/lib/acp/backend.ts +20 -10
  30. package/pi-extensions/lib/acp/context.ts +30 -10
  31. package/pi-extensions/lib/acp/event-mapper.ts +5 -5
  32. package/pi-extensions/lib/acp/session-store.ts +2 -2
  33. package/pi-extensions/lib/acp/tool-surface.ts +2 -1
  34. package/pi-extensions/lib/callback-env.ts +78 -0
  35. package/pi-extensions/lib/compaction-send-guard.ts +4 -1
  36. package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
  37. package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
  38. package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
  39. package/pi-extensions/lib/fresh-call-composition.ts +56 -22
  40. package/pi-extensions/lib/herdr-fresh-call.ts +12 -4
  41. package/pi-extensions/lib/herdr-placement.ts +13 -4
  42. package/pi-extensions/lib/mux-fresh-call.ts +23 -8
  43. package/pi-extensions/meta-bridge-omp.ts +35 -48
  44. package/run.sh +327 -246
  45. package/scripts/check-acp-backend-preflight.ts +117 -9
  46. package/scripts/check-acp-carrier-augment.ts +6 -4
  47. package/scripts/check-acp-event-mapper.ts +17 -6
  48. package/scripts/check-acp-prompt-builder.ts +17 -9
  49. package/scripts/check-acp-prompt-lifecycle.ts +23 -6
  50. package/scripts/check-acp-session-reuse.ts +64 -51
  51. package/scripts/check-acp-session-store.ts +31 -8
  52. package/scripts/check-acp-stop-reason.ts +20 -2
  53. package/scripts/check-acp-stream-hooks.ts +22 -5
  54. package/scripts/check-acp-usage-accounting.ts +42 -18
  55. package/scripts/check-entwurf-bridge-boot.ts +16 -1
  56. package/scripts/check-fresh-call-dispatch.ts +4 -5
  57. package/scripts/check-gate-qualification.ts +45 -5
  58. package/scripts/check-herdr-activation.ts +73 -0
  59. package/scripts/check-herdr-fresh-call.ts +22 -6
  60. package/scripts/check-herdr-sandbox.ts +14 -2
  61. package/scripts/check-install-container.sh +1 -1
  62. package/scripts/check-pi-launch.ts +361 -0
  63. package/scripts/check-release-gate-outcomes.ts +7 -0
  64. package/scripts/inventory-verification-surface.ts +86 -10
  65. package/scripts/lib/mutation-qualify.ts +1 -1
  66. package/scripts/mutants/acp-backend-preflight.json +20 -0
  67. package/scripts/mutants/bridge-boot-resume.json +26 -0
  68. package/scripts/mutants/herdr-activation.json +16 -0
  69. package/scripts/mutants/herdr-fresh-call.json +11 -0
  70. package/scripts/mutants/herdr-plugin-build.json +1 -1
  71. package/scripts/mutants/mux-fresh-call.json +21 -8
  72. package/scripts/mutants/omp-fresh.json +5 -9
  73. package/scripts/mutants/pack-install.json +3 -3
  74. package/scripts/mutants/pi-launch.json +113 -0
  75. package/scripts/mutants/resume-args.json +6 -6
  76. package/scripts/mutants/v2-surface.json +2 -2
  77. package/scripts/mutants/v2-visible-resume.json +17 -17
  78. package/scripts/probe-bridge-command.ts +1 -0
  79. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  80. package/scripts/smoke-acp-session-reuse-live.ts +23 -5
  81. package/scripts/smoke-agy-install-state.sh +2 -2
  82. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  83. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  84. package/scripts/smoke-herdr-fresh-call-live.ts +56 -28
  85. package/scripts/smoke-herdr-raw-install-live.sh +877 -0
  86. package/scripts/smoke-omp-fresh-live.ts +4 -2
  87. package/scripts/smoke-omp-mcp-state.sh +1 -1
  88. package/scripts/smoke-pi-provider-state.sh +2 -2
  89. package/scripts/tsconfig.json +5 -0
  90. package/scripts/check-entwurf-facts.ts +0 -391
  91. package/scripts/check-entwurf-resume-args.ts +0 -122
  92. package/scripts/check-entwurf-v2-decider.ts +0 -657
  93. package/scripts/check-entwurf-v2-lock.ts +0 -371
  94. package/scripts/check-entwurf-v2-mailbox.ts +0 -261
  95. package/scripts/check-entwurf-v2-matrix.ts +0 -401
  96. package/scripts/check-entwurf-v2-native-push.ts +0 -221
  97. package/scripts/check-entwurf-v2-release.ts +0 -151
  98. package/scripts/check-entwurf-v2-runner.ts +0 -448
  99. package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
  100. package/scripts/check-entwurf-v2-send.ts +0 -458
  101. package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
  102. package/scripts/check-socket-discovery.ts +0 -401
@@ -16,23 +16,42 @@
16
16
 
17
17
  import { strict as assert } from "node:assert";
18
18
  import { execFileSync } from "node:child_process";
19
- import { readFileSync, rmSync } from "node:fs";
20
- import { resolve } from "node:path";
19
+ import { readdirSync, readFileSync, rmSync } from "node:fs";
20
+ import { join, resolve } from "node:path";
21
21
  import { pathToFileURL } from "node:url";
22
- import type { Api, AssistantMessageEvent, Context, Model } from "@earendil-works/pi-ai";
22
+ import type { Api, AssistantMessageEvent, Model, TranscriptContext } from "@earendil-works/pi-ai";
23
+ import { normalizeContext } from "@earendil-works/pi-ai";
23
24
 
24
25
  const model = { id: "claude-sonnet-5" } as unknown as Model<Api>;
25
26
 
26
27
  // Declared tools exclude `read`, but the Claude child always exposes Read →
27
28
  // declared != actual → the preflight must reject before spawning.
28
- const context: Context = {
29
+ //
30
+ // Built through `normalizeContext` — NOT as a `Context` literal. Since pi 0.86
31
+ // a custom provider is handed a TranscriptContext (model-runtime.ts calls
32
+ // `normalizeContext(context)` before `provider.streamSimple`), so `context.tools`
33
+ // is ALWAYS undefined on the provider path and the declared surface lives in the
34
+ // leading system message's `toolsAdded`. Feeding a 0.85-shaped literal straight
35
+ // into streamShellAcp would bypass the fold and let this gate stay green over a
36
+ // preflight that can no longer see any tools at all.
37
+ const context: TranscriptContext = normalizeContext({
29
38
  messages: [{ role: "user", content: "hi", timestamp: 0 }],
30
39
  tools: [
31
40
  { name: "bash", description: "", parameters: {} as never },
32
41
  { name: "edit", description: "", parameters: {} as never },
33
42
  { name: "write", description: "", parameters: {} as never },
34
43
  ],
35
- };
44
+ });
45
+
46
+ // Premise guard: the fixture must really carry its tools as transcript state. If
47
+ // a future pi stops folding, this fails loudly instead of leaving the exclude-
48
+ // tools assertion below testing an empty surface for the wrong reason.
49
+ assert.equal(context.messages[0].role, "system", "normalizeContext folded the tools into a leading system message");
50
+ assert.deepEqual(
51
+ (context.messages[0] as { toolsAdded?: { name: string }[] }).toolsAdded?.map((t) => t.name),
52
+ ["bash", "edit", "write"],
53
+ "the declared surface rides the system message's toolsAdded",
54
+ );
36
55
 
37
56
  const TMP_EMIT = ".tmp-verify/acp-backend-preflight";
38
57
  rmSync(TMP_EMIT, { recursive: true, force: true });
@@ -44,7 +63,7 @@ try {
44
63
  const mod = (await import(backendUrl)) as {
45
64
  streamShellAcp: (
46
65
  m: Model<Api>,
47
- c: Context,
66
+ c: TranscriptContext,
48
67
  ) => AsyncIterable<AssistantMessageEvent> & {
49
68
  result: () => Promise<{ stopReason: string; errorMessage?: string }>;
50
69
  };
@@ -101,8 +120,25 @@ try {
101
120
  for await (const ev of stream) events.push(ev);
102
121
 
103
122
  const types = events.map((e) => e.type);
104
- assert.ok(!types.includes("done"), `a tool-surface lie must NOT complete as done (got ${types.join(",")})`);
105
123
  const errorEvent = events.find((e): e is Extract<AssistantMessageEvent, { type: "error" }> => e.type === "error");
124
+
125
+ // The whole claim in ONE assertion, placed BEFORE the diagnostic breakdown so
126
+ // a mutant always dies here and carries the signature. The breakdown below
127
+ // still runs on a green tree and says WHICH half broke.
128
+ const preflightFired =
129
+ !types.includes("done") &&
130
+ /cannot honor --exclude-tools \(read\)/.test(String(errorEvent?.error.errorMessage ?? ""));
131
+ assert.ok(
132
+ preflightFired,
133
+ "[QK:ACP-PREFLIGHT-REPLAYS-TRANSCRIPT-TOOLS] the runtime tool-surface preflight must read the active tools by " +
134
+ "REPLAYING the transcript's system messages (`getCurrentTools(context.messages)`), which is where pi 0.86 puts " +
135
+ "the declared surface. Reading a `context.tools` field instead yields undefined on every 0.86 provider call; " +
136
+ "with a fallback to the full builtin set that makes assertExcludeToolsHonored unfireable — the turn reaches a " +
137
+ "spawn and the operator is told a tool is excluded while the backend can still run it. " +
138
+ `Got events [${types.join(",")}], error=${JSON.stringify(errorEvent?.error.errorMessage ?? null)}`,
139
+ );
140
+
141
+ assert.ok(!types.includes("done"), `a tool-surface lie must NOT complete as done (got ${types.join(",")})`);
106
142
  assert.ok(errorEvent, `expected an error event (got ${types.join(",")})`);
107
143
  assert.equal(errorEvent.reason, "error", "tool-surface divergence is a hard error, not aborted");
108
144
  assert.equal(errorEvent.error.stopReason, "error", "final message stopReason must be error");
@@ -123,8 +159,80 @@ try {
123
159
  rmSync(TMP_EMIT, { recursive: true, force: true });
124
160
  }
125
161
 
162
+ // ---------------------------------------------------------------------------
163
+ // Provider-path fixture sweep — the cast that got past typecheck twice
164
+ // ---------------------------------------------------------------------------
165
+ // The behavioral cells above prove ONE gate feeds the 0.86 shape. This proves the
166
+ // others cannot quietly stop doing so. When the pi 0.86 pin landed, the branded
167
+ // `TranscriptContext` named every gate that built a provider-path fixture as a
168
+ // `Context` LITERAL — and named none of the four that wrote `as Context`, because a
169
+ // cast is exactly the operation that silences the brand. Those four then failed in
170
+ // the full floor with `'error' !== 'done'`, a message that says nothing about the
171
+ // cause. A fixture cast to `Context` declares no tools, `getCurrentTools` replays
172
+ // none, and every turn seals as an exclude-tools rejection before the prompt is
173
+ // sent — which reads like broken streaming and is actually a stale fixture.
174
+ //
175
+ // So: no provider-path source may cast to `Context` at all. Static by nature —
176
+ // there is no production subject here and nothing to drive, the claim IS the
177
+ // absence of a token in a file set — so this carries no mutant. Its kill-proof is
178
+ // the sweep itself: delete the assertion and nothing else changes, which is true of
179
+ // any absence check and is why it lives beside the capability it protects rather
180
+ // than in a lane of its own.
181
+ {
182
+ // The SMOKES are in scope, not only the deterministic gates. Leaving them out is how this
183
+ // guard missed `smoke-acp-session-reuse-live.ts` in the 0.23.2 release gate: the gates it
184
+ // did sweep were all green, and the one provider-path fixture it could not see failed an
185
+ // hour into a LIVE run instead.
186
+ const swept = [
187
+ ...readdirSync(resolve("scripts"))
188
+ .filter((f) => (f.startsWith("check-acp-") || f.startsWith("smoke-")) && f.endsWith(".ts"))
189
+ .map((f) => join("scripts", f)),
190
+ ...readdirSync(resolve("pi-extensions", "lib"), { recursive: true, encoding: "utf8" })
191
+ .filter((f) => f.endsWith(".test.ts"))
192
+ .map((f) => join("pi-extensions", "lib", f)),
193
+ ];
194
+ // Guard the sweep: a glob that matches nothing passes vacuously and says so to nobody.
195
+ assert.ok(swept.length >= 10, `provider-path sweep matched only ${swept.length} files — the globs stopped resolving`);
196
+
197
+ // BOTH spellings, because the hazard is the TYPE, not one syntax for reaching it. The four
198
+ // gates found in the full floor wrote `as Context`; the live smoke found in the release gate
199
+ // wrote `const turn1: Context = {…}`. A guard that names one spelling only teaches the next
200
+ // author which spelling to use.
201
+ //
202
+ // `Context[...]` and `Context<...>` are NOT the hazard and are excluded by the trailing
203
+ // lookahead: `messages: Context["messages"]` is the legitimate INPUT to `normalizeContext`,
204
+ // which takes a real Context by contract. Flagging it would push authors to re-type pi's own
205
+ // message array by hand, which is a worse fixture than the one this rule exists to prevent.
206
+ const RAW_CONTEXT = /(?:\bas\s+Context|:\s*Context)\b(?![[<])/;
207
+ const offenders: string[] = [];
208
+ for (const file of swept) {
209
+ readFileSync(file, "utf8")
210
+ .split("\n")
211
+ .forEach((line, i) => {
212
+ // Prose about the rule is not a violation of it.
213
+ if (line.trimStart().startsWith("//") || line.trimStart().startsWith("*")) return;
214
+ if (RAW_CONTEXT.test(line)) offenders.push(`${file}:${i + 1}: ${line.trim()}`);
215
+ });
216
+ }
217
+ assert.deepEqual(
218
+ offenders,
219
+ [],
220
+ "[QK:ACP-FIXTURE-NO-CONTEXT-CAST] a provider-path fixture may not be typed as the raw `Context` — neither by " +
221
+ "annotation nor by cast. Since pi 0.86 a custom provider receives a branded TranscriptContext, and the brand is " +
222
+ "the only thing that names a stale fixture at typecheck time; either spelling silences it, so the fixture " +
223
+ "declares no tools, the exclude-tools preflight rejects every turn before the prompt is sent, and the failure " +
224
+ "surfaces as `'error' !== 'done'` with nothing pointing at the cause. Build it with " +
225
+ "`normalizeContext({ tools, messages })` instead. Found:\n" +
226
+ offenders.join("\n"),
227
+ );
228
+ }
229
+
126
230
  console.log(
127
- "[check-acp-backend-preflight] ok — streamShellAcp runs assertExcludeToolsHonored before spawn; a declared-vs-actual " +
231
+ "[check-acp-backend-preflight] ok — streamShellAcp replays the 0.86 transcript's system messages for the active tool " +
232
+ "surface and runs assertExcludeToolsHonored before spawn; a declared-vs-actual " +
128
233
  "tool-surface lie fails fast into the stream as an error event (no backend launched, no done); " +
129
- "actionableAcpBackendHint (A-c) classifies a context-window 400 into an actionable hint without misclassifying unrelated failures",
234
+ "actionableAcpBackendHint (A-c) classifies a context-window 400 into an actionable hint without misclassifying " +
235
+ "unrelated failures; and no provider-path fixture in the acp gates, the smokes or the lib tests is typed as the raw " +
236
+ "`Context` (annotation or cast), so the " +
237
+ "TranscriptContext brand still names a stale one at typecheck time",
130
238
  );
@@ -19,7 +19,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
19
19
  import { tmpdir } from "node:os";
20
20
  import { join } from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
- import type { Context } from "@earendil-works/pi-ai";
22
+ import { normalizeContext } from "@earendil-works/pi-ai";
23
23
  import {
24
24
  buildPiContextAugment,
25
25
  prependNewPromptAugment,
@@ -163,8 +163,10 @@ const BRIDGE_MARK = "operating through entwurf";
163
163
  // ===========================================================================
164
164
  // helper: a context whose first user message is `firstUser`
165
165
  // ===========================================================================
166
- function ctxWith(firstUser: string): Context {
167
- return {
166
+ // Built through `normalizeContext` (pi 0.86 provider-path shape): a custom
167
+ // provider receives a TranscriptContext, never a raw Context.
168
+ function ctxWith(firstUser: string) {
169
+ return normalizeContext({
168
170
  messages: [
169
171
  { role: "user", content: firstUser, timestamp: 0 },
170
172
  {
@@ -186,7 +188,7 @@ function ctxWith(firstUser: string): Context {
186
188
  },
187
189
  { role: "user", content: "latest turn", timestamp: 0 },
188
190
  ],
189
- };
191
+ });
190
192
  }
191
193
 
192
194
  // ===========================================================================
@@ -11,8 +11,8 @@
11
11
  // Pure/deterministic — IN pnpm check.
12
12
 
13
13
  import { strict as assert } from "node:assert";
14
- import type { AssistantMessageEvent, Context } from "@earendil-works/pi-ai";
15
- import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
14
+ import type { AssistantMessageEvent } from "@earendil-works/pi-ai";
15
+ import { createAssistantMessageEventStream, normalizeContext } from "@earendil-works/pi-ai";
16
16
  import { contextToAcpPrompt, contextTranscript } from "../pi-extensions/lib/acp/context.ts";
17
17
  import {
18
18
  type AcpPiStreamState,
@@ -183,7 +183,14 @@ const deltas = (events: AssistantMessageEvent[]): string[] =>
183
183
  // 6) context conversion — transcript passthrough, no systemPrompt, single block
184
184
  // ---------------------------------------------------------------------------
185
185
  {
186
- const context: Context = {
186
+ // `normalizeContext` is the 0.86 provider-path shape: the systemPrompt and the
187
+ // declared tools are FOLDED INTO a leading `role:"system"` message rather than
188
+ // staying on their own Context fields. That is exactly why the no-leak
189
+ // assertion below matters now — the secret is INSIDE `messages`, so the only
190
+ // thing keeping it out of the ACP child's transcript is renderMessage's
191
+ // explicit `case "system"` drop. A hand-built Context literal here would have
192
+ // proved nothing: the transcript loop never saw those fields to begin with.
193
+ const context = normalizeContext({
187
194
  systemPrompt: "SECRET-SYSTEM-PROMPT-DO-NOT-LEAK",
188
195
  tools: [{ name: "x", description: "d", parameters: {} as never }],
189
196
  messages: [
@@ -226,9 +233,13 @@ const deltas = (events: AssistantMessageEvent[]): string[] =>
226
233
  timestamp: 0,
227
234
  },
228
235
  ],
229
- };
236
+ });
237
+ // The fixture really does carry the prompt as a transcript message — assert the
238
+ // premise so a future pi that stops folding cannot turn the no-leak assertion
239
+ // into a vacuous one.
240
+ assert.equal(context.messages[0].role, "system", "normalizeContext put the systemPrompt in a leading system message");
230
241
  const transcript = contextTranscript(context);
231
- assert.ok(!transcript.includes("SECRET-SYSTEM-PROMPT"), "transcript must NOT leak context.systemPrompt");
242
+ assert.ok(!transcript.includes("SECRET-SYSTEM-PROMPT"), "transcript must NOT leak the folded system prompt");
232
243
  assert.ok(!transcript.includes("private reasoning"), "transcript must NOT include assistant thinking");
233
244
  assert.match(transcript, /User: first question/, "includes prior user turn");
234
245
  assert.match(transcript, /Assistant: an answer/, "includes assistant text");
@@ -243,7 +254,7 @@ const deltas = (events: AssistantMessageEvent[]): string[] =>
243
254
  assert.equal(prompt[0].type, "text", "prompt block is text");
244
255
  assert.equal(prompt[0].text, transcript, "prompt text equals the transcript");
245
256
 
246
- assert.deepEqual(contextToAcpPrompt({ messages: [] }), [], "empty history → empty prompt array");
257
+ assert.deepEqual(contextToAcpPrompt(normalizeContext({ messages: [] })), [], "empty history → empty prompt array");
247
258
  }
248
259
 
249
260
  console.log(
@@ -16,7 +16,8 @@
16
16
  // pure scope lock over it. Pure/deterministic — IN pnpm check.
17
17
 
18
18
  import { strict as assert } from "node:assert";
19
- import type { Context } from "@earendil-works/pi-ai";
19
+ import type { TranscriptContext } from "@earendil-works/pi-ai";
20
+ import { normalizeContext } from "@earendil-works/pi-ai";
20
21
  import {
21
22
  type AcpBootstrapPath,
22
23
  buildAcpPrompt,
@@ -28,8 +29,15 @@ const REUSE_PATHS: AcpBootstrapPath[] = ["reuse", "resume", "load"];
28
29
 
29
30
  // A multi-turn context: a prior turn, an assistant answer, the real latest user
30
31
  // turn (with an image), and a SessionStart hook user-message appended AFTER it.
31
- function multiTurn(): Context {
32
- return {
32
+ //
33
+ // Built through `normalizeContext` (pi-ai) because that is what pi 0.86 hands a
34
+ // custom provider: `systemPrompt`/`tools` are folded into a LEADING
35
+ // `role:"system"` message, never their own fields. A hand-built `Context`
36
+ // literal here would test a shape production no longer receives — the
37
+ // systemPrompt-leak assertions below only mean something when the prompt is
38
+ // actually inside `messages`.
39
+ function multiTurn(): TranscriptContext {
40
+ return normalizeContext({
33
41
  systemPrompt: "SECRET-SYSTEM-PROMPT",
34
42
  tools: [{ name: "x", description: "d", parameters: {} as never }],
35
43
  messages: [
@@ -63,7 +71,7 @@ function multiTurn(): Context {
63
71
  // must NOT pick this as the latest user delta.
64
72
  { role: "user", content: "device=thinkpad time_kst=20260618T160000", timestamp: 0 },
65
73
  ],
66
- };
74
+ });
67
75
  }
68
76
 
69
77
  // ---------------------------------------------------------------------------
@@ -120,12 +128,12 @@ for (const path of REUSE_PATHS) {
120
128
  // 4) no assistant yet → first user of the whole context is the delta
121
129
  // ---------------------------------------------------------------------------
122
130
  {
123
- const ctx: Context = {
131
+ const ctx = normalizeContext({
124
132
  messages: [
125
133
  { role: "user", content: "only question", timestamp: 0 },
126
134
  { role: "user", content: "device=thinkpad time_kst=x", timestamp: 0 },
127
135
  ],
128
- };
136
+ });
129
137
  const delta = latestUserDelta(ctx);
130
138
  assert.equal(delta.length, 1, "delta exists when there is a user turn");
131
139
  assert.equal(delta[0].text, "only question", "first user wins (hook message skipped) even with no assistant");
@@ -135,7 +143,7 @@ for (const path of REUSE_PATHS) {
135
143
  // 5) trailing message is assistant (no user after it) → empty delta
136
144
  // ---------------------------------------------------------------------------
137
145
  {
138
- const ctx: Context = {
146
+ const ctx = normalizeContext({
139
147
  messages: [
140
148
  { role: "user", content: "q", timestamp: 0 },
141
149
  {
@@ -156,7 +164,7 @@ for (const path of REUSE_PATHS) {
156
164
  timestamp: 0,
157
165
  },
158
166
  ],
159
- };
167
+ });
160
168
  assert.deepEqual(latestUserDelta(ctx), [], "no user after the last assistant → empty delta");
161
169
  }
162
170
 
@@ -164,7 +172,7 @@ for (const path of REUSE_PATHS) {
164
172
  // 6) empty context → empty for every path
165
173
  // ---------------------------------------------------------------------------
166
174
  {
167
- const empty: Context = { messages: [] };
175
+ const empty = normalizeContext({ messages: [] });
168
176
  assert.deepEqual(buildAcpPrompt(empty, "new"), [], "empty context → empty new prompt");
169
177
  for (const path of REUSE_PATHS) {
170
178
  assert.deepEqual(buildAcpPrompt(empty, path), [], `empty context → empty ${path} prompt`);
@@ -36,7 +36,8 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs
36
36
  import { tmpdir } from "node:os";
37
37
  import { resolve } from "node:path";
38
38
  import { pathToFileURL } from "node:url";
39
- import type { Api, AssistantMessageEvent, Context, Message, Model } from "@earendil-works/pi-ai";
39
+ import type { Api, AssistantMessageEvent, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
40
+ import { normalizeContext } from "@earendil-works/pi-ai";
40
41
  import { isRetryableAssistantError } from "@earendil-works/pi-ai/compat";
41
42
 
42
43
  const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
@@ -215,7 +216,7 @@ function makeHarness(recordDir: string) {
215
216
  /** Start a turn and collect its events in the background. */
216
217
  function startTurn(
217
218
  backend: any,
218
- context: Context,
219
+ context: TranscriptContext,
219
220
  options: Record<string, unknown>,
220
221
  deps: unknown,
221
222
  ): { events: AssistantMessageEvent[]; done: Promise<void> } {
@@ -230,11 +231,27 @@ function startTurn(
230
231
  const sealed = (events: AssistantMessageEvent[]) =>
231
232
  events.filter((e) => e.type === "done" || e.type === "error") as any[];
232
233
 
233
- const userCtx = (text: string): Context => ({ messages: [{ role: "user", content: text, timestamp: 0 }] }) as Context;
234
+ // The pi built-ins the Claude child also exposes natively. pi declares its tool surface on
235
+ // every turn, and since 0.86 that declaration reaches a provider as the leading system
236
+ // message's `toolsAdded` (`normalizeContext`), which backend.ts replays for the exclude-tools
237
+ // truthfulness preflight. A fixture that declares NO tools is therefore not "a turn with the
238
+ // defaults" — it is a turn where the operator excluded everything, and the preflight rejects it
239
+ // before the prompt is ever sent. So every provider-path context here is built through
240
+ // `normalizeContext`, never cast `as Context`: the cast is exactly what hid this shape from
241
+ // typecheck when the 0.86 pin landed.
242
+ const PI_DECLARED_TOOLS = ["read", "bash", "edit", "write"].map((name) => ({
243
+ name,
244
+ description: "",
245
+ parameters: {} as never,
246
+ }));
247
+
248
+ const userCtx = (text: string): TranscriptContext =>
249
+ normalizeContext({ tools: PI_DECLARED_TOOLS, messages: [{ role: "user", content: text, timestamp: 0 }] });
234
250
 
235
251
  /** A reuse-shaped context: prior user, assistant, new user. */
236
- function reuseCtx(prior: string, latest: string): Context {
237
- return {
252
+ function reuseCtx(prior: string, latest: string): TranscriptContext {
253
+ return normalizeContext({
254
+ tools: PI_DECLARED_TOOLS,
238
255
  messages: [
239
256
  { role: "user", content: prior, timestamp: 0 },
240
257
  {
@@ -256,7 +273,7 @@ function reuseCtx(prior: string, latest: string): Context {
256
273
  } as unknown as Message,
257
274
  { role: "user", content: latest, timestamp: 0 },
258
275
  ],
259
- } as Context;
276
+ });
260
277
  }
261
278
 
262
279
  const TMP_EMIT = ".tmp-verify/acp-prompt-lifecycle";
@@ -30,7 +30,26 @@ import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync, rmSync,
30
30
  import { tmpdir } from "node:os";
31
31
  import { resolve } from "node:path";
32
32
  import { pathToFileURL } from "node:url";
33
- import type { Api, AssistantMessageEvent, Context, Message, Model } from "@earendil-works/pi-ai";
33
+ import type { Api, AssistantMessageEvent, Context, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
34
+ import { normalizeContext } from "@earendil-works/pi-ai";
35
+
36
+ // The pi built-ins the Claude child also exposes natively. pi declares its tool
37
+ // surface on every turn; since 0.86 that declaration reaches a provider as the
38
+ // leading system message's `toolsAdded` (normalizeContext), and backend.ts
39
+ // replays it for the exclude-tools truthfulness preflight. A fixture that
40
+ // declares NO tools is therefore not "a turn with the defaults" — it is a turn
41
+ // where the operator excluded everything, and the preflight rejects it before
42
+ // any spawn. So every provider-path context in this gate is built through `tctx`.
43
+ const PI_DECLARED_TOOLS = ["read", "bash", "edit", "write"].map((name) => ({
44
+ name,
45
+ description: "",
46
+ parameters: {} as never,
47
+ }));
48
+
49
+ /** A provider-path context in the 0.86 shape: tools folded into a leading system message. */
50
+ function tctx(messages: Context["messages"]): TranscriptContext {
51
+ return normalizeContext({ tools: PI_DECLARED_TOOLS, messages });
52
+ }
34
53
 
35
54
  const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
36
55
  const opus = { id: "claude-opus-5" } as unknown as Model<Api>;
@@ -222,30 +241,28 @@ function mkAssistant(content: Array<{ type: "text"; text: string; textSignature?
222
241
  }
223
242
 
224
243
  // A multi-turn (reuse-shaped) context: a prior user, an assistant, a new user.
225
- function reuseCtx(prior: string, latest: string): Context {
226
- return {
227
- messages: [
228
- { role: "user", content: prior, timestamp: 0 },
229
- {
230
- role: "assistant",
231
- content: [{ type: "text", text: "ok" }],
232
- api: "x",
233
- provider: "x",
234
- model: "x",
235
- usage: {
236
- input: 0,
237
- output: 0,
238
- cacheRead: 0,
239
- cacheWrite: 0,
240
- totalTokens: 0,
241
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
242
- },
243
- stopReason: "stop",
244
- timestamp: 0,
244
+ function reuseCtx(prior: string, latest: string): TranscriptContext {
245
+ return tctx([
246
+ { role: "user", content: prior, timestamp: 0 },
247
+ {
248
+ role: "assistant",
249
+ content: [{ type: "text", text: "ok" }],
250
+ api: "x",
251
+ provider: "x",
252
+ model: "x",
253
+ usage: {
254
+ input: 0,
255
+ output: 0,
256
+ cacheRead: 0,
257
+ cacheWrite: 0,
258
+ totalTokens: 0,
259
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
245
260
  },
246
- { role: "user", content: latest, timestamp: 0 },
247
- ],
248
- };
261
+ stopReason: "stop",
262
+ timestamp: 0,
263
+ },
264
+ { role: "user", content: latest, timestamp: 0 },
265
+ ]);
249
266
  }
250
267
 
251
268
  const TMP_EMIT = ".tmp-verify/acp-session-reuse";
@@ -282,7 +299,7 @@ try {
282
299
 
283
300
  // Pre-write a COMPATIBLE persisted record at a DIFFERENT acp id — if the
284
301
  // backend (wrongly) resumed it, turn 1 would prompt "OLD-RESUME-ID".
285
- const turn1Ctx: Context = { messages: [{ role: "user", content: "remember NONCE-AAA", timestamp: 0 }] };
302
+ const turn1Ctx = tctx([{ role: "user", content: "remember NONCE-AAA", timestamp: 0 }]);
286
303
  store.writeSessionRecord(
287
304
  store.buildSessionRecord(
288
305
  {
@@ -350,7 +367,7 @@ try {
350
367
  await collect(
351
368
  backend.streamAcpTurn(
352
369
  sonnet,
353
- { messages: [{ role: "user", content: "first", timestamp: 0 }] },
370
+ tctx([{ role: "user", content: "first", timestamp: 0 }]),
354
371
  { sessionId: "gate-B" },
355
372
  h.deps,
356
373
  ) as Stream,
@@ -384,7 +401,7 @@ try {
384
401
  const h = makeHarness(recordDir);
385
402
  let release!: () => void;
386
403
  h.setBlock(new Promise<void>((r) => (release = r)));
387
- const ctx: Context = { messages: [{ role: "user", content: "only", timestamp: 0 }] };
404
+ const ctx = tctx([{ role: "user", content: "only", timestamp: 0 }]);
388
405
  // turn A: first turn, blocks in prompt (still in flight, not yet retained).
389
406
  const aDone = collect(backend.streamAcpTurn(sonnet, ctx, { sessionId: "gate-D" }, h.deps) as Stream);
390
407
  await new Promise((r) => setTimeout(r, 20)); // let turn A claim the key + spawn
@@ -410,7 +427,7 @@ try {
410
427
  await collect(
411
428
  backend.streamAcpTurn(
412
429
  sonnet,
413
- { messages: [{ role: "user", content: "x", timestamp: 0 }] },
430
+ tctx([{ role: "user", content: "x", timestamp: 0 }]),
414
431
  { sessionId: "gate-E1", cwd: "/w1" },
415
432
  h.deps,
416
433
  ) as Stream,
@@ -421,7 +438,7 @@ try {
421
438
  await collect(
422
439
  backend.streamAcpTurn(
423
440
  sonnet,
424
- { messages: [{ role: "user", content: "y", timestamp: 0 }] },
441
+ tctx([{ role: "user", content: "y", timestamp: 0 }]),
425
442
  { sessionId: "gate-E1", cwd: "/w2" },
426
443
  h.deps,
427
444
  ) as Stream,
@@ -434,7 +451,7 @@ try {
434
451
  await collect(
435
452
  backend.streamAcpTurn(
436
453
  sonnet,
437
- { messages: [{ role: "user", content: "x", timestamp: 0 }] },
454
+ tctx([{ role: "user", content: "x", timestamp: 0 }]),
438
455
  { sessionId: "gate-E2" },
439
456
  h2.deps,
440
457
  ) as Stream,
@@ -481,7 +498,7 @@ try {
481
498
  const t1 = await collect(
482
499
  backend.streamAcpTurn(
483
500
  sonnet,
484
- { messages: [{ role: "user", content: "hi NONCE-F", timestamp: 0 }] },
501
+ tctx([{ role: "user", content: "hi NONCE-F", timestamp: 0 }]),
485
502
  { sessionId: "gate-F" },
486
503
  h.deps,
487
504
  ) as Stream,
@@ -513,15 +530,13 @@ try {
513
530
 
514
531
  // F4: a `new` full-transcript rebuild DROPS a lifecycle-marked assistant block
515
532
  // (context.ts filter) while keeping the real transcript text.
516
- const ctxWithNotice: Context = {
517
- messages: [
518
- { role: "user", content: "real user line", timestamp: 0 },
519
- mkAssistant([
520
- { type: "text", text: "\n[acp: session ready model=claude-sonnet-5]\n", textSignature: MARKER },
521
- { type: "text", text: "real assistant line" },
522
- ]),
523
- ],
524
- };
533
+ const ctxWithNotice = tctx([
534
+ { role: "user", content: "real user line", timestamp: 0 },
535
+ mkAssistant([
536
+ { type: "text", text: "\n[acp: session ready model=claude-sonnet-5]\n", textSignature: MARKER },
537
+ { type: "text", text: "real assistant line" },
538
+ ]),
539
+ ]);
525
540
  const built = ctxMod
526
541
  .buildAcpPrompt(ctxWithNotice, "new")
527
542
  .map((b: { text: string }) => b.text)
@@ -534,12 +549,10 @@ try {
534
549
 
535
550
  // F5: a lifecycle-marked block does NOT change the reuse-compat signature —
536
551
  // the per-message signature is identical with and without the notice.
537
- const ctxNoNotice: Context = {
538
- messages: [
539
- { role: "user", content: "real user line", timestamp: 0 },
540
- mkAssistant([{ type: "text", text: "real assistant line" }]),
541
- ],
542
- };
552
+ const ctxNoNotice = tctx([
553
+ { role: "user", content: "real user line", timestamp: 0 },
554
+ mkAssistant([{ type: "text", text: "real assistant line" }]),
555
+ ]);
543
556
  assert.deepEqual(
544
557
  store.contextMessageSignatures(ctxWithNotice),
545
558
  store.contextMessageSignatures(ctxNoNotice),
@@ -639,7 +652,7 @@ try {
639
652
  await collect(
640
653
  backend.streamAcpTurn(
641
654
  sonnet,
642
- { messages: [{ role: "user", content: "hi NONCE-G", timestamp: 0 }] },
655
+ tctx([{ role: "user", content: "hi NONCE-G", timestamp: 0 }]),
643
656
  { sessionId: "gate-G" },
644
657
  h.deps,
645
658
  ) as Stream,
@@ -704,7 +717,7 @@ try {
704
717
  await collect(
705
718
  backend.streamAcpTurn(
706
719
  sonnet,
707
- { messages: [{ role: "user", content: "hi NONCE-H", timestamp: 0 }] },
720
+ tctx([{ role: "user", content: "hi NONCE-H", timestamp: 0 }]),
708
721
  { sessionId: "gate-H-off" },
709
722
  hOff.deps,
710
723
  ) as Stream,
@@ -720,7 +733,7 @@ try {
720
733
  await collect(
721
734
  backend.streamAcpTurn(
722
735
  sonnet,
723
- { messages: [{ role: "user", content: "hi NONCE-H2", timestamp: 0 }] },
736
+ tctx([{ role: "user", content: "hi NONCE-H2", timestamp: 0 }]),
724
737
  { sessionId: "gate-H-on" },
725
738
  hOn.deps,
726
739
  ) as Stream,
@@ -761,7 +774,7 @@ try {
761
774
  await collect(
762
775
  backend.streamAcpTurn(
763
776
  sonnet,
764
- { messages: [{ role: "user", content: "hi NONCE-I1", timestamp: 0 }] },
777
+ tctx([{ role: "user", content: "hi NONCE-I1", timestamp: 0 }]),
765
778
  { sessionId: "gate-I-one" },
766
779
  h.deps,
767
780
  ) as Stream,
@@ -769,7 +782,7 @@ try {
769
782
  await collect(
770
783
  backend.streamAcpTurn(
771
784
  sonnet,
772
- { messages: [{ role: "user", content: "hi NONCE-I2", timestamp: 0 }] },
785
+ tctx([{ role: "user", content: "hi NONCE-I2", timestamp: 0 }]),
773
786
  { sessionId: "gate-I-two" },
774
787
  h.deps,
775
788
  ) as Stream,