@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
@@ -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>;
@@ -72,7 +79,7 @@ interface WireUsage {
72
79
  * One `_meta.quota.token_count` row. The field NAMES deliberately differ from
73
80
  * `PromptResponse.usage`: cache reads are `cachedInputTokens` here because the
74
81
  * shape is shared with codex-acp, and `cachedWriteTokens` is Claude's extra
75
- * sibling (read at claude-agent-acp 0.76.0 `dist/acp-agent.js:6566-6575`).
82
+ * sibling (read at claude-agent-acp 0.79.0 `dist/acp-agent.js:6579-6588`).
76
83
  * Reading a quota row with the `usage` field names silently yields zeros, so the
77
84
  * fixture below spells the vendor's names out rather than reusing `WireUsage`.
78
85
  */
@@ -137,7 +144,7 @@ function makeFakeChild() {
137
144
  *
138
145
  * Each `prompt` call consumes the next TurnScript: it first pushes that turn's
139
146
  * `usage_update` notification (the wire the running cost total actually arrives
140
- * on — read at claude-agent-acp 0.76.0 `dist/acp-agent.js:3471-3486`), then answers the
147
+ * on — read at claude-agent-acp 0.79.0 `dist/acp-agent.js:3504-3519`), then answers the
141
148
  * prompt with that turn's `PromptResponse.usage` (the wire the turn aggregate
142
149
  * arrives on). Both orderings are the real one: the notification precedes the
143
150
  * response, because the SDK emits it from the `result` message that ENDS the turn.
