Files
claude/.claude/tasks/plans/2026-10-09-model-router-tiers-1237.md
T

32 KiB
Raw Blame History

PLAN — model-router wave 1-C: absolute tiers, availability fallback, derived phases (dispatch-ready)

Contract: .claude/tasks/contracts/2026-10-09-model-router-tiers-1237.md Code: mods/model-router/hooks/register.ts (read in full) and register.test.ts. API truth: mods/model-router/.claude-plugin/types/claude-code/index.d.ts (TurnStepInput, TurnCompleteInput + TurnCompleteReason, SessionRateLimit, classic.PostModelSwitch → PostModelSwitchHookInput, $.session.model, $.model.classify, 'claude-code/testing'), .../claude-code-tools/index.d.ts.

Why (user, 2026-10-09)

  1. "Session model" phases assumed Fable. On a haiku session, plan at xhigh on haiku is wrong. Phases must name ABSOLUTE tiers.
  2. When Fable has no credit left, routing must fall back (plan → opus xhigh).
  3. The user never types /route: phases are derived (prompt wording, dispatch spans, skills) or declared by the model.
  4. Reflection and planning always get the best available model.

Engine facts (read in the declarations today)

  • SessionRateLimit.kind ∈ five_hour | seven_day | spend_limit: account windows, NOT per model → availability cannot be read from quotas.
  • A dead request: turn.step result usage: null, stopReason: null; turn.complete reason: 'error' (retries exhausted) or 'refusal' (refused, no fallback model); 'aborted' = the user interrupted.
  • classic.PostModelSwitch fires on every main-model change with from_model, to_model, source ∈ command|picker|sdk|auto|resume, context_tokens, prompt_cache_warm.
  • turn.step e.model = the id the engine resolved (session's or a fallback's); $.session.model() = the main loop's model as /model shows.
  • $.model.classify(text, labels, { model? }) → label | undefined, rejects on failure; default = the engine's small fast model.

Config (additions; everything else unchanged)

type Route = { model?: string; effort?: Level }   // model: TIER name, alias or full id
type PromptRule = { pattern: string; phase: string; mode?: 'floor' | 'default' }
type Config = {
  …existing…
  tiers: Record<string, string[]>     // tier → ordered alias preference
  fallback: string[]                  // alias order, best first; = rank
  cooldownMinutes: number             // breaker hold
  mainUpgrade: boolean                // main loop may switch UP to a phase's tier
  classifier: boolean                 // ask the small model when no rule matched
}

DEFAULT_CONFIG changes:

  • tiers: best ['fable','opus','sonnet'], big ['opus','fable','sonnet'], work ['sonnet','opus'], cheap ['haiku','sonnet'].
  • fallback: ['fable','opus','sonnet','haiku']. cooldownMinutes: 15. mainUpgrade: true. classifier: false.
  • phases: plan { model: 'best', effort: 'xhigh' }, reflect { best, high }, orchestrate { best, medium }, escalate { best, max }, judge { big, xhigh }, implement { work, medium }, write { work, medium }, verify { work, xhigh }, explore { work, medium }, mechanical { cheap, low }. (Keep the model field name: a tier name is a model NAME the resolver understands; no new tier field. AC3 greps tier: '…'?? NO: AC3 is written against model: 'best'-style entries? → see AC3 note below.)
  • prompt: [{ pattern: '\\bultrathink\\b', phase: 'escalate', mode: 'floor' }, { pattern: '\\b(plan|planifie|planning|brainstorm|architecture|con[cç]ois|design)\\b', phase: 'plan', mode: 'default' }, { pattern: '\\b(pourquoi|why|explique|explain|analyse|analyze|comprendre|understand|review|audit)\\b', phase: 'reflect', mode: 'default' }]. mode absent → 'default'. AC3 note for the executor: the contract's CHECK counts tier: '(best|big|work|cheap)' in DEFAULT_CONFIG and refuses model: ' entries there. So the PHASE type gets an explicit tier?: string field: type Route = { tier?: string; model?: string; effort?: Level }; a route resolves tier first, then model. Default phases use tier:. /route model=<x> keeps writing model (alias or id). The route tool keeps phase/effort/clear only. Validation: tiers values = non-empty arrays of models keys (bad entries dropped, logged); fallback = array of models keys, deduplicated, non-empty (else default); phase tier must be a tiers key; cooldownMinutes positive integer; mode ∈ floor|default.

