# PLAN — model-router wave 1-A: the mod (dispatch-ready) — REVISED r3 r3 changes (confirmation pass, 4 MAJOR + minors): explicit Agent params are frozen on the Loop and never overridden by in-agent `route`/`Skill(effort-*)`; `pendingPrompt` honours `e.wait`; tests assert on the `main:` line of `show` only, with full typed inputs; haiku gets `effort: undefined`; `userMain` beats every turn route including a typed `/effort-` (the text says so); `skill.prompt` acts only when no Skill call is in flight; route tool handles `clear` first and answers "off" in place; `turn.step` catch is a generator; windows keyed by resolved id; validate user entries BEFORE merging; truthful answers; unpinned-skill reset accepted and documented. Contract: .claude/tasks/contracts/2026-10-08-model-router-w1a-1533.md Wave plan + harness facts: .claude/tasks/plans/2026-10-08-model-router-mod.md Base: the spike `~/.claude/dev-mods/385f7190-70f5-4bdd-b0d8-e4566cd412fd/model-router/hooks/register.ts` (read it first; keep its proven hook shapes; drop its spike levers `via`, `stepModel`, `agentsDefault`, the tool's `model`/`scope`/`mainModelSwitch` params and the hardcoded PHASES). API reference: `/.claude-plugin/types/claude-code/index.d.ts` (grep the event or noun; `declare module 'claude-code/testing'` for the test kit) and `/.claude-plugin/types/claude-code-tools/index.d.ts` (`Skill: {`, `Agent: {`, and the Skill RESULT schema near line 5139). r2 changes (challenge round, 3 lenses): agents table = built-ins only; one writer per agent model (spawn), no per-step model rewrite unless an in-agent `route` call changed it and the engine did not fall back; every write goes to the CALLING loop; `agentsNext` / `scope` / `/route agents` / tool `model` dropped; Skill bridge answers in the tool's output shape; unconditional skill-load reset (prompt route kept); config validated at load, refs resolved once; state in the `register` closure, cloned defaults; `/route off` kill switch; queued `ultrathink` promoted to its own turn; AC1/AC5 amended. ## Files - [ ] mods/model-router/.claude-plugin/plugin.json — `{ "name": "model-router", "version": "0.1.0", "description": "", "author": { "name": "bchanot" } }` - [ ] mods/model-router/hooks/hooks.json — `{ "modules": ["./register.ts"] }` - [ ] mods/model-router/hooks/register.ts — the hooks module (below) - [ ] mods/model-router/hooks/register.test.ts — `claude plugin test` suite (below) The engine lays `./tsconfig.json` and `.claude-plugin/types/` beside a loaded mod; both are ignored by AC1 and gitignored in wave 1-B. Never commit them. ## Config (one shape, defaults in code, optional override on disk) ```ts type Level = 'low' | 'medium' | 'high' | 'xhigh' | 'max' type Route = { model?: string; effort?: Level } // model = alias OR full id type Config = { models: Record // alias → full id windows: Record // alias → context window (tokens) phases: Record agents: Record // built-in subagentType → phase name skills: Record // skill name → phase name prompt: { pattern: string; phase: string }[] // regex source, flag i mainModelSwitch: boolean; verbose: boolean; spinner: boolean } ``` DEFAULT_CONFIG values: - models: haiku→`claude-haiku-4-5-20251001`, sonnet→`claude-sonnet-5-5`, opus→`claude-opus-5-5`, fable→`claude-fable-5-1`. - windows: `claude-haiku-4-5-20251001`→200000 (keyed by FULL id; others unknown: absent = no check). - phases: plan {effort xhigh}, reflect {effort high}, orchestrate {effort medium}, escalate {effort max}, judge {opus, xhigh}, implement {sonnet, medium}, write {sonnet, medium}, verify {sonnet, xhigh}, explore {sonnet, medium}, mechanical {haiku, low}. A phase without `model` keeps the loop's model. - agents: Explore→explore, Plan→judge. NOTHING else in wave 1: every repo agent keeps its frontmatter pin (the engine applies it); the pins move into this table in wave 2, in the same change that deletes the frontmatter. - skills: {} (wave 2 fills it). - prompt: [{ pattern: '\\bultrathink\\b', phase: 'escalate' }]. - mainModelSwitch false, verbose false, spinner true. (Pass B, user 2026-10-08: verbose default off but ON for now through the override file, spinner on, user `/route` sticky, Explore → sonnet/medium.) `loadConfig($, log)` → `Config` (never throws): 1. `home = await $.env.get('HOME')`; path `${home}/.claude/model-router.json`; `$.fs.exists` then `$.fs.read`; `JSON.parse`. Any failure → the defaults. 2. `mergeConfig(D, u, log)`: FIXED merge, no recursion, VALIDATE EACH USER ENTRY BEFORE IT REPLACES A DEFAULT (an invalid user `models.sonnet` is dropped and the default kept, so the phases on `sonnet` stay valid): for each table (`models`, `windows`, `phases`, `agents`, `skills`) take `u.` only when it is a plain object, then per key: valid → over the default, invalid → `log(...)` and keep the default. Scalars (`mainModelSwitch`, `verbose`, `spinner`) taken only when boolean. `prompt` taken only when an array; each rule validated or dropped. Type guards on `unknown`, no `any`. 3. Validity rules (`log(...)` ALWAYS, not only verbose: a config error must be seen once): - models value: string matching `/^claude-[a-z0-9.-]+$/`; - windows: key a full id (same regex), value a positive integer; - phase: plain object; `effort` absent or in LEVELS; `model` absent, a `models` key or a full id; at least one of the two; - agents / skills value: a phase name (checked after phases merged); - prompt rule: `{ pattern: string, phase: }` whose pattern compiles (`new RegExp(p, 'i')` in try/catch). Lookups use `Object.hasOwn`, never bare indexing on user keys. 4. Returns `structuredClone`d data: the defaults constant is never handed out by reference. `compileRules(cfg)` → `{ re: RegExp; phase: string }[]` once per load. `resolveModel(cfg, name)`: `Object.hasOwn(cfg.models, name) ? cfg.models[name] : name`. `isModelName(cfg, v)`: a `models` key or the full-id regex. `isLevel(v)`. ## State — ONE object built inside `register`, passed to every helper ```ts type Source = 'user' | 'model' | 'skill' | 'prompt' | 'slash' type Routed = { phase: string; route: Route; source: Source } type Loop = { effort?: Level; model?: string // routed by the table or an in-agent call spawnModel: string; frozen: boolean // engine's model at spawn; fork/workflow explicitModel: boolean; explicitEffort: boolean // Agent params given → axis frozen } type State = { cfg: Config; rules: Rule[] userMain: Routed | null // /route by the user; sticky until /route clear turnMain: Routed | null // tool / skill / prompt / slash; dropped at turn end pendingPrompt: Routed | null // prompt rule typed mid-turn, promoted next turn loops: Map // agentId → that loop's routing explicitEffort: Map // Agent tool_use_id → explicit effort param skillCalls: number // Skill tool calls in flight (hook 4 ± around next) off: boolean // /route off: every hook passes through lastMain: string // "model/effort" of the last main step (spinner) } ``` `newState(cfg)` builds it; `register` calls it once; `session.start` reloads `cfg` + `rules` into it; `session.end` rebuilds it (`/clear` fires no `session.start`, so sticky routes must not survive a clear). Effective main route `mainRoute(st)`: `st.userMain ?? st.turnMain`. ONE order, transitive: user `/route` (sticky) > the latest turn route (tool, skill, slash, prompt all share `turnMain`; last writer wins) > session. A typed `/effort-` while a sticky route is in force does not apply; its text says so (hook 5). Loop lookups: `loopOf(st, e.agentId)`; a missing entry is created on first write as `{ spawnModel: '', frozen: false, explicitModel: false, explicitEffort: false }`. In-agent writes (hooks 3 and 4) never set an axis whose `explicit*` flag is true: an explicit Agent param wins for the whole run. ## Hooks Rule for failures: hooks that only observe or rewrite carry `.catch(($, e, next) => next(e))`; `turn.step` streams, so its catch is the generator form `async function* ($, e, next) { return yield* next(e) }` (a plain function there is a type error). The four hooks that ANSWER without `next` (command.run, the route tool, the Skill `effort-*` bridge, `skill.prompt`) carry a `.catch` that answers in place: `{ text: 'route failed ()' }`, `{ result: 'route failed (); nothing routed' }`, and for the two skill hooks `next(e)` (the skill then loads normally — a safe fallback). State is mutated only AFTER the input validated. 1. `session.start`: `st.cfg = await loadConfig(...)`, `st.rules = compileRules`; `registerTool($, st)` (`$.tool.register({ name: 'route', description, inputSchema })`: properties `phase` (enum = Object.keys(st.cfg.phases)), `effort` (enum LEVELS), `clear` (boolean); no `required`). The description tells the model: declare the phase before a span changes nature; acts on the calling loop only; no model choice here. `$.command.register({ name: 'route', description, argumentHint: '[show|clear|off|on|reload|| model= effort=|switch on|off|verbose on|off]', immediate: true })` in try/catch (log on failure, keep going). `$.ui.status(statusLine(st))`. 2. `command.run {command:'route'}` → `{ text: handleCommand($, st, e.args) }`: `show`/empty → `show(st)`; `clear` → userMain = turnMain = pendingPrompt = null; `off` / `on` → st.off; `reload` → loadConfig + compileRules + `registerTool` again (the phase enum follows the config) + 'config reloaded' + show; `switch on|off` → cfg.mainModelSwitch; `verbose on|off`; otherwise `parseRoute(st.cfg, args)`: a phase name, or tokens `model=` / `effort=` / bare alias / bare level, each validated by `isModelName` / `isLevel` → `st.userMain = { phase, route, source: 'user' }`; any unknown token → error text listing the phases and the levels. Never calls `next`. 3. `tool.call {tool:'mcp__model-router__route'}` → `handleRouteTool`, in this order: (i) `st.off` → `{ result: 'model-router is off (/route on to resume); nothing routed' }`; (ii) `clear` → main: `turnMain = null`; agent: unset the loop's `effort`/`model` → `{ result: 'route cleared for ' }`; (iii) validate: `phase` given and not a `phases` key → `{ deny: 'unknown phase "

