@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
package/run.sh CHANGED
@@ -2,10 +2,10 @@
2
2
  #
3
3
  # Model id convention (see AGENTS.md Hard Rule #1):
4
4
  # - User-facing examples use the qualified form `entwurf/<backend-model>`
5
- # (e.g. `entwurf/claude-sonnet-5`); the prefix routes to this provider
5
+ # (e.g. `entwurf/claude-sonnet-5-5`); the prefix routes to this provider
6
6
  # so `--provider` is redundant and is dropped in docs.
7
7
  # - Smoke helpers that feed `ensureBridgeSession({modelId})` directly (cancel,
8
- # model-switch) pass BARE backend ids (`claude-sonnet-5`, `gpt-5.4`)
8
+ # model-switch) pass BARE backend ids (`claude-sonnet-5-5`, `gpt-5.4`)
9
9
  # because the bridge library contract is bare. Smoke helpers that invoke pi
10
10
  # via the CLI still pin `--provider entwurf` and can accept either
11
11
  # bare or qualified model, but we keep bare here to match the bridge-level
@@ -32,7 +32,7 @@ TARGET_PROJECT_DIR=${2:-$PROJECT_DIR_DEFAULT}
32
32
  # and install path against the same scope for traceability.
33
33
  PACKAGE_NAME="@junghanacs/entwurf"
34
34
  # Runtime provider id — DO NOT change. Embedded in model strings
35
- # (`entwurf/claude-sonnet-5`), settings keys (`entwurfProvider`),
35
+ # (`entwurf/claude-sonnet-5-5`), settings keys (`entwurfProvider`),
36
36
  # log prefixes (`[entwurf:bootstrap]`), and the `--provider entwurf`
37
37
  # CLI surface. Renaming this would break every consumer transcript and every
38
38
  # saved session anchor.
@@ -116,9 +116,55 @@ run_vitest() {
116
116
  (cd "$REPO_DIR" && "$bin" run "$@")
117
117
  }
118
118
 
