@junghanacs/entwurf 0.34.2 → 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 (70) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/README.md +7 -0
  3. package/VERIFY.md +16 -5
  4. package/demo/README.md +109 -175
  5. package/demo/demo-baseline.sh +5 -1
  6. package/demo/demo.sh +6 -2
  7. package/demo/herdr-pi-cc-20261011.provenance.json +64 -0
  8. package/docs/acp-backend-rail.md +28 -2
  9. package/docs/assets/entwurf-demo.gif +0 -0
  10. package/docs/setup-clean-host.md +1 -1
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +46 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +67 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +9 -20
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +11 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +27 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +13 -4
  19. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +119 -11
  20. package/package.json +4 -2
  21. package/pi-extensions/lib/acp/backend-adapter.ts +52 -2
  22. package/pi-extensions/lib/acp/model-ids.js +77 -0
  23. package/pi-extensions/lib/acp/models.ts +15 -22
  24. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +12 -0
  25. package/pi-extensions/lib/fresh-call-dispatch.ts +25 -1
  26. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  27. package/pi-extensions/lib/mux-fresh-call.ts +3 -0
  28. package/pi-extensions/lib/resume-launch-identity.ts +12 -4
  29. package/pi-extensions/meta-bridge-pi-durable.ts +153 -13
  30. package/run.sh +118 -20
  31. package/scripts/check-acp-backend-preflight.ts +10 -2
  32. package/scripts/check-acp-cortex.ts +7 -6
  33. package/scripts/check-acp-prompt-lifecycle.ts +2 -2
  34. package/scripts/check-acp-provider-surface.ts +1 -1
  35. package/scripts/check-acp-session-reuse.ts +5 -5
  36. package/scripts/check-acp-stop-reason.ts +2 -2
  37. package/scripts/check-acp-stream-hooks.ts +2 -2
  38. package/scripts/check-acp-usage-accounting.ts +2 -2
  39. package/scripts/check-fresh-call-dispatch.ts +47 -4
  40. package/scripts/check-gate-qualification.ts +8 -5
  41. package/scripts/check-probe-ordering.ts +13 -6
  42. package/scripts/check-release-gate-outcomes.ts +159 -1
  43. package/scripts/check-resume-launch-identity.ts +38 -0
  44. package/scripts/ci-qualify-decide.sh +114 -17
  45. package/scripts/mutants/acp-cortex.json +17 -8
  46. package/scripts/mutants/acp-set-model-readback.json +28 -0
  47. package/scripts/mutants/demo-reproducibility.json +155 -0
  48. package/scripts/mutants/fresh-call-dispatch.json +11 -0
  49. package/scripts/mutants/herdr-dev-checkout.json +70 -0
  50. package/scripts/mutants/pi-durable.json +117 -0
  51. package/scripts/mutants/release-gate.json +28 -2
  52. package/scripts/mutants/resume-launch-identity.json +15 -0
  53. package/scripts/mutants/v2-visible-resume.json +11 -0
  54. package/scripts/resolve-acp-bridge.ts +1 -1
  55. package/scripts/smoke-acp-bundled-mcp-live.ts +1 -1
  56. package/scripts/smoke-acp-carrier-augment-live.ts +1 -1
  57. package/scripts/smoke-acp-long-turn-live.ts +1 -1
  58. package/scripts/smoke-acp-mcp-live.ts +1 -1
  59. package/scripts/smoke-acp-memory-containment-live.ts +1 -1
  60. package/scripts/smoke-acp-ordering-probe-live.ts +1 -1
  61. package/scripts/smoke-acp-overlay-live.ts +1 -1
  62. package/scripts/smoke-acp-provider-live.ts +1 -1
  63. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  64. package/scripts/smoke-acp-session-reuse-live.ts +1 -1
  65. package/scripts/smoke-acp-skill-live.ts +1 -1
  66. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  67. package/scripts/smoke-acp-v2-send-live.ts +1 -1
  68. package/scripts/smoke-entwurf-chain-live.ts +1 -1
  69. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  70. package/scripts/tsconfig.json +1 -0
