@junghanacs/entwurf 0.23.1 → 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 (67) hide show
  1. package/BASELINE.md +1 -0
  2. package/CHANGELOG.md +173 -0
  3. package/FAQ.md +47 -0
  4. package/README.md +5 -3
  5. package/VERIFY.md +2 -2
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +2 -2
  8. package/docs/setup-clean-host.md +8 -7
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +33 -1
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +19 -5
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/callback-env.js +68 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +54 -22
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +7 -3
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +13 -4
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +19 -10
  16. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +35 -42
  17. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  18. package/mcp/entwurf-bridge/src/index.ts +41 -1
  19. package/mcp/entwurf-bridge/test.sh +2 -2
  20. package/package.json +8 -7
  21. package/pi-extensions/entwurf-control.ts +56 -0
  22. package/pi-extensions/lib/acp/backend.ts +16 -6
  23. package/pi-extensions/lib/acp/context.ts +30 -10
  24. package/pi-extensions/lib/acp/session-store.ts +2 -2
  25. package/pi-extensions/lib/callback-env.ts +78 -0
  26. package/pi-extensions/lib/compaction-send-guard.ts +4 -1
  27. package/pi-extensions/lib/fresh-call-composition.ts +56 -22
  28. package/pi-extensions/lib/herdr-fresh-call.ts +12 -4
  29. package/pi-extensions/lib/herdr-placement.ts +13 -4
  30. package/pi-extensions/lib/mux-fresh-call.ts +23 -8
  31. package/pi-extensions/meta-bridge-omp.ts +35 -48
  32. package/run.sh +135 -50
  33. package/scripts/check-acp-backend-preflight.ts +117 -9
  34. package/scripts/check-acp-carrier-augment.ts +6 -4
  35. package/scripts/check-acp-event-mapper.ts +17 -6
  36. package/scripts/check-acp-prompt-builder.ts +17 -9
  37. package/scripts/check-acp-prompt-lifecycle.ts +23 -6
  38. package/scripts/check-acp-session-reuse.ts +64 -51
  39. package/scripts/check-acp-session-store.ts +31 -8
  40. package/scripts/check-acp-stop-reason.ts +20 -2
  41. package/scripts/check-acp-stream-hooks.ts +22 -5
  42. package/scripts/check-acp-usage-accounting.ts +33 -9
  43. package/scripts/check-entwurf-bridge-boot.ts +16 -1
  44. package/scripts/check-fresh-call-dispatch.ts +4 -5
  45. package/scripts/check-gate-qualification.ts +44 -5
  46. package/scripts/check-herdr-activation.ts +73 -0
  47. package/scripts/check-herdr-fresh-call.ts +22 -6
  48. package/scripts/check-herdr-sandbox.ts +14 -2
  49. package/scripts/check-install-container.sh +1 -1
  50. package/scripts/check-pi-launch.ts +5 -2
  51. package/scripts/lib/mutation-qualify.ts +1 -1
  52. package/scripts/mutants/acp-backend-preflight.json +20 -0
  53. package/scripts/mutants/bridge-boot-resume.json +26 -0
  54. package/scripts/mutants/herdr-activation.json +16 -0
  55. package/scripts/mutants/herdr-fresh-call.json +11 -0
  56. package/scripts/mutants/mux-fresh-call.json +21 -8
  57. package/scripts/mutants/omp-fresh.json +5 -9
  58. package/scripts/mutants/pack-install.json +3 -3
  59. package/scripts/probe-bridge-command.ts +1 -0
  60. package/scripts/smoke-acp-session-reuse-live.ts +23 -5
  61. package/scripts/smoke-agy-install-state.sh +2 -2
  62. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  63. package/scripts/smoke-herdr-fresh-call-live.ts +56 -28
  64. package/scripts/smoke-herdr-raw-install-live.sh +31 -3
  65. package/scripts/smoke-omp-fresh-live.ts +4 -2
  66. package/scripts/smoke-omp-mcp-state.sh +1 -1
  67. package/scripts/smoke-pi-provider-state.sh +2 -2