"; phases: …' }` (even with a valid `effort`); `effort` given and not a level → deny naming the levels; neither given → deny. (iv) route = `{ ...phases[phase], ...(effort ? { effort } : {}) }` (an explicit effort overrides the phase's). Target = the CALLING loop: main → `turnMain = { phase: phase ?? 'effort-' + effort, route, source: 'model' }`; agent → `loop.effort = route.effort` unless `loop.explicitEffort`; `loop.model = route.model` unless `loop.explicitModel` (applied at step only under the fallback guard, never on a frozen loop). (v) Answer on the EFFECTIVE outcome: main with a sticky `userMain` → `'recorded for this turn, but a sticky /route is in force; it wins until /route clear'`; otherwise `'routed to : effort , model '`, and when a model is part of the route on main while `mainModelSwitch` is off, say `model unchanged (switch off)`. Verbose → log. 4. `tool.call {tool:'Skill'}`: a. `e.skill` matches `/^effort-(low|medium|high|xhigh|max)$/` → the CALLING loop: main → `turnMain = { phase: e.skill, route: { ...st.turnMain?.route, effort }, source: 'skill' }` (effort merged over the current turn route, last loaded wins); agent → `loopOf(...).effort = level` unless `loop.explicitEffort` (entry created if missing: an untabled agent's shift must still land). Answer WITHOUT `next`, in the Skill tool's output shape (claude-code-tools ~5139; a string result is refused and the skill would load): `{ result: { success: true, commandName: e.skill, status: 'inline' }, context: [] }` where `` states the effective outcome: `'model-router: effort → for this loop from the next request on; the effort- skill text was not loaded.'`, or when main has a sticky `userMain`: `'model-router: effort- recorded, but a sticky /route is in force and wins until /route clear.'`, or when the agent axis is explicit: `'model-router: this agent was dispatched with an explicit effort; the shift does not apply.'` b. Any other skill: `st.skillCalls += 1` before `next(e)`, `-= 1` after (try/finally). Main → `turnMain = null` when its source is 'model', 'skill' or 'slash' (a 'prompt' route such as `ultrathink` stays unless the skill has a table entry); `Object.hasOwn(cfg.skills, e.skill)` → `turnMain = { phase, route, source: 'skill' }`. Agent → unset the loop's non-explicit `effort`/`model`; table entry → `loop.effort = route.effort` unless explicit (never model). Accepted change vs the legacy shifters: loading an UNPINNED skill after a shift returns main to the harness level (orchestrators already re-assert after a nested skill, lib/effort-shift.md § Re-assert). Then `return next(e)`. 5. `skill.prompt {skill: /^effort-/}`: `st.skillCalls > 0` (reached through the bridge's fallback or a Skill call) → `next(e)`. Otherwise it is a user-typed `/effort-` (or a preload, unsupported: treated the same): level parse; `turnMain = { phase: e.skill, route: { effort }, source: 'slash' }`; return `{ text: + '\n' + e.text }` (prepend, never replace: args ride in the text) where `` is `'Effort shifted to by model-router for this turn.'` or, with a sticky `userMain`, `'Effort recorded; the sticky /route wins until /route clear.'`. Unknown suffix → `next(e)`. 6. `tool.call {tool:'Agent'}`: `isLevel(e.effort) && typeof e.tool_use_id === 'string'` → `st.explicitEffort.set(e.tool_use_id, e.effort)`; always `return next(e)` unchanged (params are never rewritten). 7. `agent.spawn`: `frozen = e.fork || e.workflow !== undefined`. Route: `!frozen && e.provider.plugin === 'engine' && Object.hasOwn(cfg.agents, e.subagentType)` → `cfg.phases[cfg.agents[e.subagentType]]`, else none. Model rewrite ONLY when route?.model is set AND `e.model === undefined` (an explicit param wins): `next({ ...e, model: resolveModel(cfg, route.model) })`, else `next(e)`. On a non-deny result with `agentId`: `loops.set(agentId, { spawnModel: result.model, frozen, explicitModel: e.model !== undefined, explicitEffort: given !== undefined, effort: given ? undefined : route?.effort })` where `given = explicitEffort.get(e.tool_use_id)` (then deleted). `model` is NOT stored at spawn: the engine already runs the agent on it. Verbose log `spawn : → `. Known limit, documented in a comment: `provider.plugin === 'engine'` is the best available test for a built-in at spawn; a user agent named `Explore` in a foreign project would also match (wave 1 impact: sonnet/medium on it). 8. `turn.step` (async generator). `st.off` → log when verbose, `yield* next(e)`. Agent loop (`e.agentId`): `loop = loops.get(...)`; `effort = loop?.effort ?? e.effort`; `model = loop?.model && !loop.frozen && e.model === loop.spawnModel ? resolveModel(cfg, loop.model) : e.model` (an engine fallback — `e.model` differs from the spawn model — is never fought). Main: `set = mainRoute(st)`; `effort = set?.route.effort ?? e.effort`; `model = set?.route.model && cfg.mainModelSwitch && windowOk ? resolved : e.model`, where `resolved = resolveModel(cfg, set.route.model)` and `windowOk` = no `cfg.windows[resolved]` entry (windows are keyed by FULL id; the defaults key haiku's full id) or `(await $.session.usage()).context.tokens` is a number below it; an absent `tokens` or a failed `usage()` → no switch, logged once per turn. If the model actually sent starts with `claude-haiku`, send `effort: undefined` (omit it entirely; haiku takes none and a hook-set effort on it is unproven). Main → `lastMain = '/'`, `$.ui.status(statusLine(st))`. Verbose → log before (`step : → `) and after (`answered by `). `const r = yield* next(changed ? { ...e, model, effort } : e); return r`. 9. `prompt.submit`: `e.origin.kind !== 'composer'` → `next(e)`. First rule in `st.rules` whose `re.test(e.text)` → `routed = { phase, route, source: 'prompt' }`; `e.turnId !== undefined && e.wait` (typed mid-turn and asked to wait: it belongs to the NEXT turn) → `st.pendingPrompt = routed`; otherwise (idle, or delivered INTO the running turn) → `st.turnMain = routed`. `return next(e)`. 10. `turn.complete`: `e.agentId` → `loops.delete(e.agentId)`. Main → `turnMain = pendingPrompt; pendingPrompt = null; explicitEffort.clear(); lastMain = ''`; `$.ui.status(statusLine(st))`. `return next(e)`. 11. `ui.render {component:'Spinner'}`: `cfg.spinner && lastMain` → `next({ ...e, props: { ...e.props, suffix: ' · ' + lastMain + '…' } })` else `next(e)`. 12. `session.end`: `Object.assign(st, newState(st.cfg))` (keeps the loaded config, drops every route and map). `return next(e)`. `statusLine(st)`: `'route: ' + (st.off ? 'off' : describe(mainRoute(st)) )` where `describe` = `' '` or `'session defaults'`, plus `' · switch on'` when `cfg.mainModelSwitch`. `show(st)`: main (effective, with its source), off/on, switch, verbose, spinner, live loops count, phases as `name=/` (resolved ids printed, so a wrong `models` entry is visible), config source line (`defaults` or the override path). ## Tests (register.test.ts, `import { test, expect } from 'claude-code/testing'`) Read the kit's declarations first (`declare module 'claude-code/testing'`): `test(name, async ($, on) => …)`; events are fired as calls on `$` with the event's FULL input (the kit's `$` is `EngineCall = (e: Args)`, and AC2 type-checks the test file): `$.command.run({ command: 'route', args: 'show', origin: { kind: 'composer' }, presentation: })`, `$.prompt.submit({ text: 'ultrathink please', wait: false, origin: { kind: 'composer' } })`, `$.agent.spawn({ tool_use_id: 't1', prompt: 'x', description: 'd', subagentType: 'Explore', provider: { plugin: 'engine', tier: 'core' }, parentModel: 'claude-fable-5-1', background: false, fork: false })`, `$.tool.call({ tool: 'Skill', skill: 'effort-low' })`. Read each input type and fill every required field; never relax a test to dodge a type. Establish from the kit whether `session.start` fires at load; if not, fire `$.session.start(...)` first in every test. An event whose hook calls `next` needs a BOTTOM hook registered by the test through its `on` (the kit's bottom throws otherwise), e.g. `on('prompt.submit', ($, e) => ({ text: e.text }))`, `on('agent.spawn', ($, e) => ({ model: e.model, agentId: 'a1' }))`. A helper `mainLine(text)` returns the `main:` line of `show`; EVERY assertion on a route reads that line only (the phases listing always contains every id and level, so matching the whole text proves nothing). Tests (one per contract item 3a-3g): - 3a `Skill(effort-low)` via `$.tool.call`: resolves with `result.success === true` and `result.commandName === 'effort-low'`; a bottom `on('tool.call', { tool: 'Skill' })` registered by the test is NOT reached (flag); `mainLine` contains `skill effort-low` and `low`. - 3b route tool `{ phase: 'orchestrate' }` → `mainLine` contains `model orchestrate` and `medium`. - 3c `/route clear` → `mainLine` contains `session defaults`. - 3d `/route bogus` → text contains `unknown` and every phase name. - 3e `$.prompt.submit` with `ultrathink`, `wait: false`, no `turnId` → `mainLine` contains `prompt escalate`. - 3f `/route model=sonnet` → `mainLine` contains `claude-sonnet-5-5`; `/route model=claude-x-9` → `mainLine` contains `claude-x-9`; `/route model=sonet` → text contains `unknown`. - 3g spawn path: `$.agent.spawn(...)` for `Explore` without `model`, bottom hook captures `e.model === 'claude-sonnet-5-5'`; with `model: 'opus'` given → captured `e.model === 'opus'`. No fs, network or process in tests: the defaults path is the one exercised. ## Edge cases - `$.command.register` throws when `/route` is taken → log, keep the tool. - `loadConfig` never throws out of `session.start`; invalid entries dropped with a log. - `/clear` → `session.end` rebuilds the state; `/route reload` re-registers the tool. - Remote agents raise no `turn.complete`; denied Agent calls never spawn: both maps are bounded by `explicitEffort.clear()` at main turn end and `loops` entries only for started agents (a leak of a few entries per turn is accepted). - `loops.delete` at an agent's `turn.complete`: a resumed agent (SendMessage, woken teammate) runs its later turns at the engine's effort. Accepted in wave 1 (built-ins only); revisit with the pins in wave 2. - Spawn-vs-first-step race: the loop entry is set after `next(e)` resolves, so step 0 of a tabled built-in may run at the engine's effort. Accepted in wave 1 (Explore/Plan only); wave 2 verifies the ordering before pins move. - Unpinned agents (general-purpose, interviewer, client-handover-writer) no longer inherit a shifted level: the bridge does not move the harness level. Accepted: BDR-077 already requires explicit call-site params for built-ins; the two inline-load agents run on main's own route. - The word `any` must not appear as a TypeScript type in register.ts (AC5 greps `: any`, ``, `as any`). ## Disposition (STEP 0.6) - honors BDR-066/076/077 (tiers): wave 1 touches only the two built-ins that carry no pin (Explore sonnet/medium, Plan opus/xhigh); every repo agent keeps its frontmatter as the single writer; explicit call-site params win. - honors BDR-107/108: levels and aliases unchanged; aliases stay the config's vocabulary, full ids are resolved by the mod (LRN-203, BLK-029). - LRN-180/181 made moot: the bridge answers `Skill(effort-*)` itself, no pairing rule. "Last loaded wins" holds among pinned skills and shifts; the one behaviour change, accepted: loading an UNPINNED skill after a shift returns main to the harness level (orchestrators re-assert after nested skills already, lib/effort-shift.md § Re-assert). - LRN-204: main-loop model switch behind `mainModelSwitch` (default false) and a context-window guard. - BDR-044 not contradicted: the mod routes model/effort, never skills. - Deferred (minor, challenge r1): a ceiling on model-declared efforts; the spawn-vs-step race.