chore(tasks): model-router w1a contract + plan r3

This commit is contained in:
bchanot
2026-10-08 16:26:57 +02:00
parent b721c94dcb
commit e8ca713d9e
2 changed files with 394 additions and 0 deletions
@@ -0,0 +1,47 @@
# CONTRACT — model-router-w1a (wave 1-A: the mod itself)
- date: 2026-10-08 | flow: feat | branch: feature/model-router-mod
- status: active
## REQUEST (verbatim — IMMUTABLE)
Skill args: "model-router mod, wave 1-A: the mod itself under mods/model-router/ (plugin.json, hooks.json, register.ts, config.json alias→id + phases/agents/skills/prompt tables, register.test.ts); spike code in ~/.claude/dev-mods/385f7190-70f5-4bdd-b0d8-e4566cd412fd/model-router/ is the base; plan .claude/tasks/plans/2026-10-08-model-router-mod.md W1.1-W1.9"
User (fr, same session): "go pour le registre et go sur la vague 1". Earlier framing (verbatim excerpts): "repartir correctement chaque tache au model qui lui correspond […] Il faut que l'effort aussi soit en consequence […] plus propre, plus unifier et plus automatique (meme dans la discussion courante ou d'un agent on puisse switch d'un model / effort a un autre. Et le mieux que ca soit configurable et qu'on puisse l'installer et qu'il soit actif sur toutes les session en userscope"; "Il faut un pin pour le global, mais toutes les sous taches fait pas le routage donne au model correspondant"; "si la route modifie deja les efforts, alors les skills pour changer les efforts devienne inutile mais vont quand meme etre trigger. Ca fait doublon, des token pour rien used, et peut etre meme des conflits non ?"
## CLARIFICATIONS
Q: config.json as a 5th file? / A: no — defaults live in register.ts (`DEFAULT_CONFIG`), the optional user override is `~/.claude/model-router.json` (deep-merged); `claude plugin test` runs without fs, so the mod must work with no file at all. 4 files. [orchestrator — internal, derived from the test sandbox]
Q: userConfig (W1.8) / A: dropped for 1-A — `mainModelSwitch`, `verbose`, `spinner` are keys of the same config (one source), toggled live by `/route`. [orchestrator — internal]
Q: model ids / A: hooks always write FULL ids from `config.models` (alias → id); the Agent tool param is never rewritten (its schema accepts aliases only, and the hook-side alias resolver is stale, LRN-203 / BLK-029). [orchestrator — in-force learning]
Q: precedence / A: user `/route` (sticky until `/route clear`) > the latest turn-scoped route on main (model `route` tool, a skill load's table phase or a `Skill(effort-*)` shift, a typed `/effort-*`, a prompt rule: one slot, last writer wins; a non-effort skill load resets the slot except a prompt rule) > session settings. Explicit Agent-call `model`/`effort` params always win for that agent, for its whole run: an in-agent `route` call or `Skill(effort-*)` never touches an axis given explicitly. [orchestrator — derived from the user's "pin = entry default, sub-tasks route finer"; r3 after the confirmation challenge]
Q: verbose default / A: `verbose: false` in DEFAULT_CONFIG; for now the user wants it ON to watch the routing → after the build the orchestrator writes `~/.claude/model-router.json` with `{"verbose": true}` (user-home file, outside FILE SCOPE). [gated 2026-10-08]
Q: spinner suffix default / A: on (`spinner: true`). [gated 2026-10-08]
Q: `/route` typed by the user / A: sticky until `/route clear`, wins over model-declared routes. [gated 2026-10-08]
Q: Explore built-in / A: `explore` phase = sonnet / medium (supersedes the BDR-066 wave-3 inherit for Explore). [gated 2026-10-08]
Q: legacy `Skill(effort-*)` / A: answered by the mod without loading the skill (single writer, no pairing rule); `/effort-*` typed by the user → `skill.prompt` sets the same route and returns a one-line text. [user 2026-10-08: "doublon … conflits"]
## ACCEPTANCE CRITERIA
1. `mods/model-router/` holds exactly `.claude-plugin/plugin.json`, `hooks/hooks.json`, `hooks/register.ts`, `hooks/register.test.ts` (the engine-laid `.claude-plugin/types/` folder and `./tsconfig.json` are ignored, never committed); `claude plugin validate` passes with no warning.
CHECK: cd mods/model-router && [ "$(find . -type f | grep -v '/.claude-plugin/types/' | grep -v '^./tsconfig.json$' | sort | tr '\n' ' ')" = "./.claude-plugin/plugin.json ./hooks/hooks.json ./hooks/register.test.ts ./hooks/register.ts " ] && out=$(claude plugin validate . 2>&1) && echo "$out" | grep -q 'Validation passed' && ! echo "$out" | grep -qi 'warning' && echo FILES-VALIDATE-OK
EXPECT: FILES-VALIDATE-OK
EVIDENCE: MET exit=0 marker-found :: FILES-VALIDATE-OK
2. Type-check clean against this build's declarations (the engine-laid copy beside the spike mod).
CHECK: T=/Users/b.chanot/.claude/dev-mods/385f7190-70f5-4bdd-b0d8-e4566cd412fd/model-router/.claude-plugin/types; W=$(mktemp -d) && printf '{"compilerOptions":{"target":"es2023","lib":["es2023"],"types":[],"module":"esnext","moduleResolution":"bundler","strict":true,"noUncheckedIndexedAccess":true,"noEmit":true,"skipLibCheck":true,"jsx":"react","jsxFactory":"h","jsxFragmentFactory":"Fragment"},"include":["%s/claude-code/index.d.ts","%s/claude-code-tools/index.d.ts","%s/hooks"]}' "$T" "$T" "$PWD/mods/model-router" > "$W/tsconfig.json" && (cd "$W" && npx --yes -p typescript@5 tsc -p tsconfig.json) && echo TSC-OK
EXPECT: TSC-OK
EVIDENCE: MET exit=0 marker-found :: TSC-OK
3. `claude plugin test mods/model-router` passes; the suite covers: (a) `Skill(effort-low)` via `$.tool.call` is answered without `next` in the Skill tool's output shape (`success`, `commandName`) and the route shows `low` on main; (b) the `route` tool with `phase: "orchestrate"` sets `medium` on main and `/route show` prints it; (c) `/route clear` drops it; (d) `/route bogus` returns an error text naming the phases; (e) a `prompt.submit` text holding `ultrathink` sets `escalate` on main; (f) `/route model=sonnet` shows `claude-sonnet-5-5`, a full id passes through, a misspelt alias is refused; (g) `agent.spawn` of `Explore` without a model param reaches the bottom with `model === 'claude-sonnet-5-5'`, and with `model: 'opus'` given the param is untouched.
CHECK: cd mods/model-router && out=$(claude plugin test . 2>&1); rc=$?; echo "$out" | tail -n 5; [ $rc -eq 0 ] && [ "$(grep -cE '^\s*test\(' hooks/register.test.ts)" -ge 7 ] && echo PLUGIN-TEST-OK
EXPECT: PLUGIN-TEST-OK
EVIDENCE: MET exit=0 marker-found :: (pass) a tabled agent steps at its table effort [19.75ms] 11 pass 0 fail Ran 11 tests across 1 file. [0.47s] PLUGIN-TEST-OK
4. Hooks present, as `claude plugin validate` lists them: `session.start`, `command.run{command=route}`, `tool.call{tool=mcp__model-router__route}`, `tool.call{tool=Skill}`, `tool.call{tool=Agent}`, `skill.prompt`, `agent.spawn`, `turn.step`, `prompt.submit`, `turn.complete`, `ui.render{component=Spinner}`; every gating hook carries a fail-open `.catch` (validate prints no "gating hook without .catch").
CHECK: cd mods/model-router && out=$(claude plugin validate . 2>&1) && for h in session.start 'command.run{command=route}' 'tool.call{tool=mcp__model-router__route}' 'tool.call{tool=Skill}' 'tool.call{tool=Agent}' skill.prompt agent.spawn turn.step prompt.submit turn.complete 'ui.render{component=Spinner}'; do echo "$out" | grep -qF -- "$h" || { echo "missing $h"; exit 1; }; done && ! echo "$out" | grep -q 'without .catch' && echo HOOKS-OK
EXPECT: HOOKS-OK
EVIDENCE: MET exit=0 marker-found :: HOOKS-OK
5. Code style: no line over 80 chars, no `any` type, no `import()`; `register.ts` imports only from `claude-code` and its own plugin files.
CHECK: cd mods/model-router/hooks && ! grep -nE '.{81,}' register.ts register.test.ts && ! grep -nE ':\s*any\b|<any>|as any\b' register.ts && ! grep -q 'import(' register.ts && [ "$(grep -cE "^import .* from '(claude-code|\./)" register.ts)" -eq "$(grep -c '^import ' register.ts)" ] && echo STYLE-OK
EXPECT: STYLE-OK
EVIDENCE: MET exit=0 marker-found :: STYLE-OK
6. Judged by reading: the mod never writes a model alias into a request (every `model` it sets at `agent.spawn` or `turn.step` passes through `resolveModel`); the Agent tool's `model`/`effort` params are never rewritten and an explicit `model` param is never overridden at spawn or at any step; an agent's model is written once, at spawn (a per-step model rewrite happens only after an in-agent `route` call and only while `e.model` still equals the spawn model); a fork (`e.fork`) and a workflow agent (`e.workflow`) are never re-modelled; every write from a sub-agent's Skill or `route` call lands on that agent's loop, never on main; main-loop model changes happen only when `mainModelSwitch` is true; a `.catch` on every gating hook fails open (pass-through or an in-place answer) so a mod failure never blocks a call; all state lives in the `register` closure and the defaults constant is never mutated; no function over 25 logic lines; the spike's `via` / `stepModel` / `agentsDefault` levers and the tool's `model` / `scope` params are gone.
Q (r2): `scope: agents` / `/route agents` / A: dropped (challenge r1, no requirement behind it; per-call Agent params and the table cover it). The route tool takes `phase`, `effort`, `clear` only. [orchestrator — simplicity]
Q (r2): agents table in wave 1 / A: built-ins only (Explore, Plan), matched for the engine provider; repo agents keep their frontmatter as the single writer until wave 2. [orchestrator — single source of truth]
Q (r2): `/route off` / A: added as the session kill switch (every hook passes through). [orchestrator — robustness]
## FILE SCOPE
mods/model-router/.claude-plugin/plugin.json · mods/model-router/hooks/hooks.json · mods/model-router/hooks/register.ts · mods/model-router/hooks/register.test.ts
@@ -0,0 +1,347 @@
# 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-<l>` (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: `<spike>/.claude-plugin/types/claude-code/index.d.ts` (grep the
event or noun; `declare module 'claude-code/testing'` for the test kit) and
`<spike>/.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": "<one line>", "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<string, string> // alias → full id
windows: Record<string, number> // alias → context window (tokens)
phases: Record<string, Route>
agents: Record<string, string> // built-in subagentType → phase name
skills: Record<string, string> // 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.<table>` 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: <phase name> }` 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<string, Loop> // agentId → that loop's routing
explicitEffort: Map<string, Level> // 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-<l>` 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 (<kind>)' }`,
`{ result: 'route failed (<kind>); 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|<phase>|
model=<alias|id> effort=<level>|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=<m>`
/ `effort=<l>` / 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 <loop>' }`;
(iii) validate: `phase` given and not a `phases` key → `{ deny: 'unknown
phase "<p>"; 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 <phase> for this turn,
but a sticky /route <userMain.phase> is in force; it wins until /route
clear'`; otherwise `'routed <main|this agent> to <phase>: effort <level|
unchanged>, model <resolved id|unchanged>'`, 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: [<line>] }` where `<line>` states the effective outcome:
`'model-router: effort → <l> for this loop from the next request on; the
effort-<l> skill text was not loaded.'`, or when main has a sticky
`userMain`: `'model-router: effort-<l> recorded, but a sticky /route
<phase> 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-<l>` (or a preload, unsupported: treated the same):
level parse; `turnMain = { phase: e.skill, route: { effort }, source:
'slash' }`; return `{ text: <line> + '\n' + e.text }` (prepend, never
replace: args ride in the text) where `<line>` is `'Effort shifted to <l>
by model-router for this turn.'` or, with a sticky `userMain`, `'Effort
<l> recorded; the sticky /route <phase> 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 <type>: <e.model ?? '-'> → <result.model>`. 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 =
'<model without claude->/<effort>'`, `$.ui.status(statusLine(st))`. Verbose →
log before (`step <i> <loop>: <from> → <to>`) and after (`answered by
<usage.model>`). `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` = `'<source> <phase>'` 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 id|session>/<effort>`
(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> = (e: Args<E>)`, and
AC2 type-checks the test file): `$.command.run({ command: 'route', args:
'show', origin: { kind: 'composer' }, presentation: <a valid value from the
types> })`, `$.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`, `<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.