@@ -14,6 +14,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
14
14
  import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import type { Context } from "@earendil-works/pi-ai";
17
+ import { normalizeContext } from "@earendil-works/pi-ai";
17
18
 
18
19
  const sha256 = (v: string) => createHash("sha256").update(v).digest("hex");
19
20
  const isSha256Hex = (v: string) => /^[0-9a-f]{64}$/.test(v);
@@ -114,13 +115,16 @@ const baseInput = (): BridgeConfigInput => ({
114
115
  stopReason: "stop" as const,
115
116
  timestamp: 0,
116
117
  }) satisfies Context["messages"][number];
117
- const ctx: Context = {
118
+ // Built through `normalizeContext` — the 0.86 provider-path shape. With no
119
+ // systemPrompt and no tools it adds no leading system message, so the exact
120
+ // three-entry expectation below is the same list production hashes.
121
+ const ctx = normalizeContext({
118
122
  messages: [
119
123
  { role: "user", content: "hello", timestamp: 0 },
120
124
  assistant("hi"),
121
125
  { role: "user", content: [{ type: "image", data: "RAWIMAGEBYTES", mimeType: "image/png" }], timestamp: 0 },
122
126
  ],
123
- };
127
+ });
124
128
  const sigs = contextMessageSignatures(ctx);
125
129
  // every entry is a sha256 digest of the pre-hash role:content form.
126
130
  assert.ok(sigs.every(isSha256Hex), "every message signature is a sha256 digest");