Resolution (ONE resolver, used by spawn, main plan and texts)

function availableIn(st, aliases: string[]): string | undefined
  // first alias whose full id is not down (st.down.get(id) > now → down)
function resolveModel(st, name: string): string
  // tier name → availableIn(tiers[name]) ?? first alias → id
  // alias → id (explicit: never skipped when down); full id → itself
function resolveRoute(st, route: Route): string | undefined
  // route.tier ? resolveModel(st, route.tier) : route.model ? resolveModel(st, route.model) : undefined
function rank(st, id: string): number
  // index in cfg.fallback of the alias whose id prefixes `id` (strip "[1m]"); unknown → fallback.length

now comes from $.clock.now() (read once per hook call that needs it).

Breaker (st.down: Map<string, number> full id → until ms; st.lastMainModel: string)

  • turn.complete: main (e.agentId undefined) with reason ∈ error|refusal → markDown(st, st.lastMainModel); agent with that reason → markDown(loop.model) (Loop gains model: string, the model the engine reported at spawn, started.model). aborted/answer → nothing.
  • classic.PostModelSwitch with e.source === 'auto' → markDown(e.from_model).
  • markDown logs ALWAYS (not only verbose): model-router: <id> unavailable until <HH:MM>; routing falls back. /route reload and session.end clear the map. show() gets a down: <id> until <HH:MM>, … or down: none line.

Main-loop model decision (mainModel rewritten)

wanted = resolveRoute(st, (userMain ?? turnMain ?? turnFloor)?.route)   // per-axis as today
cur = e.model
if cur is down and wanted is undefined → wanted = nextAvailable(st, cur)  // fallback chain after cur's alias
if wanted undefined or sameRank(wanted, cur) → cur
if rank(wanted) < rank(cur)  (better) → cfg.mainUpgrade ? wanted : cur
if rank(wanted) > rank(cur)  (cheaper) → cfg.mainModelSwitch && windowOk ? wanted : cur
if cur is down and wanted defined → wanted (always: nothing to lose)

st.lastMainModel = plan.model at every main step. sameRank compares aliases (so claude-fable-5-1 vs claude-fable-5-1[1m] never flips). The log/status show → fallback when the breaker chose the model.

Spawn (spawnRoute / registerSpawn)

wanted = resolveRoute(st, route); explicit e.model still wins. Store loop.model = started.model. (Explicit alias given by the caller while down: left alone, explicit means explicit; note in the tool description.)

Derived phases (no user action)

D1. Dispatch push/pop: in the Agent tool.call hook, when e.agentId is undefined (main) and st.turnMain?.source !== 'model' written AFTER the dispatch… simpler rule: on a main Agent call, if st.resumeMain is unset, st.resumeMain = st.turnMain ?? NONE and st.turnMain = { phase: 'orchestrate', route: phases.orchestrate, source: 'derived' }. When an agent's turn.complete leaves st.loops empty AND st.turnMain?.source === 'derived' → st.turnMain = st.resumeMain (NONE → null), clear resumeMain. A route call or skill load in between replaces turnMain (source model/skill) so the pop is skipped and resumeMain cleared at the next main turn.complete (endMainTurn clears both). Source type gains 'derived'. D2. Prompt default rules (mode: 'default'): write turnMain = { phase, route, source: 'prompt' } (NOT the floor) — overridable by routes and skills; floor rules unchanged. Mid-turn prompt with a default rule → only pendingPrompt-like handling for FLOOR rules stays; a default rule typed mid-turn is ignored (the running turn has its own routes). D3. Classifier: when cfg.classifier and no rule matched and the prompt is composer-origin and idle (no turnId): label = await $.model.classify(e.text.slice(0, MAX_PROMPT_SCAN), [...phaseNames, 'other']) in try/catch; a phase label → default route (source 'prompt'); anything else → nothing. Document the cost in the config comment.

Texts

routedText/mainNote/show: print the RESOLVED id and (fallback) when the breaker skipped a better alias; (tier best → claude-fable-5-1). Spinner/status unchanged shape.