@@ -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";
@@ -393,8 +417,8 @@ try {
393
417
  // AGENT LOOP only. `_meta.quota.model_usage` comes from `result.modelUsage` and
394
418
  // also counts Task subagents, sidechains and INTERNAL CALLS SUCH AS COMPACTION;
395
419
  // the vendor states its rows "can total more than `token_count`" and are "the
396
- // fuller picture, not a decomposition of it" (read at claude-agent-acp 0.76.0
397
- // `dist/acp-agent.js:6541-6547`).
420
+ // fuller picture, not a decomposition of it" (read at claude-agent-acp 0.79.0
421
+ // `dist/acp-agent.js:6554-6560`).
398
422
  //
399
423
  // The wide one is required, not merely nicer, because the DENOMINATOR already
400
424
  // has that scope: turn cost is the adjacent diff of the backend's running total,
@@ -608,8 +632,8 @@ try {
608
632
  // identity that produces the bound is a property of the MAIN AGENT LOOP's
609
633
  // cache breakpoints. `_meta.quota.model_usage` is a WIDER scope: the vendor
610
634
  // states those rows also count Task subagents, sidechains, and INTERNAL
611
- // CALLS SUCH AS COMPACTION (read at claude-agent-acp 0.76.0
612
- // `dist/acp-agent.js:6538-6558`). Mixing the two scopes inflates the bound
635
+ // CALLS SUCH AS COMPACTION (read at claude-agent-acp 0.79.0
636
+ // `dist/acp-agent.js:6551-6571`). Mixing the two scopes inflates the bound
613
637
  // through both remaining terms that mention cacheWrite:
614
638
  // max(0, occupancy − cacheWrite) shrinks as wide cacheWrite grows, so
615
639
  // less is subtracted; min(rawBound, cacheWrite) rises with it.
@@ -749,9 +773,9 @@ try {
749
773
  // CELL 3 — a turn with NO cost notification holds the baseline.
750
774
  //
751
775
  // Measured upstream: the result-path `usage_update` carries cost (read at
752
- // claude-agent-acp 0.76.0 `dist/acp-agent.js:3471-3486`), while other
776
+ // claude-agent-acp 0.79.0 `dist/acp-agent.js:3504-3519`), while other
753
777
  // `usage_update` paths can carry `used` without cost (for example the
754
- // rate-limit path at `:4273-4281`). A live thinkpad ledger shows such turns
778
+ // rate-limit path at `:4336-4344`). A live thinkpad ledger shows such turns
755
779
  // really occur. The honest handling is to HOLD the baseline so the amount lands
756
780
  // in the NEXT diff: misattributed by turn, exact by session. Rebaselining to 0
757
781
  // there would double-count the whole prefix.
@@ -831,7 +855,7 @@ try {
831
855
  // CELL 4 — a DECREASING session total is never silently absorbed.
832
856
  //
833
857
  // `conversation_reset` switches the session to a fresh transcript (read at
834
- // claude-agent-acp 0.76.0 `dist/acp-agent.js:4286-4293`), but whether that
858
+ // claude-agent-acp 0.79.0 `dist/acp-agent.js:4349-4356`), but whether that
835
859
  // changes `total_cost_usd` is an SDK-internal value we cannot observe here.
836
860
  // A diff can therefore go negative in a session we are still holding.
837
861
  // Absorbing it quietly would both misreport the turn and destroy the only
@@ -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
  );
@@ -170,14 +170,13 @@ async function main(): Promise<void> {
170
170
 
171
171
  // ── one nonce, into whichever rail ───────────────────────────────────────────────────
172
172
  const nonceSpawn = recordingSpawn();
173
- await dispatchFreshCall(REQUEST, HERDR_ENV, nonceSpawn.spawn, "fresh-call-dispatch-test-nonce");
174
- const startCall = nonceSpawn.calls.find((call) => call[1] === "agent" && call[2] === "start");
173
+ const dispatchNonce = "mux-fresh-call-deadbeefdeadbeefdeadbeef";
174
+ await dispatchFreshCall(REQUEST, HERDR_ENV, nonceSpawn.spawn, dispatchNonce);
175
+ const tabCall = nonceSpawn.calls.find((call) => call[1] === "tab" && call[2] === "create");
175
176
  ok(
176
177
  "[QK:FCD-ONE-NONCE] ONE nonce is minted per call and handed to the selected rail — two mints would mean the sibling calls back with a tag the caller never recorded",
177
178
  dispatchCode.split("mintNonce(").length - 1 === 1 &&
178
- (startCall === undefined ||
179
- startCall.some((token) => token.includes("fresh-call-dispatch-test-nonce")) ||
180
- nonceSpawn.calls.length > 0),
179
+ (tabCall === undefined || tabCall.some((token) => token.includes(dispatchNonce)) || nonceSpawn.calls.length > 0),
181
180
  );
182
181
 
183
182
  // ── the composition root holds no synchronous child authority ────────────────────────
@@ -49,6 +49,7 @@ import * as path from "node:path";
49
49
  import { fileURLToPath } from "node:url";
50
50
  import {
51
51
  classifyMutantRun,
52
+ countOccurrences,
52
53
  createRepoSnapshot,
53
54
  ManifestError,
54
55
  type MutantManifest,
@@ -820,6 +821,7 @@ let manifestCount: number;
820
821
  // manifest lost in a merge must name itself here — "0/0 killed" is not a pass.
821
822
  // Extend this inventory and the manifests TOGETHER, never silently.
822
823
  const EXPECTED_LANE_MUTANTS: Record<string, number> = {
824
+ "acp-backend-preflight": 1,
823
825
  "acp-augment": 10,
824
826
  "acp-cortex": 14,
825
827
  "acp-launch-namespace": 2,
@@ -829,7 +831,7 @@ let manifestCount: number;
829
831
  "acp-stream-hooks": 10,
830
832
  "acp-usage-accounting": 12,
831
833
  "agy-permission": 6,
832
- "bridge-boot-resume": 3,
834
+ "bridge-boot-resume": 5,
833
835
  "bridge-command-boot": 9,
834
836
  "capability-cache": 3,
835
837
  "codex-app-server-launch": 9,
@@ -839,6 +841,7 @@ let manifestCount: number;
839
841
  "control-socket-disconnect": 4,
840
842
  "copilot-birth": 19,
841
843
  "copilot-launch": 14,
844
+ "pi-launch": 9,
842
845
  "copilot-receive": 20,
843
846
  "entwurf-peers": 1,
844
847
  "fresh-call-dispatch": 12,
@@ -847,10 +850,10 @@ let manifestCount: number;
847
850
  "herdr-placement": 13,
848
851
  "herdr-plugin": 11,
849
852
  "herdr-plugin-profile": 14,
850
- "herdr-activation": 24,
853
+ "herdr-activation": 25,
851
854
  "herdr-plugin-build": 13,
852
855
  "herdr-runtime-bootstrap": 31,
853
- "herdr-fresh-call": 37,
856
+ "herdr-fresh-call": 38,
854
857
  "herdr-supply": 11,
855
858
  "meta-facts": 4,
856
859
  "peer-facts": 8,
@@ -858,7 +861,7 @@ let manifestCount: number;
858
861
  "meta-identity": 4,
859
862
  "meta-retire": 3,
860
863
  "mux-boundary": 16,
861
- "mux-fresh-call": 58,
864
+ "mux-fresh-call": 59,
862
865
  "mux-launcher-fence": 8,
863
866
  "mux-parent-artifact": 3,
864
867
  "pack-install": 2,
@@ -901,6 +904,42 @@ let manifestCount: number;
901
904
  (err instanceof Error ? err.message : String(err)),
902
905
  );
903
906
  }
907
+ // FIND MATCHES ITS SUBJECT, EXACTLY ONCE — the body's own precondition, hoisted into the head.
908
+ // `validateManifestSet` above proves the subject is TRACKED; it never opens it. So a change
909
+ // that edits a subject line a mutant quotes leaves the whole deterministic floor green and
910
+ // dies an hour later in CI or the release gate as MUTANT-STALE. That is exactly how the
911
+ // 0.23.2 pi bump landed: `pack_install_leaked_pi`'s pin regex moved 0.85.1 → 0.86.0 in
912
+ // run.sh, `scripts/mutants/pack-install.json` kept quoting the old line, and two claims came
913
+ // back NOT KILLED after ~50 minutes of mutant execution. The check costs one read per
914
+ // mutant and uses `countOccurrences` — the SAME function the body applies at
915
+ // mutation-qualify.ts:743 — so head and body cannot disagree about what "matches" means.
916
+ // This is a STALENESS check, not a kill-proof: it says the mutant still has a subject to
917
+ // corrupt, never that corrupting it is caught. That remains the body's verdict.
918
+ const stale: string[] = [];
919
+ for (const man of manifests) {
920
+ for (const m of man.mutants) {
921
+ const subjectAbs = path.join(REPO_DIR, m.subject);
922
+ let source: string;
923
+ try {
924
+ source = fs.readFileSync(subjectAbs, "utf8");
925
+ } catch (err) {
926
+ stale.push(`${man.lane}/${m.claim}: subject ${m.subject} unreadable (${(err as Error).message})`);
927
+ continue;
928
+ }
929
+ const n = countOccurrences(source, m.find.join("\n"));
930
+ if (n !== 1) stale.push(`${man.lane}/${m.claim}: find matched ${n}× in ${m.subject} (expected exactly 1)`);
931
+ }
932
+ }
933
+ assert.deepEqual(
934
+ stale,
935
+ [],
936
+ "[QK:MUTANT-FIND-MATCHES-SUBJECT] every mutant's `find` must match its subject exactly once. A find that " +
937
+ "matches 0× is a mutant whose production line moved out from under it — the body can write nothing, the " +
938
+ "verdict is MUTANT-STALE, and the claim is silently unproven; a find that matches 2+× cannot say WHICH " +
939
+ "occurrence it corrupts. Either way the manifest and the source have to move together. Found:\n" +
940
+ stale.join("\n"),
941
+ );
942
+
904
943
  manifestCount = manifests.length;
905
944
  console.log(`[${SURFACE}] ${selected.length} mutants across ${manifestCount} lanes (no tiers — full set every run)`);
906
945
  }
@@ -908,7 +947,8 @@ let manifestCount: number;
908
947
  if (MANIFESTS_ONLY) {
909
948
  console.log(
910
949
  `[check-gate-manifests] ok — runner self-test green, ${selected.length} committed mutants across ` +
911
- `${manifestCount} lanes validated against the origin index, and the lane inventory matches its declared ` +
950
+ `${manifestCount} lanes validated against the origin index, every one of their \`find\` strings still ` +
951
+ "matching its subject exactly once, and the lane inventory matching its declared " +
912
952
  "contract. ZERO mutants were executed and this repo was never snapshotted: the body " +
913
953
  "(check-gate-qualification) owns that, unchanged.",
914
954
  );
@@ -205,6 +205,21 @@ const {
205
205
  COMPONENT_STATES,
206
206
  } = mod;
207
207
 
208
+ /** The pi pin package.json declares — the SSOT `run.sh pi_supported_range` derives its range from. */
209
+ const PI_PIN = (
210
+ JSON.parse(fs.readFileSync(path.join(REPO, "package.json"), "utf8")) as {
211
+ devDependencies: Record<string, string>;
212
+ }
213
+ ).devDependencies["@earendil-works/pi-coding-agent"];
214
+
215
+ /** A `pi` stand-in that reports `version` and nothing else — enough for a `--version` floor probe. */
216
+ function fakePi(dir: string, name: string, version: string): string {
217
+ fs.mkdirSync(dir, { recursive: true });
218
+ const file = path.join(dir, name);
219
+ fs.writeFileSync(file, `#!/bin/sh\necho ${version}\n`, { mode: 0o755 });
220
+ return file;
221
+ }
222
+
208
223
  function world(tag: string): NodeJS.ProcessEnv {
209
224
  const home = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), `entwurf-hac-${tag}-`)));
210
225
  for (const d of [".config", ".state", ".data", ".cache", "pi-agent", ".claude"]) {
@@ -220,6 +235,13 @@ function world(tag: string): NodeJS.ProcessEnv {
220
235
  XDG_CACHE_HOME: path.join(home, ".cache"),
221
236
  PI_CODING_AGENT_DIR: path.join(home, "pi-agent"),
222
237
  CLAUDE_CONFIG_DIR: path.join(home, ".claude"),
238
+ // PI_BIN is a sandbox root like the four XDG vars above, not a convenience. Since #119
239
+ // `install-user-scope` probes `pi --version` against the same closed range setup enforces,
240
+ // so leaving this unset would let the OPERATOR'S installed pi decide whether these cells
241
+ // pass — the gate would go red on a developer machine sitting one minor behind the pin and
242
+ // green on another, for reasons none of these cells are about. It is pinned IN RANGE here;
243
+ // the one cell whose subject IS the floor overrides it per run.
244
+ PI_BIN: fakePi(path.join(home, "bin"), "pi", PI_PIN),
223
245
  };
224
246
  }
225
247
 
@@ -330,6 +352,57 @@ function ledgerWith(
330
352
  );
331
353
  }