119
+ # A source-only UI over the existing owned developer-return procedure. No second
120
+ # installer, npm acquisition, automatic mode switch or ownership writer lives here.
121
+ developer_menu() {
122
+ [ "$#" -eq 0 ] || { echo 'usage: ./run.sh menu' >&2; return 2; }
123
+ if [ ! -d "$REPO_DIR/.git" ] || [ ! -f "$REPO_DIR/plugins/herdr/use-dev-checkout.sh" ]; then
124
+ echo '[menu] developer-checkout-source-only — run ./run.sh menu from the prepared source checkout, not the npm installation.' >&2
125
+ return 1
126
+ fi
127
+ if [ ! -t 0 ]; then
128
+ echo '[menu] menu-tty-required — interactive stdin is required; nothing was changed.' >&2
129
+ return 2
130
+ fi
131
+ local choice confirm
132
+ printf '\nHerdr — npm installation check → developer board (local checkout)\n'
133
+ printf 'Checkout: %s\n' "$REPO_DIR"
134
+ printf ' 1) Readiness check — read-only (not a full certificate of installation prerequisites)\n'
135
+ printf ' 2) Switch to the developer board — Pi / Claude Code / Herdr local link\n'
136
+ printf ' 0) Quit — nothing is changed\n'
137
+ if ! IFS= read -r -p 'Choose [0/1/2]: ' choice; then
138
+ echo '[menu] menu-input-closed — nothing was changed.' >&2
139
+ return 2
140
+ fi
141
+ case "$choice" in
142
+ 0) return 0 ;;
143
+ 1) bash "$REPO_DIR/plugins/herdr/use-dev-checkout.sh" --check ;;
144
+ 2)
145
+ # Show the real owner's readiness first. Busy sessions or bad ownership
146
+ # stop here; confirmation grants no authority to bypass those guards.
147
+ bash "$REPO_DIR/plugins/herdr/use-dev-checkout.sh" --check || return $?
148
+ printf '\nClose the affected sessions yourself, and start no new session while switching.\n'
149
+ printf 'The currently owned installation is deactivated, then this checkout is linked. There is no automatic recovery or retry.\n'
150
+ if ! IFS= read -r -p 'Type DEV to switch (anything else cancels): ' confirm; then
151
+ echo '[menu] menu-input-closed — apply was not called.' >&2
152
+ return 2
153
+ fi
154
+ if [ "$confirm" != DEV ]; then
155
+ echo '[menu] menu-cancelled — installation wiring is unchanged.'
156
+ return 0
157
+ fi
158
+ bash "$REPO_DIR/plugins/herdr/use-dev-checkout.sh" --apply
159
+ ;;
160
+ *) echo '[menu] invalid-menu-choice — choose 0, 1 or 2; nothing was changed.' >&2; return 2 ;;
161
+ esac
162
+ }
163
+
119
164
  usage() {
120
165
  cat <<'EOF'
121
166
  Usage:
167
+ ./run.sh menu # source-only Herdr developer board: read-only readiness check or explicitly confirmed return to checkout wiring; never closes sessions or acquires npm artifacts
122
168
  ./run.sh setup [project-dir] # ONE presence-driven composition (#86): per-component PASS/SKIP/FAIL for pi/claude/agy/copilot/omp/codex + stable dev bins + v2 install smoke. Codex birth/MCP/status-line atoms are composed at user scope; setup stays non-green until the operator trusts the birth declaration once in visible Codex. Absent harness = zero-state SKIP, detected-incomplete = named FAIL + nonzero exit. Never installs a harness, starts a daemon, touches credentials, or writes the vendor trust receipt
123
169
  ./run.sh release-gate [project-dir] [--cut] [--allow-skip-gemini] # SINGLE release gate: full static (pnpm run check:full) + the v2-native live gates (v2 matrix-live, check-bridge, doctor-pi-provider, RGG) + the ACP plugin acceptance floor (12 LIVE smokes: socket-citizen/raw-turn/overlay/provider/session-reuse/carrier-augment/memory-containment/rgg/mcp/skill/bundled-mcp/v2-send) + the one surviving axis the aggregate used to omit silently (claude-native-resume; Cortex stays a documented on-demand direct call) + the cross-harness delivery chain (smoke-entwurf-chain-live) + the Codex Pi -> visible Codex -> visible Pi receipt chain (smoke-codex-fresh-live; initial Pi and the Codex it opens in one session S, explicit operator-owned app-server PID in a DIFFERENT session — that separation is the lane B claim). TWO-TIER summary: MUST (release-blocking, owns the exit code — "green" applies here) + BEHAVIOR (advisory, non-blocking: RGG positives model-in-loop turn). STEP OUTCOME protocol: every step is INVOKED and reports its own PASS / SKIP (exit 97, a prerequisite it does not have) / FAIL — a skip is never counted as a pass. Without --cut this is the unattended diagnostic (SKIPs reported, exit 0). WITH --cut it is read as release acceptance and ANY MUST SKIP is red, which is what makes "a CUT needs LIVE=1, SKIP=0" executable instead of prose. --allow-skip-gemini accepted-but-ignored (back-compat). final cut authorization is GLG's.
124
170
  ./run.sh check-bridge # entwurf-bridge direct MCP smoke + protocol/negative-path test.sh (live substrate = v2 live smokes)
@@ -134,6 +180,7 @@ Usage:
134
180
  ./run.sh check-omp-birth-hook # #87 gate: drives the real OMP assembler into a temp dir, imports the ASSEMBLED index.ts into a MOCK omp host and fires session_start/session_switch. tui mints one backend:"omp" record + a sender marker keyed to the host's OWN pid (the one-process join) + the garden id on the status line; print/rpc/json mint NOTHING (hasUI is true on the rpc rows, as in the vendor); switch attaches on the same native id and mints the replacement on a new one; the CERTIFIED receiver reader still finds no marker. Also the four-root joint binding: extension and omp-labeled bridge child resolve the same sessions/mailbox/senders/receivers bundle against test-built literals (never the production resolver as its own oracle), under a poisoned PI_CODING_AGENT_DIR and under four distinct ENTWURF_META_* overrides; the override grammar is absolute-or-~ and both halves plus the doctor refuse anything else by name; and a drifted provenance label selects no root policy at all. Hermetic; no omp, no model turn
135
181
  ./run.sh check-pi-durable-contact # #129 L-identity gate, CHECKOUT-ONLY (an installed package refuses it in run_ts). Opens the REAL durable app from THIS checkout's verified carrier (pi/pi-durable/carrier/ under its resolver, against the checkout's pinned pi SDK set — refused by name first) with the pi-durable contact, births the citizen and runs its tools adapter-direct through a privately emitted branch bridge (ENTWURF_PI_DURABLE_BRIDGE_ENTRY; its registry must match this checkout). Matrix roots {default, explicit} x {fresh, --continue}: one record keyed by the durable session dir, sender marker on the spawned host pid, exactly one bridge child whose /proc PPID is that host, poisoned identity carriers kept from it, its four root carriers bound to the roots written, the receiver ARMED (raw marker owned by the host pid + start key as pi-durable-host, joined to the sender marker; removed on close), self = the record and replyable, peers from an outside bridge, same garden id on reopen; the bundle's compiled owner join must admit pi-durable-host. Reaps only the host/bridges it owns on any failure. Harness-bypassed: no model turn, no ToolTask. Missing inputs → SKIP (97), never a pass
136
182
  ./run.sh check-pi-durable-send # #129 L-send FIRST CELL, CHECKOUT-ONLY. Same input as check-pi-durable-contact. One native durable turn: the app's own controller input → GenerationTask → ToolTask → the contact's entwurf_v2 → the private bridge → a seeded self-fetch target (C2 recipe, owned by a separate idle child). S = a 127.0.0.1 scripted OpenAI-compatible endpoint configured through the app's documented models.json (dummy key, PI_OFFLINE); H = the native ToolTask path; V (a vendor turn) is NOT run. Oracles: the wire (tools offered, v2 schema = the bridge's tools/list, exactly two requests, the tool result + receipt in the second), the raw mailbox (.msg 1, signal, sender = the host record), the native view (one call, its settled result, root current). Not exactly-once, not recovery, not a recipient turn. Missing inputs → SKIP (97)
183
+ ./run.sh check-demo-reproducibility # source-checkout-only: discover offline demo Node tests; no LIVE, model, render or publication
137
184
  ./run.sh check-pi-durable-beside # #129 mutant execution coordinate (VERIFY rule 3): run_vitest over the six pi-durable beside tests (adapter, carrier/SDK-set verifier, preflight, owner join, carrier relocation/resolver/emitter/task footer, native-module file and directory ingress) — the pi-durable qualification lane's gate argv. Same files check-tests-beside-behavior already runs.
138
185
  ./run.sh check-pi-durable-receive # #129 L-receive FIRST CELL, CHECKOUT-ONLY. Same input (the bundle must carry the pi-durable-host owner join). The host driver gives the app NO input; a seeded claude-code sender (marker owned by a separate idle child, handed to a gate-owned sending bridge through ENTWURF_META_SENDER_MARKER) enqueues one message, the host's doorbell admits its notice to the ROOT conversation, and the scripted model drains it with entwurf_inbox_read. S withholds response #1 until the ENQUEUE receipt, the .delivered bytes, the signal, the host-owned receiver marker and the notice are captured. Oracles: receiver() admission, native view, raw archive (.delivered.read + lastReadAt), wire (exactly two requests, drain result = view), and after a green reap a forensic read-only SQLite copy (submission row by request id = reported id, root, input, done, placed entry = the notice). V NOT run. Missing inputs → SKIP (97)
139
186
  ./run.sh check-copilot-receive-arm # #82 RAIL 5 gate: the REAL receiver installer + the REAL extension.mjs forked with a stubbed SDK. Arms only after birth, marker owned by the WATCHER pid, self-fetch dispatch answer, doorbell carries the garden id and NOT the body, id-drift/foreign-parent refusals. Hermetic; no Copilot, no model turn
@@ -222,7 +269,7 @@ Usage:
222
269
  ./run.sh check-meta-manifest-schema # 0.12.2 meta-bridge: CLI-version-INDEPENDENT static guard — plugin manifests pinned to the minimal keyset that validates on the lowest supported Claude (closed-schema regression that broke 0.12.1 install on floor) + desired_mcp installed-vs-clone dual-mode. Offline (deps: python3)
223
270
  ./run.sh smoke-claude-native-resume-live # LIVE-only: Claude Code native fresh→--resume continuity + meta-record uniqueness; proves meta-bridge records identity without touching the backend resume path
224
271
 
225
- ./run.sh install-meta-bridge # INTERNAL part of `setup` (native-harness plugin) + doctor recovery path — prefer `setup`; stateful GLOBAL install (plugin + USER MCP + settings keyset, honest uninstall state)
272
+ ./run.sh install-meta-bridge # INTERNAL wiring leaf: setup, the source-only developer menu and doctor recovery compose it; stateful GLOBAL install (plugin + USER MCP + settings keyset, honest uninstall state)
226
273
  ./run.sh uninstall-meta-bridge # 1.0.0 meta-bridge Phase 2: stateful GLOBAL uninstall (restore only keys/items captured in install-state)
227
274
  ./run.sh doctor-meta-bridge # THE RELEASE ORACLE (#51, Linux-certified repair axis). exit 0 = every required layer was MEASURED on this Linux host: toolchain + state + plugin/MCP + resolved-artifact launch-form classification (all 3 owner hooks + doorbell static contract) + synthetic owner join + store scan + hook errors + SessionStart evidence + REQUIRED live MCP↔marker join + writer-version parity. Missing live evidence is NOT CERTIFIED (open a Claude session and re-run), never a pass; Darwin is NOT CERTIFIED and stays nonzero, on ONE narrowed reason — start-key and ppid are portable there (`ps` fallback, same string the TS core mints), so what holds the lane closed is per-process environment DISCOVERY alone, pending physical-host evidence. Detection power is held by check-meta-doctor-oracle
228
275
  ./run.sh copilot [args...] # #82 RAIL 7: the MANAGED Copilot launch. exec()s the vendor CLI in THIS terminal (cwd/pid/exit preserved) with COPILOT_CLI_ENABLED_FEATURE_FLAGS=EXTENSIONS set for that one invocation — entwurf owns no part of your shell and writes nothing to it, but it owns the process it is about to become. Without that flag Copilot skips the extension scan SILENTLY. Refuses to launch unless the receiver unit is actually installed. Injects `--model auto` when no --model was given and `--yolo` when no explicit permission/surface policy flag was given, both BEFORE any `--`; every argument you pass is forwarded byte-identical. RUNNING THIS IS YOUR CONSENT to that profile — use plain `copilot` for stock vendor behaviour. Not tmux, not fresh-call, mints no citizen (birth is still the first prompt)
@@ -290,7 +337,7 @@ Usage:
290
337
  ./run.sh check-fresh-cut-gate # SOURCE cell of the generation-boundary proof (IN pnpm run check:full): drives real install/setup/fresh-cut in a sandbox; certification refusal is pre-write, quiescence is fail-closed, archives preserve bytes, and the #54 exit matrix distinguishes complete / no-move / usage / incomplete transition / complete-with-cleanup-residue. No model/network/cost
291
338
  ./run.sh check-pack-install # heavy publish gate (prepublishOnly): actual npm pack + tar -tf + fresh-temp install smoke + the #116 M3-b1 herdr-plugin RUNTIME VERIFIER run against that actual installed tarball (the focused check-herdr-runtime-bootstrap drives a fixture; THIS is where the real package is proven) with the pinned pi SDK (pins derived from the package.json exact production dependency; check-dep-versions binds them) + the npm-installed bridge BOOTS (tools/list) and DELIVERS (tools/call entwurf_v2 → .msg lands) + the installed all-absent and copilot-present (four-unit fake-vendor) `entwurf setup` rows + the INSTALLED generation lifecycle on a seeded previous-generation host (REFUSE before activation writes / zero Claude invocations → installed fresh-cut archives + opens empty → install-meta-bridge PASSES) + the INSTALLED-PACKAGE branch of the Copilot and OMP birth installers actually RUN (compiled entry selected, no raw .ts, and a real birth edge mints a citizen — the half a required-artifact list can never stand in for) + the INSTALLED #129 durable bootstrap (pi/pi-durable/bootstrap.mjs) imported from node_modules: main/openDurableCitizen exported and PACKAGED_BRIDGE_ENTRY = the installed package's own dist entry — import only, no runtime/TUI/birth/bridge started + the #130 pi-durable CARRIER on actual npm consumers (Entwurf-only / aligned host / isolated global): the installed verifier, a consumer-contained resolution oracle with a same-sandbox neighbour control, every physical SDK edge at the pin, a mixed tree refused as pi-durable-sdk-mismatch though it still imports, and the public `entwurf pi-durable` reaching the app's own explicit-model refusal before any birth, also with one explicit `--native-module` (the synthetic fixture, initialized first; a reserved extension name refused before anything opens) (offline, sandbox HOME)
292
339
  ./run.sh check-install-container # 0.12.8 (#51 C): Linux artifact-CONSUMER gate — one candidate .tgz handed read-only to a checkout-invisible node:<engines-major>-bookworm cell. Default packs once to temp; ENTWURF_CANDIDATE_TGZ=/absolute/preserved.tgz consumes those exact bytes with no re-pack and prints canonical path+sha256 for release. Non-root global PATH install, frozen package, MCP tools/list, fake-Claude install-meta-bridge, path+sha256 fence, strict doctor, and the GENERATION host-state matrix (clean / v3-only store bytes unchanged / previous-generation REFUSE→fresh-cut→retry PASS) seeded inline. Docker missing = honest SKIP; ENTWURF_REQUIRE_DOCKER=1 makes that RED (required CI)
293
- ./run.sh install [project-dir] # INTERNAL part of `setup` (project .pi/settings.json wiring) + npm-consumer entry — prefer `setup`, don't call directly for dev
340
+ ./run.sh install [project-dir] # INTERNAL wiring leaf: setup/npm-consumer entry and the source-only developer menu compose it; prefer those owning procedures
294
341
  ./run.sh remove [project-dir] # remove entwurf entries from project .pi/settings.json (project scope only; global user-scope citizen left intact)
295
342
  ./run.sh install-user-scope [--plugin-runtime <root>] # #116 M3-b2 PLUGIN-ONLY forward seam: compose ONLY the global pi citizen (user-scope packages[] + provider), never a project `.pi/settings.json` — a Herdr plugin activation has no project and must not invent one. With --plugin-runtime the provider's managed command becomes the absolute bridge under that certified stable runtime (a clean host has nothing entwurf on PATH); without it every byte is the historical bare-bin install. Inverse: remove-user-scope
296
343
  ./run.sh herdr-plugin-activate <backend...> # #116 M3-b2 public forward activation. Takes a subset of {pi, claude-code} and NOTHING else — the stable runtime root is DERIVED from XDG here and handed down, and both harness writers accept a named root only when it IS that derived one. Certifies the runtime-ready journal AND the installed tree, certifies the existing ledger and its recorded harness roots, plans ADD-ONLY (a backend absent from this request is retained, never removed), preflights every selected component before the first byte, then writes each component `pending` BEFORE mutating and checkpoints `active` the moment its writer returns — so a run that dies halfway leaves a record of how far it got. Which backends is M3-b3's question: this verb is handed the answer, it does not read Herdr's integration status
@@ -1029,6 +1076,37 @@ check_capability_bundle_reach() {
1029
1076
  run_ts scripts/check-capability-bundle-reach.ts
1030
1077
  }
1031
1078
 
1079
+ check_demo_reproducibility() {
1080
+ # Source-only Node strip-types/CLI and subprocess fixtures, not adapter Vitest tests.
1081
+ # Discovery lives beside demo behavior; no filename list or npm TS crossing.
1082
+ section "demo reproducibility (offline source fixtures, no model or recording)"
1083
+ case "$REPO_DIR" in */node_modules/*) fail "check-demo-reproducibility is source-checkout-only"; return 1 ;; esac
1084
+ local -a tests=()
1085
+ local f
1086
+ while IFS= read -r f; do [ -n "$f" ] && tests+=("$f"); done < <(cd "$REPO_DIR" && find demo -type f -name '*.test.ts' -print | LC_ALL=C sort)
1087
+ if [ "${#tests[@]}" -eq 0 ]; then
1088
+ fail "[QK:DEMO-TEST-DISCOVERY] demo test discovery is empty — no fixtures is not a passing floor"
1089
+ return 1
1090
+ fi
1091
+ (
1092
+ local sandbox name
1093
+ sandbox=$(mktemp -d /tmp/entwurf-demo-unit.XXXXXX)
1094
+ trap 'rm -rf -- "$sandbox"' EXIT
1095
+ while IFS= read -r name; do
1096
+ case "$name" in ENTWURF_*|HERDR_*|PI_SESSION_ID|PI_AGENT_ID|CLAUDECODE|CLAUDE_CODE_SESSION_ID|TMUX|TMUX_PANE|LIVE) unset "$name" ;; esac
1097
+ done < <(compgen -e)
1098
+ export HOME="$sandbox/home" PI_CODING_AGENT_DIR="$sandbox/pi"
1099
+ export XDG_CONFIG_HOME="$sandbox/config" XDG_DATA_HOME="$sandbox/data" XDG_STATE_HOME="$sandbox/state" XDG_CACHE_HOME="$sandbox/cache" XDG_RUNTIME_DIR="$sandbox/runtime"
1100
+ mkdir -m 700 "$HOME" "$PI_CODING_AGENT_DIR" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" "$XDG_CACHE_HOME" "$XDG_RUNTIME_DIR" "$sandbox/bin"
1101
+ # Take.finish records PATH versions; fixtures must not depend on an installed Pi or run it.
1102
+ printf '#!/bin/sh\n[ "$1" = --version ] && { printf "offline-fixture\\n"; exit 0; }\nexit 81\n' > "$sandbox/bin/pi"
1103
+ chmod +x "$sandbox/bin/pi"
1104
+ export PATH="$sandbox/bin:$PATH"
1105
+ cd "$REPO_DIR"
1106
+ node --test "${tests[@]}"
1107
+ )
1108
+ }
1109
+
1032
1110
  check_bridge_delivery() {
1033
1111
  # demo/demo.sh scene 3, recovered as a deterministic gate. demo drove two real pi
1034
1112
  # panes so it proved DELIVERY through the installed binary (at model cost); what
@@ -1363,6 +1441,14 @@ check_acp_foreground_only() {
1363
1441
  run_vitest pi-extensions/lib/acp/backend-adapter.test.ts
1364
1442
  }
1365
1443
 
1444
+ check_acp_set_model_readback() {
1445
+ # NOT a discovery path — check-tests-beside-behavior finds the test by glob. This case is the
1446
+ # MUTANT EXECUTION COORDINATE for scripts/mutants/acp-set-model-readback.json (#128): the claude
1447
+ # adapter refuses a set-model answer that does not name the requested model, before the prompt.
1448
+ section "claude ACP set-model readback (mutant execution coordinate)"
1449
+ run_vitest pi-extensions/lib/acp/backend-adapter.test.ts
1450
+ }
1451
+
1366
1452
  check_acp_vendor_rejection() {
1367
1453
  # Behaviour-adjacent tests are discovered by the glob lane. This narrow case is the
1368
1454
  # mutant execution coordinate: include the adapter admission and real turn pipeline,
@@ -1571,12 +1657,12 @@ check_mux_launch_tmux() {
1571
1657
  smoke_acp_socket_citizen_live() {
1572
1658
  # S1 acceptance smoke (ACP plugin on v2) — OUT of pnpm check, needs LIVE=1.
1573
1659
  # Spawns a REAL `pi --entwurf-control` resident on an ACP model
1574
- # (entwurf/claude-opus-5) and proves it is a first-class socket-citizen:
1660
+ # (entwurf/claude-opus-5-5) and proves it is a first-class socket-citizen:
1575
1661
  # the control socket stands up, get_info answers with the ACP model (model-lock
1576
1662
  # did NOT revert — QM1), idle/cwd are reported, and the fail-loud streamSimple
1577
1663
  # stub never fires (turn-free launch — QM2). No prompt is sent: S1 proves
1578
1664
  # citizenship, never a backend turn (that is S2). Honest skip when LIVE!=1.
1579
- # Model override: ENTWURF_S1_MODEL (default claude-opus-5).
1665
+ # Model override: ENTWURF_S1_MODEL (default claude-opus-5-5).
1580
1666
  # LIVE=1 ./run.sh smoke-acp-socket-citizen-live
1581
1667
  run_ts scripts/smoke-acp-socket-citizen-live.ts
1582
1668
  }
@@ -1590,7 +1676,7 @@ smoke_acp_raw_turn_live() {
1590
1676
  # plus captured raw NDJSON bytes. NO provider/overlay/streamSimple/_meta — the
1591
1677
  # raw backend pipe only. Launch source must be the package bin (PATH fallback
1592
1678
  # fails acceptance unless ENTWURF_ACP_RAW_TURN_ALLOW_PATH_FALLBACK=1, debug).
1593
- # Model override: ENTWURF_ACP_RAW_TURN_MODEL (default claude-sonnet-5).
1679
+ # Model override: ENTWURF_ACP_RAW_TURN_MODEL (default claude-sonnet-5-5).
1594
1680
  # LIVE=1 ./run.sh smoke-acp-raw-turn-live
1595
1681
  run_ts scripts/smoke-acp-raw-turn-live.ts
1596
1682
  }
@@ -1608,7 +1694,7 @@ smoke_acp_overlay_live() {
1608
1694
  # meta-store for mailbox absence (flaky — concurrent sessions); the honest
1609
1695
  # claim is overlay-supplies-hooks:{}. Launch must be the package bin (PATH
1610
1696
  # fallback fails acceptance unless ENTWURF_ACP_OVERLAY_ALLOW_PATH_FALLBACK=1).
1611
- # Model override: ENTWURF_ACP_OVERLAY_MODEL (default claude-sonnet-5).
1697
+ # Model override: ENTWURF_ACP_OVERLAY_MODEL (default claude-sonnet-5-5).
1612
1698
  # LIVE=1 ./run.sh smoke-acp-overlay-live
1613
1699
  run_ts scripts/smoke-acp-overlay-live.ts
1614
1700
  }
@@ -1623,7 +1709,7 @@ smoke_acp_memory_containment_live() {
1623
1709
  # can stop a memory write is the lever — not us. Fails loud if engraving.md is
1624
1710
  # empty (carrier OFF = no containment). Launch must be the package bin (PATH
1625
1711
  # fallback fails acceptance unless ENTWURF_ACP_MEMORY_ALLOW_PATH_FALLBACK=1).
1626
- # Model override: ENTWURF_ACP_MEMORY_MODEL (default claude-sonnet-5).
1712
+ # Model override: ENTWURF_ACP_MEMORY_MODEL (default claude-sonnet-5-5).
1627
1713
  # LIVE=1 ./run.sh smoke-acp-memory-containment-live
1628
1714
  run_ts scripts/smoke-acp-memory-containment-live.ts
1629
1715
  }
@@ -1650,7 +1736,7 @@ smoke_acp_provider_live() {
1650
1736
  # assistant reply (live model proof) + the removed S0 stub error never appears
1651
1737
  # (provider path actually opened) + pi exits 0. Tool-free prompt; the
1652
1738
  # event-mapper gate owns the tool→notice contract.
1653
- # Model override: ENTWURF_ACP_PROVIDER_MODEL (default claude-sonnet-5).
1739
+ # Model override: ENTWURF_ACP_PROVIDER_MODEL (default claude-sonnet-5-5).
1654
1740
  # LIVE=1 ./run.sh smoke-acp-provider-live
1655
1741
  run_ts scripts/smoke-acp-provider-live.ts
1656
1742
  }
@@ -1664,7 +1750,7 @@ smoke_acp_session_reuse_live() {
1664
1750
  # is the verdict; codeword recall is supporting only (a rebuilt full transcript
1665
1751
  # could recall it too). The one-shot exit0 half is owned by
1666
1752
  # smoke-acp-provider-live.
1667
- # Model override: ENTWURF_ACP_PROVIDER_MODEL (default claude-sonnet-5).
1753
+ # Model override: ENTWURF_ACP_PROVIDER_MODEL (default claude-sonnet-5-5).
1668
1754
  # LIVE=1 ./run.sh smoke-acp-session-reuse-live
1669
1755
  run_ts scripts/smoke-acp-session-reuse-live.ts
1670
1756
  }
@@ -1676,7 +1762,7 @@ smoke_acp_carrier_augment_live() {
1676
1762
  # secret (the augment rode the wire to the model) and the EMPTY default carrier
1677
1763
  # must bill clean (exit 0, no HTTP-400 canary — 핀1 live). Optional tiny carrier
1678
1764
  # check via SMOKE_ACP_CARRIER_PRESENT=1 (non-blocking).
1679
- # Model override: ENTWURF_ACP_PROVIDER_MODEL (default claude-sonnet-5).
1765
+ # Model override: ENTWURF_ACP_PROVIDER_MODEL (default claude-sonnet-5-5).
1680
1766
  # LIVE=1 ./run.sh smoke-acp-carrier-augment-live
1681
1767
  run_ts scripts/smoke-acp-carrier-augment-live.ts
1682
1768
  }
@@ -1762,9 +1848,9 @@ smoke_acp_rgg_live() {
1762
1848
  # birth, record-keyed socket, attach-on-reopen, no pi-session-id socket) holds
1763
1849
  # under the ACP provider too — the logic is provider-agnostic. The POSITIVE cell
1764
1850
  # (one real turn completing transcriptPath/model) is the only model-in-loop part.
1765
- # Target override: ENTWURF_RGG_TARGET (default entwurf/claude-sonnet-5).
1851
+ # Target override: ENTWURF_RGG_TARGET (default entwurf/claude-sonnet-5-5).
1766
1852
  # ./run.sh smoke-acp-rgg-live
1767
- local target="${ENTWURF_RGG_TARGET:-entwurf/claude-sonnet-5}"
1853
+ local target="${ENTWURF_RGG_TARGET:-entwurf/claude-sonnet-5-5}"
1768
1854
  (cd "$REPO_DIR" && ENTWURF_LIVE_TARGET="$target" SMOKE_RGG_POSITIVE=0 bash scripts/smoke-resident-garden-guard.sh)
1769
1855
  }
1770
1856
 
@@ -3614,7 +3700,7 @@ check_pack() {
3614
3700
  "package.json" "README.md" "LICENSE" "CHANGELOG.md"
3615
3701
  "protocol.js" "run.sh"
3616
3702
  "pi-extensions/acp-provider.ts"
3617
- "pi-extensions/lib/acp/models.ts" "pi-extensions/lib/acp/backend.ts"
3703
+ "pi-extensions/lib/acp/models.ts" "pi-extensions/lib/acp/model-ids.js" "pi-extensions/lib/acp/backend.ts"
3618
3704
  "pi-extensions/lib/acp/overlay.ts" "pi-extensions/lib/acp/tool-surface.ts"
3619
3705
  "pi-extensions/lib/acp/event-mapper.ts" "pi-extensions/lib/acp/context.ts"
3620
3706
  "pi-extensions/entwurf-control.ts"
@@ -3756,6 +3842,8 @@ check_pack() {
3756
3842
  # pi-extensions/ so it also covers any future allowlist entry; `plugins/` needs no
3757
3843
  # companion negation because `^plugins/` above already refuses that whole tree.
3758
3844
  '\.test\.[cm]?[jt]s$'
3845
+ # Maintainer take/renderer/helper TS is source-only, including non-test implementations.
3846
+ '^demo/.*\.ts$'
3759
3847
  )
3760
3848
 
3761
3849
  local pass=1 f pat hit
@@ -4260,7 +4348,7 @@ _check_pack_install_impl() {
4260
4348
  "package.json" "README.md" "LICENSE" "CHANGELOG.md"
4261
4349
  "protocol.js" "run.sh"
4262
4350
  "pi-extensions/acp-provider.ts"
4263
- "pi-extensions/lib/acp/models.ts" "pi-extensions/lib/acp/backend.ts"
4351
+ "pi-extensions/lib/acp/models.ts" "pi-extensions/lib/acp/model-ids.js" "pi-extensions/lib/acp/backend.ts"
4264
4352
  "pi-extensions/lib/acp/overlay.ts" "pi-extensions/lib/acp/tool-surface.ts"
4265
4353
  "pi-extensions/lib/acp/event-mapper.ts" "pi-extensions/lib/acp/context.ts"
4266
4354
  "pi-extensions/entwurf-control.ts"
@@ -4686,8 +4774,8 @@ _check_pack_install_impl() {
4686
4774
  loader_ids=$(awk '$1 == "entwurf" { print $2 }' <<<"$loader_out" | LC_ALL=C sort)
4687
4775
  loader_expected=$(printf '%s\n' \
4688
4776
  claude-fable-5-1 \
4689
- claude-opus-5 \
4690
- claude-sonnet-5 \
4777
+ claude-opus-5-5 \
4778
+ claude-sonnet-5-5 \
4691
4779
  cortex-auto \
4692
4780
  cortex-claude-opus-5 \
4693
4781
  cortex-claude-sonnet-5 \
@@ -4796,7 +4884,7 @@ sys.exit(0 if any(isinstance(s,str) and s.endswith('/node_modules/@junghanacs/en
4796
4884
  echo "$foreign_out" | tail -10 | sed 's/^/ /' >&2
4797
4885
  return 1
4798
4886
  }
4799
- if grep -qi "Unknown option" <<<"$foreign_out" || ! grep -q "claude-opus-5" <<<"$foreign_out"; then
4887
+ if grep -qi "Unknown option" <<<"$foreign_out" || ! grep -q "claude-opus-5-5" <<<"$foreign_out"; then
4800
4888
  fail "[check-pack-install] foreign-cwd --entwurf-control did not load the entwurf extension from user scope:"
4801
4889
  echo "$foreign_out" | tail -10 | sed 's/^/ /' >&2
4802
4890
  return 1
@@ -7035,6 +7123,10 @@ release_gate() {
7035
7123
 
7036
7124
  cmd=${1:-}
7037
7125
  case "$cmd" in
7126
+ menu)
7127
+ shift
7128
+ developer_menu "$@"
7129
+ ;;
7038
7130
  setup)
7039
7131
  setup_all "$TARGET_PROJECT_DIR"
7040
7132
  ;;
@@ -7209,6 +7301,9 @@ case "$cmd" in
7209
7301
  check-acp-foreground-only)
7210
7302
  check_acp_foreground_only
7211
7303
  ;;
7304
+ check-acp-set-model-readback)
7305
+ check_acp_set_model_readback
7306
+ ;;
7212
7307
  check-resume-launch-identity)
7213
7308
  check_resume_launch_identity
7214
7309
  ;;
@@ -8175,6 +8270,9 @@ case "$cmd" in
8175
8270
  check-pi-queue-oracle)
8176
8271
  check_pi_queue_oracle
8177
8272
  ;;
8273
+ check-demo-reproducibility)
8274
+ check_demo_reproducibility
8275
+ ;;
8178
8276
  check-tests-beside-behavior)
8179
8277
  check_tests_beside_behavior
8180
8278
  ;;
@@ -16,13 +16,13 @@
16
16
 
17
17
  import { strict as assert } from "node:assert";
18
18
  import { execFileSync } from "node:child_process";
19
- import { readdirSync, readFileSync, rmSync } from "node:fs";
19
+ import { readdirSync, readFileSync, rmdirSync, rmSync } from "node:fs";
20
20
  import { join, resolve } from "node:path";
21
21
  import { pathToFileURL } from "node:url";
22
22
  import type { Api, AssistantMessageEvent, Model, TranscriptContext } from "@earendil-works/pi-ai";
23
23
  import { normalizeContext } from "@earendil-works/pi-ai";
24
24
 
25
- const model = { id: "claude-sonnet-5" } as unknown as Model<Api>;
25
+ const model = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
26
26
 
27
27
  // Declared tools exclude `read`, but the Claude child always exposes Read →
28
28
  // declared != actual → the preflight must reject before spawning.
@@ -157,6 +157,14 @@ try {
157
157
  );
158
158
  } finally {
159
159
  rmSync(TMP_EMIT, { recursive: true, force: true });
160
+ try {
161
+ // Same as check-acp-cortex: the emit created `.tmp-verify/` as TMP_EMIT's parent, and
162
+ // check-gate-qualification's snapshot manifest walks ignored paths, so a leftover empty
163
+ // parent reads as a dirty tree. Remove it when empty; a sibling's emit keeps it alive.
164
+ rmdirSync(".tmp-verify");
165
+ } catch {
166
+ // non-empty or already gone — fine either way
167
+ }
160
168
  }
161
169
 
162
170
  // ---------------------------------------------------------------------------
@@ -53,11 +53,11 @@ import {
53
53
  import { tmpdir } from "node:os";
54
54
  import { join, resolve } from "node:path";
55
55
  import { pathToFileURL } from "node:url";
56
+ import { getModels } from "@earendil-works/pi-ai/compat";
56
57
  import { buildPiContextAugment } from "../pi-extensions/lib/acp/augment.ts";
57
58
  import type { AcpMcpServer } from "../pi-extensions/lib/acp/config.ts";
58
59
  import {
59
60
  CORTEX_MODEL_PREFIX,
60
- curatedClaudeModels,
61
61
  curatedCortexModels,
62
62
  SUPPORTED_CORTEX_MODEL_IDS,
63
63
  } from "../pi-extensions/lib/acp/models.ts";
@@ -87,12 +87,13 @@ const cortexRowIds = cortexRows.map((m) => m.id);
87
87
  assert.deepEqual(cortexRowIds, [...SUPPORTED_CORTEX_MODEL_IDS], "curated cortex rows must match the supported id set");
88
88
  // The two Claude rows ride their OWN registry bases (a fabricated row would
89
89
  // drift from the pi-ai catalog); auto/gpt ride the sonnet base as a floor.
90
- const claudeRows = curatedClaudeModels();
90
+ // Cortex retains its own native 5 ids even when Claude's curated set moves to 5.5.
91
+ const claudeRows = getModels("anthropic");
91
92
  const sonnetBase = claudeRows.find((m) => m.id === "claude-sonnet-5");
92
93
  const opusBase = claudeRows.find((m) => m.id === "claude-opus-5");
93
94
  // Plain throw (not assert.ok): an asserts-function premise makes tsc report the
94
95
  // downstream `base` const as circularly-typed (TS7022).
95
- if (!sonnetBase || !opusBase) throw new Error("test premise: sonnet-5/opus-5 present in curated claude surface");
96
+ if (!sonnetBase || !opusBase) throw new Error("test premise: sonnet-5/opus-5 present in pi-ai registry");
96
97
  for (const row of cortexRows) {
97
98
  // Explicit annotation: node:assert's asserts-typed helpers otherwise make tsc
98
99
  // flag this flow-dependent const as circularly typed (TS7022).
@@ -480,7 +481,7 @@ try {
480
481
  // throwing IS the anti-collision proof; we also assert the rows are present.
481
482
  const registered = mod.allCuratedModels().map((m) => m.id);
482
483
  for (const want of [
483
- "claude-sonnet-5",
484
+ "claude-sonnet-5-5",
484
485
  "cortex-auto",
485
486
  "cortex-claude-opus-5",
486
487
  "cortex-claude-sonnet-5",
@@ -494,12 +495,12 @@ try {
494
495
  assert.equal(routedSonnet.adapter.backend, "cortex", "cortex-claude-sonnet-5 must route to the cortex adapter");
495
496
  assert.equal(routedSonnet.nativeModelId, "claude-sonnet-5", "prefix strip must recover the native model id");
496
497
  assert.equal(
497
- mod.resolveAcpBackendAdapter("claude-sonnet-5").adapter.backend,
498
+ mod.resolveAcpBackendAdapter("claude-sonnet-5-5").adapter.backend,
498
499
  "claude",
499
500
  "the unprefixed claude id must still route to claude (prefix keeps the namespaces apart)",
500
501
  );
501
502
  assert.equal(
502
- mod.cortexAdapter.routeModel("claude-sonnet-5"),
503
+ mod.cortexAdapter.routeModel("claude-sonnet-5-5"),
503
504
  undefined,
504
505
  "cortex must NOT claim the unprefixed claude id",
505
506
  );
@@ -40,7 +40,7 @@ import type { Api, AssistantMessageEvent, Message, Model, TranscriptContext } fr
40
40
  import { normalizeContext } from "@earendil-works/pi-ai";
41
41
  import { isRetryableAssistantError } from "@earendil-works/pi-ai/compat";
42
42
 
43
- const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
43
+ const sonnet = { id: "claude-sonnet-5-5" } as unknown as Model<Api>;
44
44
 
45
45
  type Stream = AsyncIterable<AssistantMessageEvent>;
46
46
 
@@ -151,7 +151,7 @@ function makeHarness(recordDir: string) {
151
151
  const makeConnection = (handlers: any) => ({
152
152
  initialize: async () => ({ agentCapabilities: {} }),
153
153
  newSession: async () => ({ sessionId: "ACP-1" }),
154
- setSessionConfigOption: async () => ({}),
154
+ setSessionConfigOption: async (p: any) => ({ configOptions: [{ id: "model", currentValue: p.value }] }),
155
155
  prompt: ({ sessionId }: any) => {
156
156
  promptCount++;
157
157
  notifier = (update) => handlers.sessionUpdate({ update, sessionId });
@@ -123,7 +123,7 @@ try {
123
123
  assert.equal(cap.cfg.apiKey, ENTWURF_ACP_NO_AUTH_SENTINEL, "entry apiKey is not the no-auth sentinel");
124
124
  assert.equal(cap.cfg.api, "entwurf", "entry api field drifted");
125
125
  const capIds = (cap.cfg.models ?? []).map((m) => m.id);
126
- for (const want of ["claude-sonnet-5", CURATED_ANCHOR_MODEL_ID]) {
126
+ for (const want of ["claude-sonnet-5-5", CURATED_ANCHOR_MODEL_ID]) {
127
127
  assert.ok(capIds.includes(want), `entry model surface missing ${want} (got: ${capIds.join(", ") || "none"})`);
128
128
  }
129
129
 
@@ -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