@junghanacs/entwurf 0.35.0 → 0.35.1

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 (59) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/VERIFY.md +5 -4
  3. package/docs/acp-backend-rail.md +28 -2
  4. package/docs/setup-clean-host.md +1 -1
  5. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +46 -2
  6. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +67 -0
  7. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +9 -20
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +11 -0
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +27 -1
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +13 -4
  13. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +119 -11
  14. package/package.json +2 -2
  15. package/pi-extensions/lib/acp/backend-adapter.ts +52 -2
  16. package/pi-extensions/lib/acp/model-ids.js +77 -0
  17. package/pi-extensions/lib/acp/models.ts +15 -22
  18. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +12 -0
  19. package/pi-extensions/lib/fresh-call-dispatch.ts +25 -1
  20. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  21. package/pi-extensions/lib/mux-fresh-call.ts +3 -0
  22. package/pi-extensions/lib/resume-launch-identity.ts +12 -4
  23. package/pi-extensions/meta-bridge-pi-durable.ts +153 -13
  24. package/run.sh +81 -20
  25. package/scripts/check-acp-backend-preflight.ts +10 -2
  26. package/scripts/check-acp-cortex.ts +7 -6
  27. package/scripts/check-acp-prompt-lifecycle.ts +2 -2
  28. package/scripts/check-acp-provider-surface.ts +1 -1
  29. package/scripts/check-acp-session-reuse.ts +5 -5
  30. package/scripts/check-acp-stop-reason.ts +2 -2
  31. package/scripts/check-acp-stream-hooks.ts +2 -2
  32. package/scripts/check-acp-usage-accounting.ts +2 -2
  33. package/scripts/check-fresh-call-dispatch.ts +47 -4
  34. package/scripts/check-gate-qualification.ts +6 -4
  35. package/scripts/check-probe-ordering.ts +13 -6
  36. package/scripts/check-resume-launch-identity.ts +38 -0
  37. package/scripts/mutants/acp-cortex.json +17 -8
  38. package/scripts/mutants/acp-set-model-readback.json +28 -0
  39. package/scripts/mutants/fresh-call-dispatch.json +11 -0
  40. package/scripts/mutants/herdr-dev-checkout.json +70 -0
  41. package/scripts/mutants/pi-durable.json +117 -0
  42. package/scripts/mutants/resume-launch-identity.json +15 -0
  43. package/scripts/mutants/v2-visible-resume.json +11 -0
  44. package/scripts/resolve-acp-bridge.ts +1 -1
  45. package/scripts/smoke-acp-bundled-mcp-live.ts +1 -1
  46. package/scripts/smoke-acp-carrier-augment-live.ts +1 -1
  47. package/scripts/smoke-acp-long-turn-live.ts +1 -1
  48. package/scripts/smoke-acp-mcp-live.ts +1 -1
  49. package/scripts/smoke-acp-memory-containment-live.ts +1 -1
  50. package/scripts/smoke-acp-ordering-probe-live.ts +1 -1
  51. package/scripts/smoke-acp-overlay-live.ts +1 -1
  52. package/scripts/smoke-acp-provider-live.ts +1 -1
  53. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  54. package/scripts/smoke-acp-session-reuse-live.ts +1 -1
  55. package/scripts/smoke-acp-skill-live.ts +1 -1
  56. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  57. package/scripts/smoke-acp-v2-send-live.ts +1 -1
  58. package/scripts/smoke-entwurf-chain-live.ts +1 -1
  59. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
@@ -51,8 +51,8 @@ function tctx(messages: Context["messages"]): TranscriptContext {
51
51
  return normalizeContext({ tools: PI_DECLARED_TOOLS, messages });
52
52
  }
53
53
 
54
- const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
55
- const opus = { id: "claude-opus-5" } as unknown as Model<Api>;
54
+ const sonnet = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
55
+ const opus = { id: "claude-opus-5-5" } as unknown as Model<Api>;
56
56
 