@@ -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,
@@ -3455,6 +3456,7 @@ let manifestCount: number;
3455
3456
  "codex-native": 82,
3456
3457
  "compaction-send-guard": 7,
3457
3458
  "control-socket-disconnect": 4,
3459
+ "demo-reproducibility": 12,
3458
3460
  "copilot-birth": 19,
3459
3461
  "copilot-launch": 14,
3460
3462
  "pi-launch": 9,
@@ -3462,10 +3464,10 @@ let manifestCount: number;
3462
3464
  "pi-bridge-sender": 4,
3463
3465
  "pi-mcp-bridge": 2,
3464
3466
  "pi-mcp-register": 3,
3465
- "pi-durable": 58,
3467
+ "pi-durable": 65,
3466
3468
  "copilot-receive": 20,
3467
3469
  "entwurf-peers": 1,
3468
- "fresh-call-dispatch": 11,
3470
+ "fresh-call-dispatch": 12,
3469
3471
  "fresh-cut": 3,
3470
3472
  "gate-qualification": 10,
3471
3473
  "herdr-placement": 13,
@@ -3473,6 +3475,7 @@ let manifestCount: number;
3473
3475
  "herdr-plugin-profile": 14,
3474
3476
  "herdr-activation": 34,
3475
3477
  "herdr-plugin-build": 20,
3478
+ "herdr-dev-checkout": 4,
3476
3479
  "herdr-runtime-bootstrap": 31,
3477
3480
  "herdr-fresh-call": 38,
3478
3481
  "herdr-supply": 11,
@@ -3493,16 +3496,16 @@ let manifestCount: number;
3493
3496
  "omp-fresh": 24,
3494
3497
  "omp-receive": 11,
3495
3498
  "probe-ordering": 1,
3496
- "release-gate": 22,
3499
+ "release-gate": 24,
3497
3500
  "retired-sent-surface": 2,
3498
3501
  "resume-args": 6,
3499
- "resume-launch-identity": 6,
3502
+ "resume-launch-identity": 7,
3500
3503
  "self-address": 8,
3501
3504
  "setup-verdict": 14,
3502
3505
  "source-install": 2,
3503
3506
  "typing-call-fence": 1,
3504
3507
  "v2-surface": 13,
3505
- "v2-visible-resume": 17,
3508
+ "v2-visible-resume": 18,
3506
3509
  };
3507
3510
  const laneTally: Record<string, number> = {};
3508
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
@@ -43,7 +43,7 @@
43
43
  // Pure + subprocess, no network/model — IN pnpm run check:full.
44
44
 
45
45
  import { strict as assert } from "node:assert";
46
- import { execFileSync } from "node:child_process";
46
+ import { execFileSync, spawnSync } from "node:child_process";
47
47
  import { copyFileSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
48
48
  import { tmpdir } from "node:os";
49
49
  import { basename, join } from "node:path";
@@ -650,6 +650,164 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
650
650
  }
651
651
  }
652
652
 