@@ -139,21 +143,21 @@ const baseInput = (): BridgeConfigInput => ({
139
143
 
140
144
  // toolResult folds in toolName + isError → same text, different tool/flag = different sig.
141
145
  const trContent = [{ type: "text" as const, text: "out" }];
142
- const trBase: Context = {
146
+ const trBase = normalizeContext({
143
147
  messages: [
144
148
  { role: "toolResult", toolCallId: "t1", toolName: "bash", isError: false, content: trContent, timestamp: 0 },
145
149
  ],
146
- };
147
- const trErr: Context = {
150
+ });
151
+ const trErr = normalizeContext({
148
152
  messages: [
149
153
  { role: "toolResult", toolCallId: "t1", toolName: "bash", isError: true, content: trContent, timestamp: 0 },
150
154
  ],
151
- };
152
- const trOther: Context = {
155
+ });
156
+ const trOther = normalizeContext({
153
157
  messages: [
154
158
  { role: "toolResult", toolCallId: "t1", toolName: "edit", isError: false, content: trContent, timestamp: 0 },
155
159
  ],
156
- };
160
+ });
157
161
  assert.notEqual(
158
162
  contextMessageSignatures(trBase)[0],
159
163
  contextMessageSignatures(trErr)[0],
@@ -164,6 +168,25 @@ const baseInput = (): BridgeConfigInput => ({
164
168
  contextMessageSignatures(trOther)[0],
165
169
  "toolResult toolName changes the signature",
166
170
  );
171
+
172
+ // 0.86 shape: `normalizeContext` folds systemPrompt+tools into a LEADING
173
+ // `role:"system"` message, so the carrier now rides INSIDE `messages` and is
174
+ // hashed like any other turn. Two consequences this cell locks:
175
+ // - a systemPrompt drift changes signature[0] → the prefix check declares the
176
+ // candidate incompatible → no reuse of a session built under the old prompt;
177
+ // - the same user turn under a system prompt is NOT a prefix of the same turn
178
+ // without one, so a 0.85-shaped record cannot silently be reused at 0.86.
179
+ const sameUser = [{ role: "user" as const, content: "hello", timestamp: 0 }];
180
+ const bare = contextMessageSignatures(normalizeContext({ messages: [...sameUser] }));
181
+ const withPrompt = contextMessageSignatures(normalizeContext({ systemPrompt: "carrier A", messages: [...sameUser] }));
182
+ const withOtherPrompt = contextMessageSignatures(
183
+ normalizeContext({ systemPrompt: "carrier B", messages: [...sameUser] }),
184
+ );
185
+ assert.equal(bare.length, 1, "no systemPrompt and no tools → no leading system message");
186
+ assert.equal(withPrompt.length, 2, "a systemPrompt becomes a leading system message that is hashed too");
187
+ assert.notEqual(withPrompt[0], withOtherPrompt[0], "a systemPrompt drift changes the leading signature");
188
+ assert.ok(!hasPrefix(withPrompt, bare), "a prompt-carrying transcript is not a prefix of a bare one");
189
+ assert.ok(!hasPrefix(withPrompt, withOtherPrompt), "a drifted carrier breaks prefix compatibility");
167
190
  }
168
191
 
169
192
  // ---------------------------------------------------------------------------
@@ -52,7 +52,8 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs
52
52
  import { tmpdir } from "node:os";
53
53
  import { resolve } from "node:path";
54
54
  import { pathToFileURL } from "node:url";
55
- import type { Api, AssistantMessageEvent, Context, Model } from "@earendil-works/pi-ai";
55
+ import type { Api, AssistantMessageEvent, Model } from "@earendil-works/pi-ai";
56
+ import { normalizeContext } from "@earendil-works/pi-ai";
56
57
 
57
58
  const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
58
59
 
@@ -83,6 +84,20 @@ function makeFakeChild() {
83
84
  };
84
85
  }
85
86
 
87
+ // The pi built-ins the Claude child also exposes natively. pi declares its tool surface on
88
+ // every turn, and since 0.86 that declaration reaches a provider as the leading system
89
+ // message's `toolsAdded` (`normalizeContext`), which backend.ts replays for the exclude-tools
90
+ // truthfulness preflight. A fixture that declares NO tools is therefore not "a turn with the
91
+ // defaults" — it is a turn where the operator excluded everything, and the preflight rejects it
92
+ // before the prompt is ever sent. So every provider-path context here is built through
93
+ // `normalizeContext`, never cast `as Context`: the cast is exactly what hid this shape from
94
+ // typecheck when the 0.86 pin landed.
95
+ const PI_DECLARED_TOOLS = ["read", "bash", "edit", "write"].map((name) => ({
96
+ name,
97
+ description: "",
98
+ parameters: {} as never,
99
+ }));
100
+
86
101
  const EMPTY_MCP_HASH = "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945";
87
102
  const DEFAULT_RESOLVED_CONFIG: any = {
88
103
  settingSources: [],
@@ -267,7 +282,10 @@ try {
267
282
  const events = await collect(
268
283
  backend.streamAcpTurn(
269
284
  sonnet,
270
- { messages: [{ role: "user", content: `drive ${label}`, timestamp: 0 }] } as Context,
285
+ normalizeContext({
286
+ tools: PI_DECLARED_TOOLS,
287
+ messages: [{ role: "user", content: `drive ${label}`, timestamp: 0 }],
288
+ }),
271
289
  { sessionId: `stop-${label}` },
272
290
  makeHarness(recordDir, cell.wire),
273
291
  ) as Stream,
@@ -32,7 +32,8 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs
32
32
  import { tmpdir } from "node:os";
33
33
  import { resolve } from "node:path";
34
34
  import { pathToFileURL } from "node:url";
35
- import type { Api, AssistantMessageEvent, Context, Message, Model } from "@earendil-works/pi-ai";
35
+ import type { Api, AssistantMessageEvent, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
36
+ import { normalizeContext } from "@earendil-works/pi-ai";
36
37
 
37
38
  const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
38
39
 
@@ -133,10 +134,26 @@ async function collect(stream: Stream): Promise<AssistantMessageEvent[]> {
133
134
  const sealed = (events: AssistantMessageEvent[]) =>
134
135
  events.filter((e) => e.type === "done" || e.type === "error") as any[];
135
136
 
136
- const userCtx = (text: string): Context => ({ messages: [{ role: "user", content: text, timestamp: 0 }] }) as Context;
137
+ // The pi built-ins the Claude child also exposes natively. pi declares its tool surface on
138
+ // every turn, and since 0.86 that declaration reaches a provider as the leading system
139
+ // message's `toolsAdded` (`normalizeContext`), which backend.ts replays for the exclude-tools
140
+ // truthfulness preflight. A fixture that declares NO tools is therefore not "a turn with the
141
+ // defaults" — it is a turn where the operator excluded everything, and the preflight rejects it
142
+ // before the prompt is ever sent. So every provider-path context here is built through
143
+ // `normalizeContext`, never cast `as Context`: the cast is exactly what hid this shape from
144
+ // typecheck when the 0.86 pin landed.
145
+ const PI_DECLARED_TOOLS = ["read", "bash", "edit", "write"].map((name) => ({
146
+ name,
147
+ description: "",
148
+ parameters: {} as never,
149
+ }));
137
150
 
138
- function reuseCtx(prior: string, latest: string): Context {
139
- return {
151
+ const userCtx = (text: string): TranscriptContext =>
152
+ normalizeContext({ tools: PI_DECLARED_TOOLS, messages: [{ role: "user", content: text, timestamp: 0 }] });
153
+
154
+ function reuseCtx(prior: string, latest: string): TranscriptContext {
155
+ return normalizeContext({
156
+ tools: PI_DECLARED_TOOLS,
140
157
  messages: [
141
158
  { role: "user", content: prior, timestamp: 0 },
142
159
  {
@@ -158,7 +175,7 @@ function reuseCtx(prior: string, latest: string): Context {
158
175
  } as unknown as Message,
159
176
  { role: "user", content: latest, timestamp: 0 },
160
177
  ],
161
- } as Context;
178
+ });
162
179
  }
163
180
 
164
181
  // EVERY cell registers onResponse; the exemption claim at the end judges the sum.
@@ -50,8 +50,15 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs
50
50
  import { tmpdir } from "node:os";
51
51
  import { resolve } from "node:path";
52
52
  import { pathToFileURL } from "node:url";
53
- import type { Api, AssistantMessage, AssistantMessageEvent, Context, Message, Model } from "@earendil-works/pi-ai";
54
- import { isContextOverflow } from "@earendil-works/pi-ai";
53
+ import type {
54
+ Api,
55
+ AssistantMessage,
56
+ AssistantMessageEvent,
57
+ Message,
58
+ Model,
59
+ TranscriptContext,
60
+ } from "@earendil-works/pi-ai";
61
+ import { isContextOverflow, normalizeContext } from "@earendil-works/pi-ai";
55
62
  import { calculateContextTokens } from "@earendil-works/pi-coding-agent";
56
63
 
57
64
  const claude = { id: "claude-sonnet-5" } as unknown as Model<Api>;
@@ -216,7 +223,22 @@ function sealedMessage(events: AssistantMessageEvent[]): AssistantMessage {
216
223
  return seal[0].message as AssistantMessage;
217
224
  }
218
225
 
219
- const userCtx = (text: string): Context => ({ messages: [{ role: "user", content: text, timestamp: 0 }] }) as Context;
226
+ // The pi built-ins the Claude child also exposes natively. pi declares its tool surface on
227
+ // every turn, and since 0.86 that declaration reaches a provider as the leading system
228
+ // message's `toolsAdded` (`normalizeContext`), which backend.ts replays for the exclude-tools
229
+ // truthfulness preflight. A fixture that declares NO tools is therefore not "a turn with the
230
+ // defaults" — it is a turn where the operator excluded everything, and the preflight rejects it
231
+ // before the prompt is ever sent. So every provider-path context here is built through
232
+ // `normalizeContext`, never cast `as Context`: the cast is exactly what hid this shape from
233
+ // typecheck when the 0.86 pin landed.
234
+ const PI_DECLARED_TOOLS = ["read", "bash", "edit", "write"].map((name) => ({
235
+ name,
236
+ description: "",
237
+ parameters: {} as never,
238
+ }));
239
+
240
+ const userCtx = (text: string): TranscriptContext =>
241
+ normalizeContext({ tools: PI_DECLARED_TOOLS, messages: [{ role: "user", content: text, timestamp: 0 }] });
220
242
 
221
243
  const zeroUsage = () => ({
222
244
  input: 0,
@@ -228,8 +250,9 @@ const zeroUsage = () => ({
228
250
  });
229
251
 
230
252
  /** A reuse-shaped context: prior user, assistant, new user. */
231
- function reuseCtx(prior: string, latest: string): Context {
232
- return {
253
+ function reuseCtx(prior: string, latest: string): TranscriptContext {
254
+ return normalizeContext({
255
+ tools: PI_DECLARED_TOOLS,
233
256
  messages: [
234
257
  { role: "user", content: prior, timestamp: 0 },
235
258
  {
@@ -244,11 +267,11 @@ function reuseCtx(prior: string, latest: string): Context {
244
267
  } as unknown as Message,
245
268
  { role: "user", content: latest, timestamp: 0 },
246
269
  ],
247
- } as Context;
270
+ });
248
271
  }
249
272
 
250
273
  /** A third turn on the same reused session. */
251
- function reuseCtx3(a: string, b: string, c: string): Context {
274
+ function reuseCtx3(a: string, b: string, c: string): TranscriptContext {
252
275
  const assistant = {
253
276
  role: "assistant",
254
277
  content: [{ type: "text", text: "ok" }],
@@ -259,7 +282,8 @@ function reuseCtx3(a: string, b: string, c: string): Context {
259
282
  stopReason: "stop",
260
283
  timestamp: 0,
261
284
  } as unknown as Message;
262
- return {
285
+ return normalizeContext({
286
+ tools: PI_DECLARED_TOOLS,
263
287
  messages: [
264
288
  { role: "user", content: a, timestamp: 0 },
265
289
  assistant,
@@ -267,7 +291,7 @@ function reuseCtx3(a: string, b: string, c: string): Context {
267
291
  assistant,
268
292
  { role: "user", content: c, timestamp: 0 },
269
293
  ],
270
- } as Context;
294
+ });
271
295
  }
272
296
 
273
297
  const TMP_EMIT = ".tmp-verify/acp-usage-accounting";
@@ -224,6 +224,20 @@ async function main(): Promise<void> {
224
224
  !tools.some((t) => ["entwurf", "entwurf_resume", "entwurf_send"].includes(String(t?.name))),
225
225
  );
226
226
 
227
+ const callback = tools.find((t) => t?.name === "entwurf_callback");
228
+ ok(
229
+ "[QK:BRIDGEBOOT-CALLBACK-REGISTERED] G1h: entwurf_callback is registered on the runtime tools/list surface — not merely imported",
230
+ !!callback,
231
+ );
232
+ const callbackSchema = callback?.inputSchema ?? {};
233
+ const callbackProps = callbackSchema.properties ?? {};
234
+ const callbackRequired = Array.isArray(callbackSchema.required) ? callbackSchema.required.map(String) : [];
235
+ ok(
236
+ "[QK:BRIDGEBOOT-CALLBACK-ZERO-ARG] G1h: entwurf_callback takes EXACTLY zero inputs — a target or nonce parameter here would be a model-supplied address, which Hard Rule 5 refuses",
237
+ Object.keys(callbackProps).length === 0 && callbackRequired.length === 0,
238
+ `--- inputSchema ---\n${JSON.stringify(callbackSchema)}`,
239
+ );
240
+
227
241
  // G1f — the public surface is an EXACT set, judged last so the named assertions above
228
242
  // keep their own diagnosis. Every check before this one is existential: each names one
229
243
  // verb it cares about, so a verb nobody named could be dropped (entwurf_fresh_call was
@@ -233,6 +247,7 @@ async function main(): Promise<void> {
233
247
  // from the server: an oracle computed from the subject proves nothing.
234
248
  const publicSurface = tools.map((t) => String(t?.name)).sort();
235
249
  const expectedSurface = [
250
+ "entwurf_callback",
236
251
  "entwurf_fresh_call",
237
252
  "entwurf_inbox_read",
238
253
  "entwurf_peers",
@@ -242,7 +257,7 @@ async function main(): Promise<void> {
242
257
  "entwurf_v2",
243
258
  ];
244
259
  ok(
245
- "[QK:BRIDGEBOOT-PUBLIC-SURFACE-EXACT-SET] G1f: the runtime tools/list surface is EXACTLY the seven shipped garden verbs — no missing verb, no undecided extra, no duplicate",
260
+ "[QK:BRIDGEBOOT-PUBLIC-SURFACE-EXACT-SET] G1f: the runtime tools/list surface is EXACTLY the eight shipped garden verbs — no missing verb, no undecided extra, no duplicate",
246
261
  publicSurface.length === expectedSurface.length && expectedSurface.every((n, i) => publicSurface[i] === n),
247
262
  `--- want ---\n${expectedSurface.join(",")}\n--- got ---\n${publicSurface.join(",")}`,
248
263
  );
@@ -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,
@@ -848,10 +850,10 @@ let manifestCount: number;
848
850
  "herdr-placement": 13,
849
851
  "herdr-plugin": 11,
850
852
  "herdr-plugin-profile": 14,
851
- "herdr-activation": 24,
853
+ "herdr-activation": 25,
852
854
  "herdr-plugin-build": 13,
853
855
  "herdr-runtime-bootstrap": 31,
854
- "herdr-fresh-call": 37,
856
+ "herdr-fresh-call": 38,
855
857
  "herdr-supply": 11,
856
858
  "meta-facts": 4,
857
859
  "peer-facts": 8,
@@ -859,7 +861,7 @@ let manifestCount: number;
859
861
  "meta-identity": 4,
860
862
  "meta-retire": 3,
861
863
  "mux-boundary": 16,
862
- "mux-fresh-call": 58,
864
+ "mux-fresh-call": 59,
863
865
  "mux-launcher-fence": 8,
864
866
  "mux-parent-artifact": 3,
865
867
  "pack-install": 2,
@@ -902,6 +904,42 @@ let manifestCount: number;
902
904
  (err instanceof Error ? err.message : String(err)),
903
905
  );
904
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
+
905
943
  manifestCount = manifests.length;
906
944
  console.log(`[${SURFACE}] ${selected.length} mutants across ${manifestCount} lanes (no tiers — full set every run)`);
907
945
  }
@@ -909,7 +947,8 @@ let manifestCount: number;
909
947
  if (MANIFESTS_ONLY) {
910
948
  console.log(
911
949
  `[check-gate-manifests] ok — runner self-test green, ${selected.length} committed mutants across ` +
912
- `${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 " +
913
952
  "contract. ZERO mutants were executed and this repo was never snapshotted: the body " +
914
953
  "(check-gate-qualification) owns that, unchanged.",
915
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"),
@@ -291,7 +291,15 @@ async function main(): Promise<void> {
291
291
  if (callerPane?.workspaceId === undefined) {
292
292
  fail(`pane get of the caller pane failed or carried no workspace: ${callerGet.stderr || callerGet.stdout}`);
293
293
  }
294
- const tabRun = herdr(bin, sandbox, buildHerdrTabCreateArgs({ workspaceId: callerPane.workspaceId, cwd: REPO_DIR }));
294
+ const tabCallback = {
295
+ target: "20260101T010101-aaaaaa",
296
+ nonce: "mux-fresh-call-deadbeefdeadbeefdeadbeef",
297
+ };
298
+ const tabRun = herdr(
299
+ bin,
300
+ sandbox,
301
+ buildHerdrTabCreateArgs({ workspaceId: callerPane.workspaceId, cwd: REPO_DIR, callback: tabCallback }),
302
+ );
295
303
  const tab = tabRun.status === 0 ? parseHerdrTabCreateResponse(tabRun.stdout) : null;
296
304
  if (tab === null) fail(`tab create failed or was unreadable: ${tabRun.stderr || tabRun.stdout}`);
297
305
  const tabRootPane = tab.rootPane;
@@ -401,7 +409,11 @@ async function main(): Promise<void> {
401
409
  );
402
410
  }
403
411
 
404
- const spareRun = herdr(bin, sandbox, buildHerdrTabCreateArgs({ workspaceId: callerPane.workspaceId }));
412
+ const spareRun = herdr(
413
+ bin,
414
+ sandbox,
415
+ buildHerdrTabCreateArgs({ workspaceId: callerPane.workspaceId, callback: tabCallback }),
416
+ );
405
417
  const spareTab = spareRun.status === 0 ? parseHerdrTabCreateResponse(spareRun.stdout) : null;
406
418
  if (spareTab === null)
407
419
  fail(`the second tab create failed or was unreadable: ${spareRun.stderr || spareRun.stdout}`);
@@ -321,7 +321,7 @@ function finish(t) {
321
321
  // artifact ship" is precisely the question it owns. The subset form named only the five
322
322
  // pre-0.14 verbs and would have accepted a candidate with no entwurf_fresh_call /
323
323
  // entwurf_resume_call; equality also refuses an extra or duplicated verb.
324
- const EXPECT_TOOLS = ['entwurf_fresh_call', 'entwurf_inbox_read', 'entwurf_peers', 'entwurf_register_native', 'entwurf_resume_call', 'entwurf_self', 'entwurf_v2'];
324
+ const EXPECT_TOOLS = ['entwurf_callback', 'entwurf_fresh_call', 'entwurf_inbox_read', 'entwurf_peers', 'entwurf_register_native', 'entwurf_resume_call', 'entwurf_self', 'entwurf_v2'];
325
325
  if (names.length !== EXPECT_TOOLS.length || EXPECT_TOOLS.some((n, i) => names[i] !== n)) {
326
326
  console.error('MCP tool set MISMATCH — want exactly [' + EXPECT_TOOLS.join(',') + '] got [' + names.join(',') + ']');
327
327
  process.exit(1);
@@ -15,7 +15,8 @@
15
15
  * six receiver-state facts before it execs, because setting its flag is a PROMISE of a
16
16
  * doorbell that may not exist. `entwurf pi` promises nothing pi does not already own: on a
17
17
  * host where the entwurf extension is not registered, `pi --entwurf-control` refuses itself
18
- * with `Error: Unknown option: --entwurf-control` and exit 1 (measured, pi 0.85.1). A
18
+ * with `Error: Unknown option: --entwurf-control` and exit 1 (`[측정 2026-09-20]` pi 0.86.0,
19
+ * sandbox HOME + PI_CODING_AGENT_DIR, that exact stderr line and exit 1). A
19
20
  * pre-check here would duplicate that refusal and go stale the day pi renames it.
20
21
  *
21
22
  * WHY ONE RECURSION FENCE AND NOT TWO. The sentinel closes the only real loop — a PATH
@@ -26,7 +27,9 @@
26
27
  *
27
28
  * WHY THE FLAG IS ADDED AND NEVER DEDUPLICATED. `pit`/`pius`-style operator wrappers already
28
29
  * pass `--entwurf-control`; passing it twice was measured byte-identical to passing it once
29
- * (pi 0.85.1, extension registered). The launcher therefore injects exactly ONE and leaves
30
+ * (pi 0.85.1, extension registered that receipt was NOT retaken at 0.86.0, because it needs a
31
+ * host with the extension registered and taking it would have meant opening a control session on
32
+ * the operator's own pi; the 0.86.0 re-measure above covers the unregistered refusal instead). The launcher therefore injects exactly ONE and leaves
30
33
  * the operator's copies alone — an argv scan would be code earning nothing.
31
34
  */
32
35
 
@@ -617,7 +617,7 @@ function sha256File(file: string): string {
617
617
  return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
618
618
  }
619
619
 
620
- function countOccurrences(haystack: string, needle: string): number {
620
+ export function countOccurrences(haystack: string, needle: string): number {
621
621
  let count = 0;
622
622
  let at = haystack.indexOf(needle);
623
623
  while (at !== -1) {