chore(583): make per-agent model routing a hard constraint + PreToolUse gate
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 14s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m42s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m2s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

The kickoff tells the orchestrator to route agents by capability, but that rule
lived only in a prose paragraph. On 2026-07-25 a session dispatched two
implementers (#436, #440) with `model` omitted, silently inheriting the
orchestrator tier — while following every bullet in HARD CONSTRAINTS in the same
session. The bulleted list is what functions as the checklist; prose above it
reads as background.

- HARD CONSTRAINTS: keyed routing bullet requiring the tier to be stated in the
  dispatch itself, so an invisible omission becomes visible output. Prose
  paragraph tightened to point at the key rather than restate the table (the
  kickoff is pasted into every session — duplication is a per-session tax, #542).
- .claude/hooks/pretooluse-agent-model.sh: PreToolUse on Agent, `ask` when a
  committing agent is dispatched with no explicit `model`. Narrow by design —
  passes through read-only/recon types, `fork` (model override ignored by the
  tool), and any dispatch already naming a tier, because a gate that fires on
  every fan-out trains one-shot dismissal. `ask` not `deny`: routing is a
  judgment call with no derivable right answer, unlike the H6/H10 merge gate.
- New decision record `process.per-agent-model-routing`; catalog regenerated.

Decision matrix verified against 9 payloads incl. a replay of the dispatch that
missed. decisions-validate: OK.

fixes #583
This commit is contained in:
2026-07-25 11:02:42 +02:00
parent be1070de51
commit d221a06522
5 changed files with 138 additions and 5 deletions
+49
View File
@@ -524,3 +524,52 @@ The dependencies API is unaffected: `POST /issues/{n}/dependencies` with `{"owne
blocked-by correctly, though the bare `{index}` form returns 201 without reliably attaching — verify
with the GET. (That last sentence is carried forward from an earlier revision of the handoff doc,
commit `f93458c7`, where it was dropped by a later prune rather than disproved.)
## 2026-07-25 — Name the model tier for every dispatched agent; a PreToolUse gate makes the silent default visible (#583)
`key: process.per-agent-model-routing` · `status: active` · `since: 2026-07-25` · `supersedes: none` · `superseded-by: none`
**Rule:** State the model tier (and effort, where the client exposes it) in the dispatch itself for every delegated agent — bounded recon → cheapest fast tier at `low`; mechanical slice against a documented contract → mid tier; judgment-heavy work → orchestrator tier; independent review → a different model family than the implementer.
**Signals:** subagent model routing · `model` omitted · silent tier inheritance · orchestrator tier for a mechanical slice · capability routing · prose rule vs HARD CONSTRAINT · dispatch-time checkpoint · paths: `.claude/hooks/pretooluse-agent-model.sh`, `docs/handoffs/chicorytv-issue-queue.md` · issues: #583, #436, #440
**Mechanics:** `Agent` tool `model` parameter; PreToolUse hook on the existing `Agent|Task` matcher in `.claude/settings.json`.
On 2026-07-25 a session dispatched two implementers (#436, #440) with `model` omitted on both calls;
both silently inherited the Opus orchestrator tier. #440 was a mechanical SPA slice against an
already-shipped backend contract — a plausible mid-tier candidate.
The interesting part is *why*, because it wasn't forgetfulness. **Every rule in `HARD CONSTRAINTS` was
followed in that same session** — worktree off `origin/main`, one committing agent per worktree,
parallelize on disjoint slices, local gate before push. Routing was the one instruction living only in
a prose paragraph, and it was the one that got defaulted. Treat that as the general lesson: in a
kickoff doc that is pasted into every session, **the bulleted imperative list is what actually
functions as the checklist**, and prose above it is read as background. A rule you want followed
belongs in the list, keyed, or it is advisory in practice.
Three aggravating factors, all worth checking when writing any future rule here:
- **Scope gap.** The low-cost-routing paragraph is written entirely about queue selection and recon
("bounded searches, inventories, log triage, report drafting"). It never named *implementers*, and
gave no default for the bounded-but-not-trivial case — so the largest-cost dispatch fell in a gap.
- **The wrong default is the silent one.** Omitting `model` produces no artifact. Nothing in the
session report revealed the tier; the operator had to ask. Contrast the BOM trap
(`process.bom-format-detection-recipe`), where a hook fires because a memory describing the trap
demonstrably failed to prevent it twice in one day.
- **Distance from the decision point.** The rule sits ~line 84 of the kickoff; dispatch happens after
orientation, claiming, the bundle scan and doc reading.
Hence the two-part fix: a keyed HARD CONSTRAINT that requires the tier to be **stated out loud in the
dispatch** (a self-correcting mechanism — it turns an invisible omission into visible output), plus
`.claude/hooks/pretooluse-agent-model.sh`, which `ask`s when a **committing** agent is dispatched with
no explicit `model`.
The hook is deliberately **narrow**, and the scoping is the load-bearing design choice: it passes
through read-only/recon agent types, `fork` (whose `model` override is ignored by the tool by design,
so prompting would be nonsense), and any dispatch that already names a tier. A gate that fires on
every fan-out trains one-shot dismissal and becomes noise — which would leave it worse than no gate.
It is `ask`, never `deny`: routing is a judgment call with no derivable right answer, unlike the
H6/H10 merge gate (`release.merge-consent-autogrant`) which derives a verifiable state and can
therefore grant or refuse outright.
**Known limitation:** review agents (`feature-dev:code-reviewer`) pass through, since they cannot
commit — so the "prefer a different model family for independent review" half of the rule remains
doc-only, unenforced. That half is the higher-value routing decision of the two; if cross-family
review drift shows up again, gate it next.