653
+ // ===========================================================================
654
+ // 8g. #137: TOML data-subject hunks, not path identity; Markdown/JSON data and
655
+ // code/signatures/machinery remain file-level. Hermetic like 8f: real history is NOT
656
+ // in a qualification snapshot.
657
+ // ==========================================================================
658
+ {
659
+ const tmp = mkdtempSync(join(tmpdir(), "entwurf-qualify-hunks-"));
660
+ const git = (...args: string[]) =>
661
+ execFileSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "user.email=g@e", "-c", "user.name=g", ...args], {
662
+ cwd: tmp,
663
+ stdio: "ignore",
664
+ });
665
+ const head = () => execFileSync("git", ["rev-parse", "HEAD"], { cwd: tmp, encoding: "utf8" }).trim();
666
+ const data = 'version = "1"\n[[panes]]\ncommand = ["node", "build.mjs"]\n# outside\n';
667
+ const wrong: string[] = [];
668
+ try {
669
+ git("init", "-q", "-b", "main");
670
+ mkdirSync(join(tmp, "scripts/mutants"), { recursive: true });
671
+ mkdirSync(join(tmp, "fixture"));
672
+ mkdirSync(join(tmp, "scripts/fixtures"));
673
+ copyFileSync(join(REPO_DIR, "scripts/ci-qualify-decide.sh"), join(tmp, "scripts/ci-qualify-decide.sh"));
674
+ const manifest = JSON.stringify({
675
+ mutants: [
676
+ { subject: "fixture/data.toml", find: ["[[panes]]"] },
677
+ { subject: "fixture/data.toml", find: ['command = ["node", "build.mjs"]'] },
678
+ { subject: "fixture/data.toml", find: ["[[panes]]", 'command = ["node", "build.mjs"]'] },
679
+ { subject: "fixture/code.ts", find: ["return 1"], signatureSource: "fixture/signature.md" },
680
+ { subject: "fixture/signature.md", find: ["signature"] },
681
+ { subject: "fixture/joined.toml", find: ["alpha = 1", "beta = 2"] },
682
+ { subject: "fixture/doc.md", find: ["Legacy rows are documented: kept for history."] },
683
+ { subject: "fixture/reg.json", find: ['"mode": "self-fetch",'] },
684
+ { subject: "scripts/fixtures/pin.json", find: ['"pinned": 1'] },
685
+ { subject: "scripts/ci-qualify-decide.sh", find: ["set -euo pipefail"] },
686
+ ],
687
+ });
688
+ writeFileSync(join(tmp, "scripts/mutants/fixture.json"), manifest);
689
+ writeFileSync(join(tmp, "fixture/data.toml"), data);
690
+ writeFileSync(join(tmp, "fixture/code.ts"), "// outside\nfunction f() { return 1; }\n");
691
+ writeFileSync(join(tmp, "fixture/signature.md"), "signature\n# outside\n");
692
+ writeFileSync(join(tmp, "fixture/joined.toml"), "version = 1\nalpha = 1\nbeta = 2\n");
693
+ const doc = "# Doc\nLegacy rows are documented: kept for history.\n# outside\n";
694
+ const reg = '{\n"copilot": {\n"mode": "self-fetch",\n"level": 6\n}\n}\n';
695
+ writeFileSync(join(tmp, "fixture/doc.md"), doc);
696
+ writeFileSync(join(tmp, "fixture/reg.json"), reg);
697
+ writeFileSync(join(tmp, "scripts/fixtures/pin.json"), '{\n"version": 1,\n"pinned": 1\n}\n');
698
+ git("add", "-A");
699
+ git("commit", "-qm", "seed");
700
+ const base = head();
701
+ const nonToml: string[] = [];
702
+ const check = (name: string, expected: boolean, reason?: string, before = base, into = wrong) => {
703
+ const r = spawnSync("bash", [join(tmp, "scripts/ci-qualify-decide.sh"), before, head()], {
704
+ cwd: tmp,
705
+ encoding: "utf8",
706
+ env: { ...process.env, CI_EVENT_NAME: "push", CI_FORCED: "false" },
707
+ });
708
+ if (r.status !== 0 || r.stdout.trim() !== `run_body=${expected}` || (reason && !r.stderr.includes(reason)))
709
+ into.push(`${name}: status=${r.status}, out=${r.stdout.trim()}, err=${r.stderr.trim()}`);
710
+ };
711
+ const commit = () => {
712
+ git("add", "-A");
713
+ git("commit", "-qm", "cell");
714
+ };
715
+ const reset = () => git("reset", "--hard", base); // ONLY this test-owned throwaway repository.
716
+ writeFileSync(join(tmp, "fixture/data.toml"), data.replace('version = "1"', 'version = "2"'));
717
+ commit();
718
+ check("outside", false, "fixture/data.toml: no find range touched");
719
+ // A replay must read HEAD's manifest, not an unrelated dirty worktree's find.
720
+ writeFileSync(
721
+ join(tmp, "scripts/mutants/fixture.json"),
722
+ '{"mutants":[{"subject":"fixture/data.toml","find":["version"]}]}',
723
+ );
724
+ check("HEAD manifest, not dirty worktree", false);
725
+ reset();
726
+ for (const [name, content] of [
727
+ ["panes line", data.replace("[[panes]]", "[[panes]] # changed")],
728
+ ["command line", data.replace('command = ["node", "build.mjs"]', 'command = ["node", "build.mjs"] # changed')],
729
+ ["moved", `# inserted above\n${data}`],
730
+ ["ambiguous", `${data}[[panes]]\n`],
731
+ ["missing find", data.replace("[[panes]]", "[[other]]")],
732
+ ] as const) {
733
+ writeFileSync(join(tmp, "fixture/data.toml"), content);
734
+ commit();
735
+ check(name, true);
736
+ reset();
737
+ }
738
+ git("rm", "fixture/data.toml");
739
+ commit();
740
+ check("deleted subject", true);
741
+ reset();
742
+ git("mv", "fixture/data.toml", "fixture/renamed.toml");
743
+ commit();
744
+ check("renamed subject", true);
745
+ reset();
746
+ writeFileSync(join(tmp, "fixture/code.ts"), "// outside changed\nfunction f() { return 1; }\n");
747
+ commit();
748
+ check("code outside find remains file-level", true);
749
+ reset();
750
+ writeFileSync(join(tmp, "fixture/signature.md"), "signature\n# outside changed\n");
751
+ commit();
752
+ check("signature also data subject remains file-level", true);
753
+ reset();
754
+ writeFileSync(join(tmp, "fixture/joined.toml"), "version = 1\nalpha = 1\nbeta = 2 # changed\n");
755
+ commit();
756
+ check("only multiline find, second line touched", true);
757
+ reset();
758
+ writeFileSync(join(tmp, "scripts/fixtures/pin.json"), '{\n"version": 2,\n"pinned": 1\n}\n');
759
+ commit();
760
+ check("machinery also data subject remains file-level", true);
761
+ reset();
762
+ writeFileSync(
763
+ join(tmp, "scripts/ci-qualify-decide.sh"),
764
+ `${readFileSync(join(tmp, "scripts/ci-qualify-decide.sh"), "utf8")}\n# outside find\n`,
765
+ );
766
+ commit();
767
+ check("machinery also subject remains file-level", true);
768
+ reset();
769
+ // Outside an untouched exact-once span, a shorter presence literal (measured on DELIVERY.md)
770
+ // or a later JSON duplicate key (JSON.parse: last wins) can re-satisfy the oracle.
771
+ writeFileSync(join(tmp, "fixture/doc.md"), `${doc}Legacy rows are documented\n`);
772
+ commit();
773
+ check("markdown restatement outside find", true, "conservative:", base, nonToml);
774
+ reset();
775
+ writeFileSync(
776
+ join(tmp, "fixture/reg.json"),
777
+ reg.replace("6\n}\n}\n", '6\n},\n"copilot": { "level": 6, "mode": "self-fetch" }\n}\n'),
778
+ );
779
+ commit();
780
+ check("json duplicate key outside find", true, "conservative:", base, nonToml);
781
+ reset();
782
+ // The first quoted name must still fail open after a >pipe-buffer list:
783
+ // printf | grep -q can lose its match to SIGPIPE under pipefail.
784
+ writeFileSync(join(tmp, '000"quoted'), "x\n");
785
+ for (let i = 0; i < 768; i++) writeFileSync(join(tmp, "fixture", `${i}_${"x".repeat(180)}.txt`), "x\n");
786
+ commit();
787
+ check("large quoted changed-name list", true, "quoted changed path cannot be read honestly");
788
+ reset();
789
+ check("unreadable range", true, "fail-open 4", "f".repeat(40));
790
+ git("rm", "scripts/mutants/fixture.json");
791
+ commit();
792
+ check("missing manifests", true);
793
+ assert.ok(
794
+ wrong.length === 0,
795
+ "[QK:QUALIFY-FILTER-SUBJECT-HUNKS] only untouched, unmoved, exact-once TOML find ranges may skip; " +
796
+ "unknown ranges run, and code/signature/machinery retain file-level protection. " +
797
+ wrong.join("; "),
798
+ );
799
+ assert.ok(
800
+ nonToml.length === 0,
801
+ "[QK:QUALIFY-FILTER-TOML-ONLY-REFINEMENT] Markdown and JSON subjects stay file-level: an untouched exact-once " +
802
+ "find does not pin kill-power when a shorter presence literal or a later duplicate JSON key outside it can " +
803
+ "re-satisfy the oracle. " +
804
+ nonToml.join("; "),
805
+ );
806
+ } finally {
807
+ rmSync(tmp, { recursive: true, force: true });
808
+ }
809
+ }
810
+
653
811
  // ===========================================================================