Tests (register.test.ts; names must contain the contract's words)

Reuse the boot helper; full typed inputs; bottom hooks (agent.spawn, turn.complete, classic.PostModelSwitch — read its input type for the required fields; prompt.submit). Engine effort high in steps.

  • tier: a plan route upgrades a haiku session to fable at xhigh — route tool plan, step with model: 'claude-haiku-4-5-20251001' → bottom sees claude-fable-5-1 and xhigh.
  • downgrade: mechanical on fable keeps the model while the switch is off.
  • fallback: an error turn on fable moves the next main step to opus — step on fable (sets lastMainModel), $.turn.complete({ reason: 'error', agentId undefined, … }), step on fable → bottom sees claude-opus-5-5, effort unchanged; then /route reload → step on fable stays fable.
  • breaker: PostModelSwitch auto marks the old model down — $.classic.PostModelSwitch({ from_model: 'claude-fable-5-1', to_model: 'claude-opus-5-5', source: 'auto', … }) → /route show lists claude-fable-5-1 under down:.
  • spawn: Explore goes to opus while sonnet is down — mark sonnet down through an agent error turn (spawn Explore via bottom hook returning a1, $.turn.complete({ agentId: 'a1', reason: 'error' })), spawn again → bottom e.model === 'claude-opus-5-5'.
  • derived: a dispatch pushes orchestrate and pops the previous plan route — route tool plan, $.tool.call({ tool: 'Agent', … }) with a bottom hook, /route show main line shows derived orchestrate; $.turn.complete({ agentId: 'a1', reason: 'answer' }) (loop registered via spawn) → main line shows model plan again.
  • default rule: "planifie la migration" routes the turn to plan, a route call overrides.
  • Keep every B1 floor test and all earlier tests green (≥ 40 tests total).

Constraints

  • ≤ 25 logic lines per function (extract helpers: availableIn, rank, markDown, nextAvailable, decideMain, pushOrchestrate, popOrchestrate, applyDefaultRule, classifyPrompt), 80 chars/line, no any, state in the closure, fail-open .catch with warnOnce on every new hook (classic.PostModelSwitch), the route tool schema unchanged.
  • Do not touch: the hardening (caps, safely, attestation), the Skill bridge, the floor slot semantics.
  • Verify: validate, the contract's tsc CHECK, claude plugin test ., AC3/AC4 greps, gates.sh run on the contract.

Disposition

  • honors BDR-115 and its amendment (one resolver, calling-loop writes, truthful texts, per-machine config); supersedes "a phase without model keeps the loop's model" (every default phase now names a tier).
  • honors BDR-076 (dispatched judgment on opus first: big = opus, fable, sonnet) and BDR-066 (execution on sonnet: work).
  • LRN-203: hooks still write full ids (the resolver's output).
  • LRN-204: downgrade on main stays gated; upgrade accepted (quality over one cold-cache step).
  • Deferred: repo agents' frontmatter pins cannot fall back (the mod does not see them in wave 1) → wave 2 moves them into the table with tiers.

r2 — challenge round (3 lenses, all FATAL: 4 BLOCKER, 20 MAJOR): BINDING, overrides every section above where they conflict

R1. ONE phase field for the fallback-aware choice: Route = { tier?: string; model?: string; effort?: Level }. Default phases use tier: only (plan, reflect, orchestrate, escalate → best; judge → big; implement, write, verify, explore → work; mechanical → cheap). acceptPhase refuses a route carrying both tier and model, and refuses a tiers key that collides with a models alias. The earlier "model: 'best'" drafts and the "no new tier field" sentence are VOID. /route model=<alias|id> keeps writing model; the route tool schema is unchanged. R2. Ids: canonical(st, id) = strip a trailing [1m], then alias → table id, then two-way prefix match against the table ids (id.startsWith(tableId) || tableId.startsWith(id)), else the id itself. aliasOf(st, id) and modelRank(st, id) (= index of the alias in fallback, undefined when unknown) work on canonical ids. The existing effort rank keeps its name. Breaker keys, agentModels values and comparisons are canonical. When the current main model carries [1m], a resolved replacement carries [1m] too (the long-context tier is a property of the session, not of the alias); log the first time it happens (unverified live: see Verify). R3. Model axis per slot: routeModelName(route) = route.tier ?? route.model; the main model axis is the FIRST defined routeModelName across userMain, turnMain, turnFloor (per-axis, like B1's effort). resolveName turns that name into an available id: a tiers key → first alias of the list not down → models id; a tier whose every alias is down → nextAvailable(st, cur) (global chain) → may be undefined (keep cur); an alias or full id → canonical id, never skipped (explicit means explicit). R4. Main decision decideMain(st, cur, wanted, ctx) → { model, why }, used by mainPlan AND by every text (texts pass cur = canonical(await $.session.model())); order is BINDING: 1. st.off → cur. 2. rankCur = modelRank(cur); UNKNOWN cur (not in the table) → cur, log once per session (model-router: <id> unknown to the models table; no model switch), the breaker still applies at step 3 if it is down. 3. cur DOWN → wanted if defined and not down, else nextAvailable(cur); apply windowOk; if nothing fits → cur (why fallback). 4. wanted undefined or aliasOf(wanted) === aliasOf(cur) → cur. 5. modelRank(wanted) < rankCur (better) → cfg.mainUpgrade && ctx.tokens <= cfg.upgradeMaxTokens ? wanted (why upgrade) : cur (why upgrade skipped: context <n> tokens over <max> or switch off). 6. cheaper → cfg.mainModelSwitch && windowOk ? wanted (why downgrade) : cur (why switch off). New config scalar upgradeMaxTokens (default 200000): an upgrade pays a cold read of the whole context on the new model (LRN-204); above the threshold it is skipped and logged once per turn. ctx.tokens comes from $.session.usage() read once per main step (fail → treat as 0). R5. Engine fallback respected: at every main step sess = canonical(await $.session.model()); when canonical(e.model) !== sess, the engine is on a fallback → markDown(sess, 'engine fallback') and cur = e.model (the router never upgrades back to the model the engine just left). R6. Breaker inputs (replace the r1 list): (a) classic.StopFailure with error ∈ rate_limit | overloaded | billing_error | model_not_found → markDown(target) where target = st.agentModels.get(e.agent_id) when e.agent_id is set, else st.lastPlan?.model; other errors (context limit = invalid_request, server_error, auth, max_output_tokens…) → nothing; (b) R5's engine-fallback detection; (c) classic.PostModelSwitch: source command | picker | sdk → st.down.delete(canonical(to_model)) and reset its strikes (the user's explicit /model wins); source auto → LOG only (requested_model, from, to), never a mark (unverified semantics). turn.complete reason is NOT a breaker input any more (context-limit and network errors are not availability); refusal → nothing. Backoff per canonical id: strikes 1, 2, 3… → 15, 30, 60, 120, 300 min (cap); model_not_found → until /route reload. markDown logs ALWAYS: model-router: <id> unavailable (<reason>) until <HH:MM>; routing falls back. Inert while st.off. Lifecycle: /route reload clears down and strikes BEFORE loading the config (whatever the read result); session.end (/clear) KEEPS down, strikes and agentModels (availability is account-wide); expired entries are pruned at the start of any hook that reads them, with now read ONLY when st.down.size > 0 ($.clock.now()), passed explicitly to the helpers (no clock read in sync text functions: they receive the pruned map). R7. Agent models: st.agentModels: Map<agentId, canonicalId> set at spawn from started.model (canonicalized; an alias answered by a hook above is mapped through the table); deleted with the loop. No Loop.model, spawnModel or loop.model identifier anywhere (W1-A AC8 grep). spawnRoute resolves route.tier ?? route.model through resolveName (skips down aliases); explicit e.model still wins even when down. Deferred (noted): agents without a table row and no explicit model follow parentModel; forks always inherit; neither falls back in wave 1. R8. Derived orchestrate (D1) made exact: state pushed: { prev: Routed | null; spawnIds: Set<string> } | null. In the main Agent tool.call hook: before next, if st.turnMain?.source is not 'model' or 'skill' and st.pushed is null → st.pushed = { prev: st.turnMain, spawnIds: new Set() } and st.turnMain = { phase: 'orchestrate', route: phases.orchestrate, source: 'derived' }; after next resolves: the spawned agentId (from st.spawnByCall: Map<tool_use_id, agentId> filled at agent.spawn) is added to pushed.spawnIds; if NO agent was registered for this tool_use_id (foreground run already finished, or denied) → nothing to wait for from this call. Pop rule: when pushed.spawnIds is empty after the Agent call returned, or when the LAST id of pushed.spawnIds ends (turn.complete with that agentId, deleted from the set), and st.turnMain?.source === 'derived' → st.turnMain = pushed.prev, st.pushed = null. A route/skill write in between (source model/skill) replaces turnMain; the pop then only clears pushed. endMainTurn clears pushed and spawnByCall. In turn.complete for an agent, delete the loop and the maps FIRST, inside safely, before any other work. R9. Prompt default rules (D2) made safe: rules scanned in two passes (floor rules, then default rules), each pass first match; absent mode → 'floor' (B1 override files keep their meaning). Default rules are SKIPPED when the trimmed text starts with / (slash commands and skills route themselves), when the same prompt carries a floor match or sets typedSlash (the user's explicit level wins), or when typed mid-turn. Patterns compile with flags iu and the defaults use Unicode-aware guards instead of \b: (?<![\p{L}\p{N}-])(plan|planifie|planning| brainstorm|architecture|con[cç]ois|design)(?![\p{L}\p{N}-]) and the reflect list likewise; the validator requires the pattern to compile with iu. A default-rule route is written to turnMain (source 'prompt'); it never lowers (no cheap/work default rule shipped). R10. Classifier (D3) DEFERRED to wave 2: no classifier key, no code. R11. Texts: routedText, effortBridge/mainNote, slashEffort, show, statusLine, the route tool description and mainOnHaiku derive their MODEL words from decideMain with cur = canonical(await $.session.model()) (hooks are async; show becomes async — the command hook awaits it); they print the decided id and why (upgrade, fallback, switch off, unchanged). The tool description says: "the main loop moves UP to a phase's tier by itself, DOWN only with the switch on; a sub-agent's model is fixed at spawn". show prints: upgrade: on|off, switch (downgrade): on|off, down: <id> until <HH:MM> (<reason>) …| none, each phase as name=<tier or model>→<resolved id>/<effort>. Existing test 3f (/route model=sonnet shows claude-sonnet-5-5) is adapted: on the kit's session model the line reads asked claude-sonnet-5-5, keeps <cur> (switch off); the alias→id resolution is asserted on the asked part. R12. st.lastPlan: Plan | null replaces lastMain and lastMainModel; the spinner text is derived at render; endMainTurn resets it. R13. Config validation additions: tiers values non-empty arrays of alias keys (bad entries dropped, logged), fallback deduplicated non-empty alias list (else default, logged), cooldownMinutes and upgradeMaxTokens positive integers, mode ∈ floor|default, a log at load when tiers.best[0] !== fallback[0] (rank comes from fallback alone). mainModelSwitch documented as DOWNGRADE-only in the Config comment. R14. Tests (≥ 43 total, names carry the contract words): keep all 30; add: tier (plan on a haiku session → fable xhigh, with mock.clock installed where the breaker is touched), downgrade (mechanical on fable keeps fable, switch off), fallback (plan route + $.classic.StopFailure({ error: 'rate_limit', … }) on main after a fable step → next step claude-opus-5-5 at xhigh; /route reload → fable again), breaker ×3 (an aborted/invalid_request failure never marks down; backoff expiry via mock.clock advance restores fable; /model command PostModelSwitch source: 'command' clears a down model), engine fallback ($.session.model mocked/answered as fable while the step arrives on opus → no upgrade back, fable marked down), unknown (cur claude-zz-9 never switches), spawn (Explore → opus while sonnet is down through an agent StopFailure with agent_id), derived ×2 (push on dispatch, pop when the spawned agent ends → plan back; a route call after the dispatch is NOT overwritten by the pop), default rule ×3 (planifie → plan then a route call overrides; /analyze … typed → no rule; /effort-low pourquoi … → no default rule, floor low), per axis (/route effort=low sticky + turn plan tier → model axis = best). Read mock.clock and how $.session.model is answered in the kit (a bottom on('session.model', …) hook) before writing them. R15. Disposition, superseded clauses named: floor contract AC4 "turnMain only ever holds 'model' or 'skill' sources" → now also 'derived' and 'prompt'; W1-A AC6 "main-loop model changes happen only when mainModelSwitch is true" → true for DOWNGRADES only; upgrades follow mainUpgrade + upgradeMaxTokens, and the breaker/engine-fallback path moves off a dead model unconditionally; BDR-115 (6) window guard → applied to every switch (up, down, fallback) through windowOk. The tiers contract AC5 reads "every B1/1-A criterion still holds EXCEPT the three clauses above". R16. Live verification after reload (orchestrator, not the executor): the [1m] carry-over on a fallback id, PostModelSwitch source: 'auto' semantics, StopFailure reaching the mod with agent_id.

r3 — confirmation pass (FATAL(8): 1 BLOCKER, 6 MAJOR): BINDING over r2 where they conflict

S1. R5 (engine-fallback detection at every step) is REMOVED: no comparison of e.model with $.session.model() at steps, no mark from it. The engine's own fallback is learned ONLY through classic.PostModelSwitch source: 'auto', which now MARKS canonical(from_model) down with one strike (15 min) when from_model is a table id, logging requested_model, to_model. (R6(c) "auto → log only" is void.) A mark is idempotent per episode: markDown on an id already down adds NO strike and logs nothing; strikes count episodes (a mark after expiry). S2. st.sessionModel (raw string) is read once at session.start through $.session.model() inside try/catch ('' on failure) and refreshed in the PostModelSwitch hook from e.to_model (any source). No other $.session.model() call anywhere; texts use st.sessionModel. S3. Within a turn the main model is STICKY once moved: cur for the decision is st.lastPlan?.model ?? e.model (the model actually sent last; lastPlan is reset at endMainTurn so each turn starts from the engine's model). After an upgrade (plan → fable), a later cheaper phase in the same turn (implement → work) goes through the CHEAPER branch against cur = fable: gated by mainModelSwitch + windowOk, so no return trip and no second cold read. After a fallback (fable down → opus), later steps stay on opus for the turn. "Keep cur" returns the exact string last sent (e.model verbatim on the first step), so [1m] is preserved; a resolved replacement carries [1m] only when the raw session string carries it AND the target alias is not haiku. S4. decideMain spelled out (order binding): off → cur · unknown cur (no table alias) → cur, logged once · cur down → first available of [wanted (if a table id and not down), nextAvailable(cur)] that passes windowOk, else cur · wanted undefined → cur · wanted unknown to the table (explicit full id such as claude-x-9) → treated as CHEAPER (gated by mainModelSwitch, windowOk) · same alias → cur · better → mainUpgrade && tokens ≤ upgradeMaxTokens && windowOk ? wanted : cur · cheaper → mainModelSwitch && windowOk ? wanted : cur. ctx.tokens from $.session.usage() read once per main step (catch → 0); texts read it the same way (async), so a text and the step agree. nextAvailable(cur) walks fallback from the alias after cur's (unknown cur → from the top) skipping down ids; at spawn, nextAvailable walks from the tier's last alias. model_not_found marks show until reload in texts. S5. Derived orchestrate (R8 rewritten): D1 affects BACKGROUND dispatches only. In the main Agent tool.call hook: push as in R8 (source not model/skill, pushed null) BEFORE next; after next: read the RESULT — status === 'async_launched' → add result.agentId to pushed.spawnIds; any other status or a deny → nothing to wait for from this call. Pop rule unchanged (spawnIds empty after the call, or the last id's turn.complete); no spawnByCall map. Documented: a foreground dispatch pushes and pops inside one call, so no main step runs at orchestrate for it (fine: main is blocked meanwhile). S6. Breaker targets keep their value until replaced: st.lastPlan is NOT reset at endMainTurn (only the spinner text is cleared via a separate st.spinner string); agentModels entries are deleted at session.end only, never at an agent's turn.complete (the StopFailure/turn.complete order is unverified; R16 gains it). S7. R9 patterns: compile with iu; on a SyntaxError retry with i (B1 override files keep working); a pattern failing both is dropped, logged. S8. R11: the route tool description is STATIC text (registered once): "the main loop moves up to a phase's tier by itself (below the context cap), down only with the switch on; a sub-agent's model is fixed at spawn". show, routedText, mainNote, statusLine call decideMain with cur = st.lastPlan?.model ?? st.sessionModel and the same tokens read. S9. Tests, kit recipe (replaces R14 details): boot(model = 'claude-fable-5-1') registers, before the first $ call, bottom hooks on('session.model', () => ({ value: model })) (answer shape per the Op results in the declarations), on('classic.StopFailure', ($, e) => <passthrough result>), on('classic.PostModelSwitch', …), and installs mock.clock(on); every breaker test advances the mock clock. derived recipe: prompt "planifie …" (source 'prompt'), then $.tool.call({ tool: 'Agent', … }) whose bottom hook returns { result: { status: 'async_launched', agentId: 'a1', … } } (read the Agent RESULT type for the required fields) → /route show main line says derived orchestrate; then $.turn.complete({ agentId: 'a1', … }) → main line says prompt plan. Second derived test: same, but a route tool call reflect after the dispatch → the pop does not overwrite model reflect. engine fallback test: $.classic.PostModelSwitch({ from_model: 'claude-fable-5-1', to_model: 'claude-opus-5-5', source: 'auto', … }) → fable listed under down:; a plan route step does not go back to fable; a second auto switch inside the hold adds no strike (show prints the same until). Strikes test: expire (advance clock) → mark again → until doubles. S10. AC3 fix: the fallback: and tiers: greps run inside the DEFAULT_CONFIG awk range. S11. R16 gains: the order of classic.StopFailure vs turn.complete; whether the engine's fallback on a hook-rewritten request raises PostModelSwitch.

r4 — second confirmation (FATAL(4): 1 BLOCKER, 3 MAJOR): BINDING over r3 where they conflict; the last revision, executor dispatched on it

T1. Two fields, no contradiction: st.turnModel: string | undefined is the STICKY cur, set ONLY when decideMain moved the model (upgrade, downgrade or fallback), reset in endMainTurn and at session.end; st.lastPlan (the plan actually sent last, breaker target) is KEPT across turns and never used as cur. cur = st.turnModel ?? e.model. "Keep cur" returns st.turnModel when set, else e.model VERBATIM: an unrouted step never re-sends a model the router did not choose this turn, so an engine fallback that lands in e.model is respected by construction. S3's "lastPlan is reset at endMainTurn" is void (S6 stands). T2. Auto switch marking (S1 refined): on PostModelSwitch source: 'auto', let sent = canonical(st.lastPlan?.model) and to = canonical(to_model). If to === sent → nothing (the engine landed where the router already was, or the router's own rewrite surfaced as a switch). Else the mark target is sent when it is a table id (the model actually sent), else canonical(from_model) when THAT is a table id, else nothing. Always log from_model, to_model, requested_model. R16 gains: which from_model the event carries after a router upgrade, and whether a router rewrite itself raises an auto switch. T3. st.sessionModel is PRESERVED through the session.end rebuild (listed with down, strikes, agentModels). canonical() never prefix-matches an empty string or a string that does not start with claude-: both map to UNKNOWN (returned unchanged, no table id). The [1m] carry reads the raw string of the step (e.model, or st.turnModel), never sessionModel. sessionModel is used by texts only; when it is '' or unknown, texts print the engine word session model instead of an id. T4. Tokens: ctx.tokens: number | undefined (undefined on a failed or absent read). The upgrade cap treats undefined as 0 (upgrade allowed: the targets are fable/opus, no window entry); windowOk treats undefined as NOT fitting (fail closed, as today). T5. Marks: strikes are per EPISODE (a mark on an id already down adds no strike and no log), but a model_not_found arriving during a timed hold LENGTHENS it to "until reload" (logged once). Auto marks use the same episode backoff (15 → 30 → 60 → 120 → 300 min). T6. S5 race: on an async_launched result with st.pushed === null, push again first (if turnMain?.source still allows it), then add the id. T7. Tests assert hold DURATIONS (minutes until, computed from the mock clock) or the presence of the id under down:, never a literal HH:MM. show prints down: <id> for <n> min (<reason>) (and until reload), computed from the pruned map and the clock value passed in. T8. R16 final list (live, orchestrator): StopFailure vs turn.complete order; PostModelSwitch on a rewritten-request fallback and its from_model; whether a router rewrite raises auto; [1m] carry validity on opus; $.session.model() string form.