32 KiB
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)
- "Session model" phases assumed Fable. On a haiku session,
planat xhigh on haiku is wrong. Phases must name ABSOLUTE tiers. - When Fable has no credit left, routing must fall back (plan → opus xhigh).
- The user never types
/route: phases are derived (prompt wording, dispatch spans, skills) or declared by the model. - 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.stepresultusage: null,stopReason: null;turn.completereason: 'error'(retries exhausted) or'refusal'(refused, no fallback model);'aborted'= the user interrupted. classic.PostModelSwitchfires on every main-model change withfrom_model,to_model,source∈ command|picker|sdk|auto|resume,context_tokens,prompt_cache_warm.turn.stepe.model= the id the engine resolved (session's or a fallback's);$.session.model()= the main loop's model as/modelshows.$.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 themodelfield name: a tier name is a model NAME the resolver understands; no newtierfield. AC3 grepstier: '…'?? NO: AC3 is written againstmodel: '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' }].modeabsent → 'default'. AC3 note for the executor: the contract's CHECK countstier: '(best|big|work|cheap)'in DEFAULT_CONFIG and refusesmodel: 'entries there. So the PHASE type gets an explicittier?: stringfield:type Route = { tier?: string; model?: string; effort?: Level }; a route resolvestierfirst, thenmodel. Default phases usetier:./route model=<x>keeps writingmodel(alias or id). The route tool keepsphase/effort/clearonly. Validation:tiersvalues = non-empty arrays ofmodelskeys (bad entries dropped, logged);fallback= array ofmodelskeys, deduplicated, non-empty (else default); phasetiermust be atierskey;cooldownMinutespositive 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.agentIdundefined) withreason∈ error|refusal →markDown(st, st.lastMainModel); agent with that reason →markDown(loop.model)(Loop gainsmodel: string, the model the engine reported at spawn,started.model).aborted/answer→ nothing.classic.PostModelSwitchwithe.source === 'auto'→markDown(e.from_model).markDownlogs ALWAYS (not only verbose):model-router: <id> unavailable until <HH:MM>; routing falls back./route reloadandsession.endclear the map.show()gets adown: <id> until <HH:MM>, …ordown: noneline.
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 toolplan, step withmodel: 'claude-haiku-4-5-20251001'→ bottom seesclaude-fable-5-1andxhigh.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 seesclaude-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 showlistsclaude-fable-5-1underdown:.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 → bottome.model === 'claude-opus-5-5'.derived: a dispatch pushes orchestrate and pops the previous plan route— route toolplan,$.tool.call({ tool: 'Agent', … })with a bottom hook,/route showmain line showsderived orchestrate;$.turn.complete({ agentId: 'a1', reason: 'answer' })(loop registered via spawn) → main line showsmodel planagain.default rule: "planifie la migration" routes the turn to plan, a route call overrides.- Keep every B1
floortest 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, noany, state in the closure, fail-open.catchwithwarnOnceon 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 runon 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.