654
812
  // 9. Every gate a mutant manifest NAMES runs inside `pnpm run check:full`
655
813
  //
@@ -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
 
@@ -28,37 +28,117 @@ fail_open() {
28
28
  }
29
29
 
30
30
  collect_paths_from_manifests() {
31
- python3 - "$REPO_DIR" <<'PY'
31
+ python3 - "$REPO_DIR" "${1:-}" <<'PY'
32
32
  import glob
33
33
  import json
34
34
  import os
35
35
  import sys
36
+ import subprocess
36
37
 
37
- repo = sys.argv[1]
38
- out = set()
39
- for path in sorted(glob.glob(os.path.join(repo, "scripts/mutants/*.json"))):
40
- with open(path, encoding="utf-8") as handle:
41
- manifest = json.load(handle)
38
+ repo, rev = sys.argv[1:]
39
+ def git(*args):
40
+ return subprocess.check_output(["git", "-C", repo, *args])
41
+ paths = (git("ls-tree", "-r", "--name-only", rev, "scripts/mutants").decode().splitlines()
42
+ if rev else sorted(glob.glob(os.path.join(repo, "scripts/mutants/*.json"))))
43
+ paths = [p for p in paths if p.endswith(".json")]
44
+ if not paths:
45
+ raise ValueError("no mutant manifests")
46
+ out, signatures, subjects = set(), set(), {}
47
+ for path in paths:
48
+ if rev:
49
+ manifest = json.loads(git("show", f"{rev}:{path}"))
50
+ else:
51
+ with open(path, encoding="utf-8") as handle:
52
+ manifest = json.load(handle)
42
53
  for mutant in manifest.get("mutants", []):
43
54
  subject = mutant.get("subject")
44
55
  if subject:
45
56
  out.add(subject)
57
+ find = mutant.get("find")
58
+ # The runner joins a find ARRAY into ONE multiline occurrence.
59
+ needle = "\n".join(find) if isinstance(find, list) and all(isinstance(s, str) for s in find) else None
60
+ subjects.setdefault(subject, []).append(needle)
46
61
  signature_source = mutant.get("signatureSource")
47
62
  if signature_source:
48
63
  out.add(signature_source)
49
- for entry in sorted(out):
50
- print(entry)
64
+ signatures.add(signature_source)
65
+ if not out or any(any(c in p for c in '\n\r\t"\\') for p in out):
66
+ raise ValueError("empty or unrepresentable manifest paths")
67
+ print(json.dumps({"paths": sorted(out), "signatures": sorted(signatures), "subjects": subjects}))
51
68
  PY
52
69
  }