57
57
  type Stream = AsyncIterable<AssistantMessageEvent> & {
58
58
  result: () => Promise<{ stopReason: string; errorMessage?: string }>;
@@ -154,7 +154,7 @@ function makeHarness(recordDir: string, config: any = DEFAULT_RESOLVED_CONFIG, e
154
154
  newSessionSeq++;
155
155
  return { sessionId: `ACP-${newSessionSeq}` };
156
156
  },
157
- setSessionConfigOption: async () => ({}),
157
+ setSessionConfigOption: async (p: any) => ({ configOptions: [{ id: "model", currentValue: p.value }] }),
158
158
  prompt: async ({ sessionId, prompt }: any) => {
159
159
  promptCalls.push({ sessionId, text: prompt.map((b: { text: string }) => b.text).join("\n") });
160
160
  if (block) await block;
@@ -505,7 +505,7 @@ try {
505
505
  );
506
506
  const d1 = deltaText(t1);
507
507
  const iPrep = d1.indexOf("[acp: preparing claude session]");
508
- const iReady = d1.indexOf("[acp: session ready model=claude-sonnet-5]");
508
+ const iReady = d1.indexOf("[acp: session ready model=claude-sonnet-5-5]");
509
509
  const iSent = d1.indexOf("[acp: sending prompt]");
510
510
  assert.ok(
511
511
  iPrep >= 0 && iReady > iPrep && iSent > iReady,
@@ -533,7 +533,7 @@ try {
533
533
  const ctxWithNotice = tctx([
534
534
  { role: "user", content: "real user line", timestamp: 0 },
535
535
  mkAssistant([
536
- { type: "text", text: "\n[acp: session ready model=claude-sonnet-5]\n", textSignature: MARKER },
536
+ { type: "text", text: "\n[acp: session ready model=claude-sonnet-5-5]\n", textSignature: MARKER },
537
537
  { type: "text", text: "real assistant line" },
538
538
  ]),
539
539
  ]);
@@ -55,7 +55,7 @@ import { pathToFileURL } from "node:url";
55
55
  import type { Api, AssistantMessageEvent, Model } from "@earendil-works/pi-ai";
56
56
  import { normalizeContext } from "@earendil-works/pi-ai";
57
57
 
58
- const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
58
+ const sonnet = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
59
59
 
60
60
  type Stream = AsyncIterable<AssistantMessageEvent> & {
61
61
  result: () => Promise<{ stopReason: string; errorMessage?: string }>;
@@ -124,7 +124,7 @@ function makeHarness(recordDir: string, wireReason: string | null) {
124
124
  createConnection: (_child: any, handlers: any) => ({
125
125
  initialize: async () => ({ agentCapabilities: {} }),
126
126
  newSession: async () => ({ sessionId: "ACP-1" }),
127
- setSessionConfigOption: async () => ({}),
127
+ setSessionConfigOption: async (p: any) => ({ configOptions: [{ id: "model", currentValue: p.value }] }),
128
128
  prompt: async ({ sessionId }: any) => {
129
129
  await handlers.sessionUpdate({
130
130
  update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "PARTIAL" } },
@@ -35,7 +35,7 @@ import { pathToFileURL } from "node:url";
35
35
  import type { Api, AssistantMessageEvent, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
36
36
  import { normalizeContext } from "@earendil-works/pi-ai";
37
37
 
38
- const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
38
+ const sonnet = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
39
39
 
40
40
  type Stream = AsyncIterable<AssistantMessageEvent>;
41
41
 
@@ -93,7 +93,7 @@ function makeHarness(recordDir: string) {
93
93
  const makeConnection = (_handlers: any) => ({
94
94
  initialize: async () => ({ agentCapabilities: {} }),
95
95
  newSession: async () => ({ sessionId: "ACP-1" }),
96
- setSessionConfigOption: async () => ({}),
96
+ setSessionConfigOption: async (p: any) => ({ configOptions: [{ id: "model", currentValue: p.value }] }),
97
97
  prompt: async (params: any) => {
98
98
  // Deep-cloned: the capture must be the bytes that crossed the seam, not a
99
99
  // live reference a later mutation could rewrite.
@@ -61,7 +61,7 @@ import type {
61
61
  import { isContextOverflow, normalizeContext } from "@earendil-works/pi-ai";
62
62
  import { calculateContextTokens } from "@earendil-works/pi-coding-agent";
63
63
 
64
- const claude = { id: "claude-sonnet-5" } as unknown as Model<Api>;
64
+ const claude = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
65
65
  const cortex = { id: "cortex-auto" } as unknown as Model<Api>;
66
66
 
67
67
  type Stream = AsyncIterable<AssistantMessageEvent>;
@@ -160,7 +160,7 @@ function makeHarness(recordDir: string, scripts: TurnScript[]) {
160
160
  newSessionCalls++;
161
161
  return { sessionId: "ACP-1" };
162
162
  },
163
- setSessionConfigOption: async () => ({}),
163
+ setSessionConfigOption: async (p: any) => ({ configOptions: [{ id: "model", currentValue: p.value }] }),
164
164
  prompt: async ({ sessionId }: any) => {
165
165
  const script = scripts[turnIndex++] ?? {};
166
166
  const update: Record<string, unknown> = { sessionUpdate: "usage_update" };
@@ -26,6 +26,8 @@
26
26
  * FCD-RENDER-PER-RAIL each rail renders its own receipt; no universal tmux prose
27
27
  * FCD-HERDR-VIEW-NOT-ADDRESS the herdr receipt says its coordinates are a view
28
28
  * FCD-RECOVERY-VISIBLE a post-split failure shows closed vs orphan-unreclaimed:reason
29
+ * FCD-ACP-MODEL-EXACT a pi `entwurf/<id>` that is not an exact curated ACP id refuses on
30
+ * either rail before anything runs (#128)
29
31
  */
30
32
 
31
33
  import assert from "node:assert/strict";
@@ -112,10 +114,18 @@ async function main(): Promise<void> {
112
114
  // Up to the FIRST statement of the tmux path, which is the target capability question
113
115
  // (#95 lane B added a caller-side one beside it, so `const missing =` is no longer the
114
116
  // boundary — it is the line that FOLDS the two).
115
- const herdrBranch = dispatchCode.slice(
116
- dispatchCode.indexOf('if (selectFreshCallRail(env) === "herdr")'),
117
- dispatchCode.indexOf("\tconst targetMissing ="),
118
- );
117
+ // Fail closed on the anchors: a renamed or reordered line would otherwise slice an EMPTY branch,
118
+ // and every negative `!herdrBranch.includes(…)` below would pass on nothing (#128 review D2).
119
+ // The slice ends at the herdr branch's OWN return, not at a tmux-path statement, so a mutant
120
+ // that edits the tmux path cannot move this boundary and fail this cell for its reason.
121
+ const herdrStart = dispatchCode.indexOf('if (selectFreshCallRail(env) === "herdr")');
122
+ const herdrReturn = 'return { rail: "herdr", result: await herdrFreshCall(';
123
+ const herdrReturnAt = dispatchCode.indexOf(herdrReturn);
124
+ const herdrBranch =
125
+ herdrStart >= 0 && herdrReturnAt > herdrStart
126
+ ? dispatchCode.slice(herdrStart, herdrReturnAt + herdrReturn.length)
127
+ : "";
128
+ const herdrBranchLocated = herdrBranch.includes("herdrFreshCall(");
119
129
 
120
130
  // ── no fallback, either direction ────────────────────────────────────────────────────
121
131
  const incomplete = await dispatchFreshCall(REQUEST, { HERDR_ENV: "1" }, recordingSpawn().spawn);
@@ -124,6 +134,37 @@ async function main(): Promise<void> {
124
134
  incomplete.rail === "herdr" && !incomplete.result.ok && incomplete.result.reason === "herdr-context-missing",
125
135
  );
126
136
 
137
+ // ── #128: an entwurf/<id> pi launch names an EXACT curated ACP id ───────────────────────
138
+ {
139
+ const herdrSpawn = recordingSpawn();
140
+ const pi = (model: string) => ({ ...REQUEST, model });
141
+ const herdrRetired = await dispatchFreshCall(pi("entwurf/claude-sonnet-5"), HERDR_ENV, herdrSpawn.spawn);
142
+ const tmuxRetired = await dispatchFreshCall(pi(" entwurf/claude-opus-5 "), {}, recordingSpawn().spawn);
143
+ const current = recordingSpawn();
144
+ const herdrCurrent = await dispatchFreshCall(pi("entwurf/claude-opus-5-5"), HERDR_ENV, current.spawn);
145
+ const cortex = await dispatchFreshCall(pi("entwurf/cortex-claude-sonnet-5"), {}, recordingSpawn().spawn);
146
+ const otherBackend = await dispatchFreshCall(
147
+ { ...REQUEST, backend: "claude-code", model: "entwurf/claude-sonnet-5" },
148
+ {},
149
+ recordingSpawn().spawn,
150
+ );
151
+ const reasonOf = (d: Awaited<ReturnType<typeof dispatchFreshCall>>) => (d.result.ok ? null : d.result.reason);
152
+ ok(
153
+ "[QK:FCD-ACP-MODEL-EXACT] a pi sibling under the entwurf provider must name an EXACT curated ACP id: a retired Claude 5 id refuses as acp-model-not-curated on BOTH rails (trimmed like the rails read it) before herdr is asked anything, while the 5.5 id and a Cortex id pass through and another backend is not judged — Pi would otherwise open the retired id on a Cortex row by substring",
154
+ herdrRetired.rail === "herdr" &&
155
+ reasonOf(herdrRetired) === "acp-model-not-curated" &&
156
+ herdrSpawn.calls.length === 0 &&
157
+ tmuxRetired.rail === "tmux" &&
158
+ reasonOf(tmuxRetired) === "acp-model-not-curated" &&
159
+ reasonOf(herdrCurrent) !== "acp-model-not-curated" &&
160
+ current.calls.length > 0 &&
161
+ reasonOf(cortex) !== "acp-model-not-curated" &&
162
+ reasonOf(otherBackend) !== "acp-model-not-curated" &&
163
+ renderDispatchedFreshCall(herdrRetired).text.includes("exact curated ACP id") &&
164
+ renderDispatchedFreshCall(tmuxRetired).text.includes("exact curated ACP id"),
165
+ );
166
+ }
167
+
127
168
  const nonPilot = recordingSpawn();
128
169
  const refused = await dispatchFreshCall({ ...REQUEST, backend: "codex" }, HERDR_ENV, nonPilot.spawn);
129
170
  ok(
@@ -132,6 +173,7 @@ async function main(): Promise<void> {
132
173
  !refused.result.ok &&
133
174
  refused.result.reason === "herdr-backend-unsupported" &&
134
175
  nonPilot.calls.length === 0 &&
176
+ herdrBranchLocated &&
135
177
  // And structurally: the herdr branch names NEITHER capability preflight — not the
136
178
  // target one, and not the caller-seat one #95 lane B put beside it.
137
179
  !herdrBranch.includes("codexFreshPreflight") &&
@@ -153,6 +195,7 @@ async function main(): Promise<void> {
153
195
  !durableRefused.result.ok &&
154
196
  durableRefused.result.reason === "herdr-backend-unsupported" &&
155
197
  durable.calls.length === 0 &&
198
+ herdrBranchLocated &&
156
199
  !herdrBranch.includes("piDurable"),
157
200
  );
158
201
 
@@ -3441,6 +3441,7 @@ let manifestCount: number;
3441
3441
  "acp-launch-namespace": 2,
3442
3442
  "acp-overlay": 1,
3443
3443
  "acp-prompt-lifecycle": 15,
3444
+ "acp-set-model-readback": 2,
3444
3445
  "acp-stop-reason": 6,
3445
3446
  "acp-stream-hooks": 10,
3446
3447
  "acp-usage-accounting": 12,
@@ -3463,10 +3464,10 @@ let manifestCount: number;
3463
3464
  "pi-bridge-sender": 4,
3464
3465
  "pi-mcp-bridge": 2,
3465
3466
  "pi-mcp-register": 3,
3466
- "pi-durable": 58,
3467
+ "pi-durable": 65,
3467
3468
  "copilot-receive": 20,
3468
3469
  "entwurf-peers": 1,
3469
- "fresh-call-dispatch": 11,
3470
+ "fresh-call-dispatch": 12,
3470
3471
  "fresh-cut": 3,
3471
3472
  "gate-qualification": 10,
3472
3473
  "herdr-placement": 13,
@@ -3474,6 +3475,7 @@ let manifestCount: number;
3474
3475
  "herdr-plugin-profile": 14,
3475
3476
  "herdr-activation": 34,
3476
3477
  "herdr-plugin-build": 20,
3478
+ "herdr-dev-checkout": 4,
3477
3479
  "herdr-runtime-bootstrap": 31,
3478
3480
  "herdr-fresh-call": 38,
3479
3481
  "herdr-supply": 11,
@@ -3497,13 +3499,13 @@ let manifestCount: number;
3497
3499
  "release-gate": 24,
3498
3500
  "retired-sent-surface": 2,
3499
3501
  "resume-args": 6,
3500
- "resume-launch-identity": 6,
3502
+ "resume-launch-identity": 7,
3501
3503
  "self-address": 8,
3502
3504
  "setup-verdict": 14,
3503
3505
  "source-install": 2,
3504
3506
  "typing-call-fence": 1,
3505
3507
  "v2-surface": 13,
3506
- "v2-visible-resume": 17,
3508
+ "v2-visible-resume": 18,
3507
3509
  };
3508
3510
  const laneTally: Record<string, number> = {};
3509
3511
  for (const man of manifests) laneTally[man.lane] = (laneTally[man.lane] ?? 0) + man.mutants.length;
@@ -174,7 +174,7 @@ function makeRecordingConnection(calls: RecordedCall[]): AcpConnectionLike {
174
174
  },
175
175
  setSessionConfigOption: async (params) => {
176
176
  calls.push({ method: "setSessionConfigOption", params });
177
- return {};
177
+ return { configOptions: [{ id: "model", currentValue: (params as { value?: unknown }).value }] };
178
178
  },
179
179
  prompt: async (params) => {
180
180
  calls.push({ method: "prompt", params });
@@ -381,12 +381,19 @@ function makeRecordingConnection(calls: RecordedCall[]): AcpConnectionLike {
381
381
  "backend.ts still sends the clientInfo the probe mirrors",
382
382
  );
383
383
  // The claude adapter's enforceModel wire shape — the LIVE probe executes THIS
384
- // method (emitted twin), so its shape is pinned where it lives.
384
+ // method (emitted twin), so its shape is pinned where it lives. Sliced to the claude
385
+ // adapter: the cortex adapter makes the same call, and must not satisfy this pin.
386
+ const claudeAdapterSrc = ADAPTER_SRC.slice(
387
+ ADAPTER_SRC.indexOf("export const claudeAdapter"),
388
+ ADAPTER_SRC.indexOf("export const cortexAdapter"),
389
+ );
385
390
  assert.ok(
386
- ADAPTER_SRC.includes(
387
- 'await setConfig.call(connection, { sessionId: acpSessionId, configId: "model", value: nativeModelId });',
388
- ),
389
- 'claudeAdapter.enforceModel still sends setSessionConfigOption({configId:"model"})',
391
+ claudeAdapterSrc.length > 0 &&
392
+ claudeAdapterSrc.includes(
393
+ 'await setConfig.call(connection, {\n\t\t\tsessionId: acpSessionId,\n\t\t\tconfigId: "model",\n\t\t\tvalue: nativeModelId,\n\t\t});',
394
+ ) &&
395
+ claudeAdapterSrc.includes("confirmClaudeSetModelAnswer(answer, nativeModelId);"),
396
+ 'claudeAdapter.enforceModel still sends setSessionConfigOption({configId:"model"}) and reads its answer back',
390
397
  );
391
398
 
392
399
  // Permission policy: the runner's copy must mirror backend.ts's private
@@ -42,6 +42,7 @@ import assert from "node:assert/strict";
42
42
  import * as fs from "node:fs";
43
43
  import * as os from "node:os";
44
44
  import * as path from "node:path";
45
+ import { AcpModelNotCuratedError } from "../pi-extensions/lib/acp/model-ids.js";
45
46
  import { serializeMetaIdentity, upsertMetaSession } from "../pi-extensions/lib/meta-session.ts";
46
47
  import {
47
48
  ResumeBackendUnsupportedError,
@@ -162,6 +163,43 @@ function main(): void {
162
163
  );
163
164
  }
164
165
 
166
+ // 4d. #128 — the second EXPECTED refusal. Under the `entwurf` provider Pi resolves `--model`
167
+ // by substring, so a retired Claude 5 id would reopen on a Cortex row. The pair judged is the
168
+ // one the launch would EMIT: a bare Claude id recorded under another provider is routed to
169
+ // `entwurf` too, so judging the recorded provider alone would let that one through.
170
+ {
171
+ const acpModelLine = (provider: string, modelId: string): string =>
172
+ `${JSON.stringify({ type: "model_change", provider, modelId })}\n`;
173
+ const settle = (gid: string): { identity?: ReturnType<typeof resolveResumeLaunchIdentity>; err?: unknown } => {
174
+ try {
175
+ return { identity: resolveResumeLaunchIdentity(gid) };
176
+ } catch (err) {
177
+ return { err };
178
+ }
179
+ };
180
+ const minted = (native: string, name: string, provider: string, modelId: string): string =>
181
+ mintRecord(native, writeTranscript(name, sessionLine(native) + acpModelLine(provider, modelId)));
182
+ const retired = settle(
183
+ minted("0199a5a5-1111-4222-8333-444455556666", "acp-retired.jsonl", "entwurf", "claude-sonnet-5"),
184
+ );
185
+ const routed = settle(
186
+ minted("0199b5b5-1111-4222-8333-444455556666", "acp-routed.jsonl", "anthropic", "claude-opus-5"),
187
+ );
188
+ const current = settle(
189
+ minted("0199c5c5-1111-4222-8333-444455556666", "acp-current.jsonl", "entwurf", "claude-sonnet-5-5"),
190
+ );
191
+ ok(
192
+ "4d: an entwurf-provider resume of a non-curated id is a TYPED refusal — recorded under entwurf, or a bare Claude id the launch would route to entwurf — while the exact 5.5 id resolves unchanged [QK:RESUME-ID-ACP-EXACT-CURATION]",
193
+ retired.err instanceof AcpModelNotCuratedError &&
194
+ retired.err.reason === "acp-model-not-curated" &&
195
+ retired.err.model === "claude-sonnet-5" &&
196
+ routed.err instanceof AcpModelNotCuratedError &&
197
+ routed.err.model === "claude-opus-5" &&
198
+ current.identity?.provider === "entwurf" &&
199
+ current.identity.model === "claude-sonnet-5-5",
200
+ );
201
+ }
202
+
165
203
  const gidNoFile = mintRecord("0199dddd-1111-4222-8333-444455556666", null);
166
204
  rejects(gidNoFile, "no recorded transcriptPath", "4: transcriptPath null → nothing to resume");
167
205
 
@@ -5,21 +5,21 @@
5
5
  {
6
6
  "claim": "CORTEX-CURATED-FOUR-ROWS",
7
7
  "title": "dropping a curated row silently shrinks the GLG-decided 4-row cortex surface",
8
- "subject": "pi-extensions/lib/acp/models.ts",
8
+ "subject": "pi-extensions/lib/acp/model-ids.js",
9
9
  "find": [
10
- "export const SUPPORTED_CORTEX_MODEL_IDS = [",
10
+ "export const SUPPORTED_CORTEX_MODEL_IDS = /** @type {const} */ ([",
11
11
  "\t\"cortex-auto\",",
12
12
  "\t\"cortex-claude-opus-5\",",
13
13
  "\t\"cortex-claude-sonnet-5\",",
14
14
  "\t\"cortex-openai-gpt-5.4\",",
15
- "] as const;"
15
+ "]);"
16
16
  ],
17
17
  "replace": [
18
- "export const SUPPORTED_CORTEX_MODEL_IDS = [",
18
+ "export const SUPPORTED_CORTEX_MODEL_IDS = /** @type {const} */ ([",
19
19
  "\t\"cortex-auto\",",
20
20
  "\t\"cortex-claude-sonnet-5\",",
21
21
  "\t\"cortex-openai-gpt-5.4\",",
22
- "] as const;"
22
+ "]);"
23
23
  ],
24
24
  "gate": ["bash", "run.sh", "check-acp-cortex"],
25
25
  "timeoutSeconds": 300,
@@ -29,11 +29,20 @@
29
29
  {
30
30
  "claim": "CLAUDE-CURATED-THREE-ROWS",
31
31
  "title": "dropping a curated CLAUDE row shrinks the operator's model list while the exact-set comparison stays green — expected derives from the same constant, so only the independent count catches it",
32
- "subject": "pi-extensions/lib/acp/models.ts",
32
+ "subject": "pi-extensions/lib/acp/model-ids.js",
33
33
  "find": [
34
- "export const SUPPORTED_ANTHROPIC_MODEL_IDS = [\"claude-sonnet-5\", \"claude-opus-5\", \"claude-fable-5-1\"] as const;"
34
+ "export const SUPPORTED_ANTHROPIC_MODEL_IDS = /** @type {const} */ ([",
35
+ "\t\"claude-sonnet-5-5\",",
36
+ "\t\"claude-opus-5-5\",",
37
+ "\t\"claude-fable-5-1\",",
38
+ "]);"
39
+ ],
40
+ "replace": [
41
+ "export const SUPPORTED_ANTHROPIC_MODEL_IDS = /** @type {const} */ ([",
42
+ "\t\"claude-sonnet-5-5\",",
43
+ "\t\"claude-opus-5-5\",",
44
+ "]);"
35
45
  ],
36
- "replace": ["export const SUPPORTED_ANTHROPIC_MODEL_IDS = [\"claude-sonnet-5\", \"claude-opus-5\"] as const;"],
37
46
  "gate": ["bash", "run.sh", "check-acp-provider-surface"],
38
47
  "timeoutSeconds": 300,
39
48
  "signature": "[QK:CLAUDE-CURATED-THREE-ROWS]",
@@ -0,0 +1,28 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "acp-set-model-readback",
4
+ "mutants": [
5
+ {
6
+ "claim": "ACP-CLAUDE-SET-MODEL-READBACK",
7
+ "title": "the claude set-model answer is no longer read back — a missing model option or another row reaches the prompt, so a different model answers under the requested id",
8
+ "subject": "pi-extensions/lib/acp/backend-adapter.ts",
9
+ "find": ["\t\tconfirmClaudeSetModelAnswer(answer, nativeModelId);"],
10
+ "replace": ["\t\tvoid answer;"],
11
+ "gate": ["bash", "run.sh", "check-acp-set-model-readback"],
12
+ "timeoutSeconds": 60,
13
+ "signature": "[QK:ACP-CLAUDE-SET-MODEL-READBACK]",
14
+ "signatureSource": "pi-extensions/lib/acp/backend-adapter.test.ts"
15
+ },
16
+ {
17
+ "claim": "ACP-CLAUDE-SET-MODEL-ALIAS-EXACT",
18
+ "title": "an alias answer is matched by row name alone — the `default` row or a retired id's row carrying the 5.5 name answers silently under the 5.5 id",
19
+ "subject": "pi-extensions/lib/acp/backend-adapter.ts",
20
+ "find": ["\tif (alias !== undefined && currentValue === alias.value && selected?.name === alias.name) return;"],
21
+ "replace": ["\tif (alias !== undefined && selected?.name === alias.name) return;"],
22
+ "gate": ["bash", "run.sh", "check-acp-set-model-readback"],
23
+ "timeoutSeconds": 60,
24
+ "signature": "[QK:ACP-CLAUDE-SET-MODEL-ALIAS-EXACT]",
25
+ "signatureSource": "pi-extensions/lib/acp/backend-adapter.test.ts"
26
+ }
27
+ ]
28
+ }
@@ -138,6 +138,17 @@
138
138
  "timeoutSeconds": 120,
139
139
  "signature": "[QK:FCD-NO-SYNC-SPAWN]",
140
140
  "signatureSource": "scripts/check-fresh-call-dispatch.ts"
141
+ },
142
+ {
143
+ "claim": "FCD-ACP-MODEL-EXACT",
144
+ "title": "dropping the exact-curation check lets a retired entwurf/claude-sonnet-5 reach a rail, where Pi opens it on the Cortex row by substring",
145
+ "subject": "pi-extensions/lib/fresh-call-dispatch.ts",
146
+ "find": ["\treturn !isCuratedAcpModelId(trimmed.slice(slash + 1));"],
147
+ "replace": ["\treturn false;"],
148
+ "gate": ["bash", "run.sh", "check-fresh-call-dispatch"],
149
+ "timeoutSeconds": 120,
150
+ "signature": "[QK:FCD-ACP-MODEL-EXACT]",
151
+ "signatureSource": "scripts/check-fresh-call-dispatch.ts"
141
152
  }
142
153
  ]
143
154
  }
@@ -0,0 +1,70 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "herdr-dev-checkout",
4
+ "mutants": [
5
+ {
6
+ "claim": "DEV-MENU-EXPLICIT-CONFIRM",
7
+ "title": "the source menu invokes apply after an unconfirmed selection instead of requiring exact DEV; the existing helper guards do not substitute for the operator's choice",
8
+ "subject": "run.sh",
9
+ "find": [" if [ \"$confirm\" != DEV ]; then"],
10
+ "replace": [" if false; then"],
11
+ "gate": ["bash", "run.sh", "check-tests-beside-behavior"],
12
+ "timeoutSeconds": 600,
13
+ "signature": "[QK:DEV-MENU-EXPLICIT-CONFIRM]",
14
+ "signatureSource": "plugins/herdr/use-dev-checkout.test.mjs",
15
+ "build": {
16
+ "argv": ["bash", "scripts/build-bridge.sh"],
17
+ "output": "mcp/entwurf-bridge/dist"
18
+ }
19
+ },
20
+ {
21
+ "claim": "HERDR-DEV-PREPARE-BEFORE-TEARDOWN",
22
+ "title": "deactivation must be preceded by a successful source bridge proof, not merely a build",
23
+ "subject": "plugins/herdr/use-dev-checkout.sh",
24
+ "find": ["(cd \"$ROOT\" && pnpm run build-bridge)", "\"$ROOT/run.sh\" check-bridge"],
25
+ "replace": ["(cd \"$ROOT\" && pnpm run build-bridge)", ":"],
26
+ "gate": ["bash", "run.sh", "check-tests-beside-behavior"],
27
+ "timeoutSeconds": 600,
28
+ "signatureSource": "plugins/herdr/use-dev-checkout.test.mjs",
29
+ "build": {
30
+ "argv": ["bash", "scripts/build-bridge.sh"],
31
+ "output": "mcp/entwurf-bridge/dist"
32
+ },
33
+ "signature": "[QK:HERDR-DEV-PREPARE-BEFORE-TEARDOWN]"
34
+ },
35
+ {
36
+ "claim": "HERDR-DEV-RECHECK-BEFORE-TEARDOWN",
37
+ "title": "an old-runtime process appearing during source preparation must refuse before the owner is deactivated",
38
+ "subject": "plugins/herdr/use-dev-checkout.sh",
39
+ "find": ["quiescent", "", "stage=owned-deactivate"],
40
+ "replace": [":", "", "stage=owned-deactivate"],
41
+ "gate": ["bash", "run.sh", "check-tests-beside-behavior"],
42
+ "timeoutSeconds": 600,
43
+ "signatureSource": "plugins/herdr/use-dev-checkout.test.mjs",
44
+ "build": {
45
+ "argv": ["bash", "scripts/build-bridge.sh"],
46
+ "output": "mcp/entwurf-bridge/dist"
47
+ },
48
+ "signature": "[QK:HERDR-DEV-RECHECK-BEFORE-TEARDOWN]"
49
+ },
50
+ {
51
+ "claim": "HERDR-DEV-NO-SUCCESS-ON-DRIFT",
52
+ "title": "actual Pi model-id drift must remain non-green rather than claiming successful developer wiring",
53
+ "subject": "plugins/herdr/use-dev-checkout.sh",
54
+ "find": [
55
+ "[ \"$ACTUAL_IDS\" = \"$SOURCE_IDS\" ] || { echo '[use-dev-checkout] actual Pi model IDs differ from the explicit checkout provider; not accepted.' >&2; exit 1; }"
56
+ ],
57
+ "replace": [
58
+ "true || { echo '[use-dev-checkout] actual Pi model IDs differ from the explicit checkout provider; not accepted.' >&2; exit 1; }"
59
+ ],
60
+ "gate": ["bash", "run.sh", "check-tests-beside-behavior"],
61
+ "timeoutSeconds": 600,
62
+ "signatureSource": "plugins/herdr/use-dev-checkout.test.mjs",
63
+ "build": {
64
+ "argv": ["bash", "scripts/build-bridge.sh"],
65
+ "output": "mcp/entwurf-bridge/dist"
66
+ },
67
+ "signature": "[QK:HERDR-DEV-NO-SUCCESS-ON-DRIFT]"
68
+ }
69
+ ]
70
+ }
@@ -277,6 +277,123 @@
277
277
  "output": "mcp/entwurf-bridge/dist"
278
278
  }
279
279
  },
280
+ {
281
+ "claim": "PI-DURABLE-RECEIVER-READ-WITHDRAWS-STALE",
282
+ "title": "an inbox read no longer withdraws the notices its drain made stale, so each still-queued notice is placed later as its own run and an empty read (#140)",
283
+ "subject": "pi-extensions/meta-bridge-pi-durable.ts",
284
+ "find": ["\t\t\t\tawait withdrawDrained(context);\n"],
285
+ "replace": [""],
286
+ "gate": ["bash", "run.sh", "check-pi-durable-beside"],
287
+ "timeoutSeconds": 300,
288
+ "signature": "[QK:PI-DURABLE-RECEIVER-READ-WITHDRAWS-STALE]",
289
+ "signatureSource": "pi-extensions/meta-bridge-pi-durable.test.ts",
290
+ "build": {
291
+ "argv": ["bash", "scripts/build-bridge.sh"],
292
+ "output": "mcp/entwurf-bridge/dist"
293
+ }
294
+ },
295
+ {
296
+ "claim": "PI-DURABLE-RECEIVER-FOREIGN-GARDEN-READ-KEEPS",
297
+ "title": "the per-notice file predicate is bypassed, so any read — another garden's included — withdraws notices whose own mail is still unread, and that mail is never announced again",
298
+ "subject": "pi-extensions/meta-bridge-pi-durable.ts",
299
+ "find": ["\t\t\tif (notice.fresh.some((name) => unread.has(`${name}.delivered`))) continue;\n"],
300
+ "replace": [""],
301
+ "gate": ["bash", "run.sh", "check-pi-durable-beside"],
302
+ "timeoutSeconds": 300,
303
+ "signature": "[QK:PI-DURABLE-RECEIVER-FOREIGN-GARDEN-READ-KEEPS]",
304
+ "signatureSource": "pi-extensions/meta-bridge-pi-durable.test.ts",
305
+ "build": {
306
+ "argv": ["bash", "scripts/build-bridge.sh"],
307
+ "output": "mcp/entwurf-bridge/dist"
308
+ }
309
+ },
310
+ {
311
+ "claim": "PI-DURABLE-RECEIVER-PARTIAL-DRAIN-WITHDRAWS-ONLY-ARCHIVED",
312
+ "title": "the withdrawal runs only after a successful read, so a drain that failed part-way leaves the notices of the mail it already archived queued, stale",
313
+ "subject": "pi-extensions/meta-bridge-pi-durable.ts",
314
+ "find": [
315
+ "\t\t\treturn await call();\n\t\t} finally {\n\t\t\ttry {\n\t\t\t\tawait withdrawDrained(context);\n\t\t\t} finally {\n\t\t\t\tleaveGate();\n\t\t\t}\n\t\t}\n"
316
+ ],
317
+ "replace": [
318
+ "\t\t\tconst answered = await call();\n\t\t\tif (!answered.isError) await withdrawDrained(context);\n\t\t\treturn answered;\n\t\t} finally {\n\t\t\tleaveGate();\n\t\t}\n"
319
+ ],
320
+ "gate": ["bash", "run.sh", "check-pi-durable-beside"],
321
+ "timeoutSeconds": 300,
322
+ "signature": "[QK:PI-DURABLE-RECEIVER-PARTIAL-DRAIN-WITHDRAWS-ONLY-ARCHIVED]",
323
+ "signatureSource": "pi-extensions/meta-bridge-pi-durable.test.ts",
324
+ "build": {
325
+ "argv": ["bash", "scripts/build-bridge.sh"],
326
+ "output": "mcp/entwurf-bridge/dist"
327
+ }
328
+ },
329
+ {
330
+ "claim": "PI-DURABLE-RECEIVER-READ-SERIALIZED",
331
+ "title": "an inbox read no longer waits for the doorbell's gate, so a drain that overtakes an admission in flight withdraws before that notice's handle exists and the notice stays queued, stale",
332
+ "subject": "pi-extensions/meta-bridge-pi-durable.ts",
333
+ "find": ["\t\tawait enterGate();\n"],
334
+ "replace": [""],
335
+ "gate": ["bash", "run.sh", "check-pi-durable-beside"],
336
+ "timeoutSeconds": 300,
337
+ "signature": "[QK:PI-DURABLE-RECEIVER-READ-SERIALIZED]",
338
+ "signatureSource": "pi-extensions/meta-bridge-pi-durable.test.ts",
339
+ "build": {
340
+ "argv": ["bash", "scripts/build-bridge.sh"],
341
+ "output": "mcp/entwurf-bridge/dist"
342
+ }
343
+ },
344
+ {
345
+ "claim": "PI-DURABLE-RECEIVER-ARRIVAL-DURING-READ-ANNOUNCED",
346
+ "title": "the gate opening after a read forgets the signal that landed while it was held, so mail that arrived after the drain is never announced until another arrival",
347
+ "subject": "pi-extensions/meta-bridge-pi-durable.ts",
348
+ "find": ["\t\tif (pending) void ring(\"signal\");\n"],
349
+ "replace": [""],
350
+ "gate": ["bash", "run.sh", "check-pi-durable-beside"],
351
+ "timeoutSeconds": 300,
352
+ "signature": "[QK:PI-DURABLE-RECEIVER-ARRIVAL-DURING-READ-ANNOUNCED]",
353
+ "signatureSource": "pi-extensions/meta-bridge-pi-durable.test.ts",
354
+ "build": {
355
+ "argv": ["bash", "scripts/build-bridge.sh"],
356
+ "output": "mcp/entwurf-bridge/dist"
357
+ }
358
+ },
359
+ {
360
+ "claim": "PI-DURABLE-RECEIVER-WITHDRAW-FAILURE-KEEPS-READ",
361
+ "title": "a notice whose native withdraw rejected is put back among the held ones, so every later read retries that withdraw and names the same failure again",
362
+ "subject": "pi-extensions/meta-bridge-pi-durable.ts",
363
+ "find": [
364
+ "\t\t\t\tnoteWithdrawalFailure({ reason: \"abort-failed\", requestId: notice.requestId, detail: describeError(error) });\n"
365
+ ],
366
+ "replace": [
367
+ "\t\t\t\tnoteWithdrawalFailure({ reason: \"abort-failed\", requestId: notice.requestId, detail: describeError(error) });\n\t\t\t\tnotices.push(notice);\n"
368
+ ],
369
+ "gate": ["bash", "run.sh", "check-pi-durable-beside"],
370
+ "timeoutSeconds": 300,
371
+ "signature": "[QK:PI-DURABLE-RECEIVER-WITHDRAW-FAILURE-KEEPS-READ]",
372
+ "signatureSource": "pi-extensions/meta-bridge-pi-durable.test.ts",
373
+ "build": {
374
+ "argv": ["bash", "scripts/build-bridge.sh"],
375
+ "output": "mcp/entwurf-bridge/dist"
376
+ }
377
+ },
378
+ {
379
+ "claim": "PI-DURABLE-RECEIVER-INSPECTION-FAILURE-KEEPS-NOTICES",
380
+ "title": "a mailbox that could not be listed is judged as if it held no unread mail, so every held notice is withdrawn while its mail may still be unread",
381
+ "subject": "pi-extensions/meta-bridge-pi-durable.ts",
382
+ "find": [
383
+ "\t\t\tnoteWithdrawalFailure({ reason: \"inspection-failed\", detail: `${inbox}: ${describeError(error)}` });\n\t\t\treturn;\n"
384
+ ],
385
+ "replace": [
386
+ "\t\t\tnoteWithdrawalFailure({ reason: \"inspection-failed\", detail: `${inbox}: ${describeError(error)}` });\n\t\t\tunread = new Set();\n"
387
+ ],
388
+ "gate": ["bash", "run.sh", "check-pi-durable-beside"],
389
+ "timeoutSeconds": 300,
390
+ "signature": "[QK:PI-DURABLE-RECEIVER-INSPECTION-FAILURE-KEEPS-NOTICES]",
391
+ "signatureSource": "pi-extensions/meta-bridge-pi-durable.test.ts",
392
+ "build": {
393
+ "argv": ["bash", "scripts/build-bridge.sh"],
394
+ "output": "mcp/entwurf-bridge/dist"
395
+ }
396
+ },
280
397
  {
281
398
  "claim": "PI-DURABLE-CARRIER-RELOCATION-TWO-RULES",
282
399
  "title": "a carrier file's import of a sibling keeps its .ts specifier, so the installed carrier asks Node for a file that was never emitted",
@@ -91,6 +91,21 @@
91
91
  "timeoutSeconds": 60,
92
92
  "signature": "[QK:RESUME-ID-SESSION-ABSOLUTE]",
93
93
  "signatureSource": "scripts/check-resume-launch-identity.ts"
94
+ },
95
+ {
96
+ "claim": "RESUME-ID-ACP-EXACT-CURATION",
97
+ "title": "judging the RECORDED provider instead of the emitted one lets a bare Claude 5 id recorded under another provider reopen under entwurf on a Cortex row",
98
+ "subject": "pi-extensions/lib/resume-launch-identity.ts",
99
+ "find": [
100
+ "\tif (provider === PROVIDER_ID && !isCuratedAcpModelId(model)) throw new AcpModelNotCuratedError(model);"
101
+ ],
102
+ "replace": [
103
+ "\tif (identity.provider === PROVIDER_ID && !isCuratedAcpModelId(model)) throw new AcpModelNotCuratedError(model);"
104
+ ],
105
+ "gate": ["bash", "run.sh", "check-resume-launch-identity"],
106
+ "timeoutSeconds": 60,
107
+ "signature": "[QK:RESUME-ID-ACP-EXACT-CURATION]",
108
+ "signatureSource": "scripts/check-resume-launch-identity.ts"
94
109
  }
95
110
  ]
96
111
  }
@@ -210,6 +210,17 @@
210
210
  "timeoutSeconds": 60,
211
211
  "signature": "[QK:V2RESUME-SHARED-ID-GRAMMAR]",
212
212
  "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
213
+ },
214
+ {
215
+ "claim": "V2RESUME-ACP-MODEL-REFUSED",
216
+ "title": "not matching AcpModelNotCuratedError turns the named refusal into a raw error escaping the resume verb",
217
+ "subject": "pi-extensions/lib/entwurf-v2-visible-resume.ts",
218
+ "find": ["\t\t\tif (err instanceof AcpModelNotCuratedError) {"],
219
+ "replace": ["\t\t\tif (err instanceof AcpModelNotCuratedError && false) {"],
220
+ "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
221
+ "timeoutSeconds": 60,
222
+ "signature": "[QK:V2RESUME-ACP-MODEL-REFUSED]",
223
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
213
224
  }
214
225
  ]
215
226
  }