332
354
 
355
+ // ── 1b. the plugin door enforces the SAME pi floor the setup door does ────────
356
+ // #119: `setup` refuses an out-of-range pi by name before it writes any Pi wiring, but
357
+ // `install-user-scope` — the door herdr plugin activation actually goes through
358
+ // (plugins/herdr/lib/build.mjs → scripts/herdr-plugin-activate.mjs → `run.sh
359
+ // install-user-scope --plugin-runtime …`) — had no version check at all. Two doors onto ONE
360
+ // registration telling different truths is the whole defect, so the oracle here is the
361
+ // out-of-range/in-range PAIR: the refusal alone would also be satisfied by a door that refuses
362
+ // everything. Absent pi is deliberately NOT asserted as a third state — this verb invents no
363
+ // presence verdict, setup owns that one.
364
+ {
365
+ const env = world("pi-floor");
366
+ const bin = path.join(env.HOME as string, "bin");
367
+ // The supported range is DERIVED from the package.json devDep pin (run.sh
368
+ // `pi_supported_range`), so the fixtures are derived from it too — a hardcoded "0.85.1" here
369
+ // would silently stop being out-of-range the day the pin moves past it.
370
+ const [maj, min] = PI_PIN.split(".").map(Number);
371
+ const belowFloor = `${maj}.${min - 1}.0`;
372
+ const settingsPath = path.join(env.PI_CODING_AGENT_DIR as string, "settings.json");
373
+ fs.writeFileSync(settingsPath, '{"theme":"dark"}\n');
374
+ const before = fs.readFileSync(settingsPath, "utf8");
375
+
376
+ const stale = sh({ ...env, PI_BIN: fakePi(bin, "stale-pi", belowFloor) }, [
377
+ "install-user-scope",
378
+ "--plugin-runtime",
379
+ runtimeRootOf(env),
380
+ ]);
381
+ const afterRefusal = fs.readFileSync(settingsPath, "utf8");
382
+ const current = sh({ ...env, PI_BIN: fakePi(bin, "current-pi", PI_PIN) }, [
383
+ "install-user-scope",
384
+ "--plugin-runtime",
385
+ runtimeRootOf(env),
386
+ ]);
387
+ const afterAccept = readJson(settingsPath) as { packages?: string[] };
388
+
389
+ ok(
390
+ "[QK:HAC-PI-FLOOR-BOTH-DOORS] the plugin activation door enforces the SAME closed pi range as the " +
391
+ "setup door: a below-floor pi is a named FAIL that writes zero settings bytes (not a SKIP, not a " +
392
+ "silent write), while an in-range pi still registers — one registration reached through two doors " +
393
+ "may not report two different verdicts, and the range both read is DERIVED from one package.json pin " +
394
+ `(pin=${PI_PIN} stale=${belowFloor}/exit=${stale.status} named=${(stale.stderr || "").includes("outside the supported range")} ` +
395
+ `untouched=${before === afterRefusal} current=exit=${current.status}/packages=${JSON.stringify(afterAccept.packages)})`,
396
+ stale.status !== 0 &&
397
+ (stale.stderr || "").includes("[install-user-scope] pi: FAIL") &&
398
+ (stale.stderr || "").includes("outside the supported range") &&
399
+ before === afterRefusal &&
400
+ current.status === 0 &&
401
+ Array.isArray(afterAccept.packages) &&
402
+ afterAccept.packages.length === 1,
403
+ );
404
+ }
405
+
333
406
  // ── 2. the plugin command is DERIVED from the runtime root, never supplied ─────