53
70
 
54
- # A changed path matters when it is a mutant's subject or signature source, or
55
- # when it sits in the machinery that decides what those mutants prove.
71
+ load_manifest_surface() {
72
+ MANIFEST_DATA="$(collect_paths_from_manifests "${1:-}")" || fail_open 6 "missing/unreadable mutant manifests"
73
+ MANIFEST_PATHS="$(printf '%s' "$MANIFEST_DATA" | python3 -c 'import json,sys;print("\n".join(json.load(sys.stdin)["paths"]))')" || fail_open 6 "unreadable manifest paths"
74
+ SIGNATURE_PATHS="$(printf '%s' "$MANIFEST_DATA" | python3 -c 'import json,sys;print("\n".join(json.load(sys.stdin)["signatures"]))')" || fail_open 6 "unreadable signature paths"
75
+ }
76
+
77
+ # Return success ONLY when every exact-once TOML find span is demonstrably
78
+ # untouched and unmoved on BOTH blobs. Any uncertainty leaves the body armed.
79
+ subject_find_untouched() {
80
+ python3 - "$REPO_DIR" "$BEFORE" "$HEAD_SHA" "$1" 3<<< "$MANIFEST_DATA" <<'PY'
81
+ import json
82
+ import os
83
+ import re
84
+ import subprocess
85
+ import sys
86
+
87
+ repo, before, head, path = sys.argv[1:]
88
+ def git(*args):
89
+ return subprocess.check_output(["git", "-C", repo, *args], stderr=subprocess.DEVNULL).decode("utf-8")
90
+ def span(source, needle):
91
+ if not needle or source.count(needle) != 1:
92
+ raise ValueError("find absent or ambiguous")
93
+ start = source[:source.index(needle)].count("\n") + 1
94
+ return start, start + needle.count("\n") - int(needle.endswith("\n"))
95
+ try:
96
+ finds = json.load(os.fdopen(3))["subjects"].get(path)
97
+ if not finds or any(not needle for needle in finds):
98
+ raise ValueError("missing find spans")
99
+ old, new = git("show", f"{before}:{path}"), git("show", f"{head}:{path}")
100
+ if "\0" in old or "\0" in new:
101
+ raise ValueError("binary subject")
102
+ diff = git("diff", "--no-renames", "--no-ext-diff", "--no-textconv", "--no-color", "-U0", before, head, "--", path)
103
+ if "Binary files " in diff or "GIT binary patch" in diff:
104
+ raise ValueError("binary diff")
105
+ hunks = []
106
+ for line in diff.splitlines():
107
+ if not line.startswith("@@"):
108
+ continue
109
+ match = re.fullmatch(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?", line)
110
+ if not match:
111
+ raise ValueError("unreadable hunk")
112
+ a, b, c, d = match.groups()
113
+ hunks.append((int(a), int(b or 1), int(c), int(d or 1)))
114
+ if not hunks:
115
+ raise ValueError("no readable text hunks")
116
+ for needle in set(finds):
117
+ os_, oe = span(old, needle)
118
+ ns, ne = span(new, needle)
119
+ if (os_, oe) != (ns, ne):
120
+ raise ValueError("find range moved")
121
+ for a, b, c, d in hunks:
122
+ if (b > 0 and a <= oe and a + b - 1 >= os_) or (d > 0 and c <= ne and c + d - 1 >= ns):
123
+ raise ValueError("find range touched")
124
+ except (ValueError, KeyError, TypeError, OSError, subprocess.SubprocessError) as error:
125
+ print(f"ci-qualify-decide: fail-open 7 {path}: {error} -> run_body=true", file=sys.stderr)
126
+ sys.exit(1)
127
+ print(f"ci-qualify-decide: {path}: no find range touched (all exact-once spans unmoved)", file=sys.stderr)
128
+ PY
129
+ }
130
+
131
+ # Signature sources and machinery MUST precede subjects: many paths are BOTH.
132
+ # Code/unknown subjects retain file-level protection; only TOML data gets hunk
133
+ # refinement (#137 non-goal: no skipping code/gate/mutant/matrix changes). TOML
134
+ # refuses a redefined key, but a shorter Markdown presence literal or a later JSON
135
+ # duplicate key outside an untouched find can still re-satisfy an oracle.
56
136
  matches_qualification_surface() {
57
137
  local file="$1"
58
138
  local subject
59
139
  while IFS= read -r subject; do
60
- [ "$file" = "$subject" ] && { echo "manifest-subject"; return 0; }
61
- done <<< "$MANIFEST_PATHS"
140
+ [ "$file" = "$subject" ] && { echo "manifest-signature-source"; return 0; }
141
+ done <<< "$SIGNATURE_PATHS"
62
142
  case "$file" in
63
143
  scripts/mutants/*) echo "glob:scripts/mutants/**"; return 0 ;;
64
144
  scripts/check-*) echo "glob:scripts/check-*"; return 0 ;;
@@ -73,6 +153,20 @@ matches_qualification_surface() {
73
153
  # that stopped covering something cannot hide behind its own change.
74
154
  scripts/ci-qualify-decide.sh) echo "exact:scripts/ci-qualify-decide.sh"; return 0 ;;
75
155
  esac
156
+ while IFS= read -r subject; do
157
+ if [ "$file" = "$subject" ]; then
158
+ if [ "${MATCH_MODE:-files}" = range ]; then
159
+ case "$file" in
160
+ *.toml) ;;
161
+ *) echo "conservative:non-toml-subject"; return 0 ;;
162
+ esac
163
+ if subject_find_untouched "$file"; then
164
+ return 1
165
+ fi
166
+ fi
167
+ echo "manifest-subject"; return 0
168
+ fi
169
+ done <<< "$MANIFEST_PATHS"
76
170
  return 1
77
171
  }
78
172
 
@@ -98,13 +192,12 @@ decide_from_files() {
98
192
  fi
99
193
  }
100
194
 
101
- MANIFEST_PATHS="$(collect_paths_from_manifests)"
102
- [ -n "$MANIFEST_PATHS" ] || { echo "ABORT: no mutant manifests read from $REPO_DIR/scripts/mutants" >&2; exit 1; }
103
-
104
195
  if [ "${1:-}" = "--files-from" ]; then
196
+ MATCH_MODE=files
197
+ load_manifest_surface
105
198
  SRC="${2:-}"
106
199
  [ -n "$SRC" ] || { echo "ABORT: --files-from needs a path or -" >&2; exit 1; }
107
- if [ "$SRC" = "-" ]; then FILES="$(cat)"; else FILES="$(cat "$SRC")"; fi
200
+ if [ "$SRC" = "-" ]; then FILES="$(cat)"; else FILES="$(cat "$SRC")"; fi || fail_open 4 "unreadable files-from input"
108
201
  decide_from_files "$FILES"
109
202
  exit 0
110
203
  fi
@@ -154,6 +247,10 @@ esac
154
247
  git -C "$REPO_DIR" cat-file -e "$BEFORE^{commit}" 2>/dev/null || fail_open 4 "before=$BEFORE is not a commit object in this checkout"
155
248
  git -C "$REPO_DIR" cat-file -e "$HEAD_SHA^{commit}" 2>/dev/null || fail_open 4 "head=$HEAD_SHA is not a commit object in this checkout"
156
249
 
157
- CHANGED="$(git -C "$REPO_DIR" diff --name-only "$BEFORE".."$HEAD_SHA" 2>/dev/null)" || fail_open 4 "git diff $BEFORE..$HEAD_SHA failed"
250
+ MATCH_MODE=range
251
+ load_manifest_surface "$HEAD_SHA"
252
+ CHANGED="$(git -C "$REPO_DIR" diff --no-renames --no-ext-diff --no-textconv --name-only "$BEFORE".."$HEAD_SHA" 2>/dev/null)" || fail_open 4 "git diff $BEFORE..$HEAD_SHA failed"
253
+ # Git quotes unrepresentable changed names; never interpret those as absent paths.
254
+ if grep -q '^"' <<< "$CHANGED"; then fail_open 4 "quoted changed path cannot be read honestly"; fi
158
255
 
159
256
  decide_from_files "$CHANGED"
@@ -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
+ }