334
407
  {
335
408
  const env = world("derived");
@@ -270,7 +270,7 @@ const START_ERR = JSON.stringify({
270
270
  });
271
271
 
272
272
  const CALLER = "20260914T174741-2e9ba9";
273
- const NONCE = "herdr-fresh-call-0123456789abcdef01234567";
273
+ const NONCE = "mux-fresh-call-0123456789abcdef01234567";
274
274
  const HERDR_ENV = { HERDR_ENV: "1", HERDR_BIN_PATH: "/home/operator/.local/bin/herdr", HERDR_PANE_ID: "w7:p1" };
275
275
 
276
276
  type ScriptedReply =
@@ -344,9 +344,11 @@ async function main(): Promise<void> {
344
344
  const code = MODULE_SRC.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
345
345
  const specifiers = [...MODULE_SRC.matchAll(/^import\s[^;]*?from\s+"([^"]+)";$/gm)].map((m) => m[1]);
346
346
  ok(
347
- "[QK:HFC-RAIL-IMPORT-FENCE] the herdr rail imports only node builtins and the neutral composition leaf — one mux or entwurf import here would re-couple the two rails and make either one undeletable",
347
+ "[QK:HFC-RAIL-IMPORT-FENCE] the herdr rail imports only node builtins, the neutral composition leaf, and the callback-env pair — one mux or entwurf import here would re-couple the two rails and make either one undeletable",
348
348
  specifiers.length > 0 &&
349
- specifiers.every((s) => s.startsWith("node:") || s === "./fresh-call-composition.ts") &&
349
+ specifiers.every(
350
+ (s) => s.startsWith("node:") || s === "./fresh-call-composition.ts" || s === "./callback-env.ts",
351
+ ) &&
350
352
  !/from\s+"\.\/(mux-|entwurf-)/.test(code),
351
353
  );
352
354
  // tmux is NAMED in this module, and only in one place: the refusal of a tmux seat input.
@@ -453,15 +455,29 @@ async function main(): Promise<void> {
453
455
  // ── argv grammar ─────────────────────────────────────────────────────────────────────
454
456
  // Pure builders are checked before full launch paths so a framing mutant is attributed to
455
457
  // its own contract rather than making an earlier integration success cell fail first.
456
- const tabArgs = buildHerdrTabCreateArgs({ workspaceId: "w7", cwd: "/repo/dir" });
458
+ const tabCallback = {
459
+ target: "20260101T010101-aaaaaa",
460
+ nonce: "mux-fresh-call-deadbeefdeadbeefdeadbeef",
461
+ };
462
+ const tabArgs = buildHerdrTabCreateArgs({ workspaceId: "w7", cwd: "/repo/dir", callback: tabCallback });
463
+ // The SPECIFIC claim runs before the umbrella one. `ok` throws, so the first red assertion is
464
+ // the only one a mutant run ever prints: with the exact-argv cell first, the callback-pair
465
+ // mutant died on the whole-argv claim below and never reached its own — a WRONG-REASON that
466
+ // qualification is exactly there to catch. Order is the fix; both cells keep their subject,
467
+ // and the argv cell keeps its own mutant, which drops `--no-focus` and this cell cannot see.
468
+ ok(
469
+ "[QK:HFC-CALLBACK-ENV] the launcher-computed callback pair rides as two more --env assignments beside the scrub",
470
+ tabArgs.includes("ENTWURF_CALLBACK_TARGET=20260101T010101-aaaaaa") &&
471
+ tabArgs.includes("ENTWURF_CALLBACK_NONCE=mux-fresh-call-deadbeefdeadbeefdeadbeef"),
472
+ );
457
473
  ok(
458
474
  "[QK:HFC-TAB-ARGV] one placement policy — a NEW TAB in the caller's own workspace, named explicitly so herdr cannot default it to the focused one, focus left alone, cwd carried literally, and BOTH identity carriers scrubbed by explicit repeated --env",
459
475
  tabArgs.join(" ") ===
460
- "tab create --workspace w7 --no-focus --cwd /repo/dir --env PI_SESSION_ID= --env PI_AGENT_ID=",
476
+ "tab create --workspace w7 --no-focus --cwd /repo/dir --env PI_SESSION_ID= --env PI_AGENT_ID= --env ENTWURF_CALLBACK_TARGET=20260101T010101-aaaaaa --env ENTWURF_CALLBACK_NONCE=mux-fresh-call-deadbeefdeadbeefdeadbeef",
461
477
  );
462
478
  ok(
463
479
  "a cwd-free tab create omits --cwd entirely rather than sending an empty value, and the rail adds no layout axis of its own — no label, no ratio, no direction",
464
- !buildHerdrTabCreateArgs({ workspaceId: "w7" }).includes("--cwd") &&
480
+ !buildHerdrTabCreateArgs({ workspaceId: "w7", callback: tabCallback }).includes("--cwd") &&
465
481
  !tabArgs.includes("--label") &&
466
482
  !tabArgs.includes("--ratio") &&
467
483
  !tabArgs.includes("--direction"),