12 KiB
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
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.jsonare ignored, never committed);claude plugin validatepasses 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- 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
claude plugin test mods/model-routerpasses; the suite covers: (a)Skill(effort-low)via$.tool.callis answered withoutnextin the Skill tool's output shape (success,commandName) and the route showslowon main; (b) theroutetool withphase: "orchestrate"setsmediumon main and/route showprints it; (c)/route cleardrops it; (d)/route bogusreturns an error text naming the phases; (e) aprompt.submittext holdingultrathinksetsescalateon main; (f)/route model=sonnetshowsclaude-sonnet-5-5, a full id passes through, a misspelt alias is refused; (g)agent.spawnofExplorewithout a model param reaches the bottom withmodel === 'claude-sonnet-5-5', and withmodel: '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- Hooks present, as
claude plugin validatelists 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 - Code style: no line over 80 chars, no
anytype, noimport();register.tsimports only fromclaude-codeand its own plugin files. CHECK: cd mods/model-router/hooks && ! grep -nE '.{81,}' register.ts register.test.ts && ! grep -nE ':\sany\b||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 - Judged by reading: the mod never writes a model alias into a request (every
modelit sets atagent.spawnorturn.steppasses throughresolveModel); the Agent tool'smodel/effortparams are never rewritten and an explicitmodelparam 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-agentroutecall and only whilee.modelstill 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 orroutecall lands on that agent's loop, never on main; main-loop model changes happen only whenmainModelSwitchis true; a.catchon every gating hook fails open (pass-through or an in-place answer) so a mod failure never blocks a call; all state lives in theregisterclosure and the defaults constant is never mutated; no function over 25 logic lines; the spike'svia/stepModel/agentsDefaultlevers and the tool'smodel/scopeparams 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 takesphase,effort,clearonly. [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]
HARDENING ROUND (security gate 2026-10-08, user go "oui durcis") — criteria 7-11
/routeis user-only:command.runanswers{ text: 'route: user-only command' }without acting whene.origin.kind !== 'composer'; a test proves it (origin{ kind: 'plugin', name: 'x' }or the kit's non-composer origin → text containsuser-only, state unchanged). CHECK: cd mods/model-router && grep -q "origin.kind" hooks/register.ts && grep -q "user-only" hooks/register.ts && grep -q "user-only" hooks/register.test.ts && echo ORIGIN-OK EXPECT: ORIGIN-OK EVIDENCE: pending- An in-agent
routecall or skill table entry never changes that agent's MODEL: theLooptype has nomodelaxis and noexplicitModelflag,turn.stepon an agent loop rewriteseffortonly, the route tool's description says "effort only; the model of a sub-agent is fixed at spawn". A test proves it: a route tool call carryingagentId: 'a1'withphase: 'judge'followed by aturn.stepfora1leavesmodelas given and setsefforttoxhigh. CHECK: cd mods/model-router && ! grep -qE "loop.model|explicitModel|spawnModel" hooks/register.ts && grep -q "fixed at spawn" hooks/register.ts && echo NO-AGENT-MODEL-OK EXPECT: NO-AGENT-MODEL-OK EVIDENCE: pending - Config hardening: a prompt rule pattern longer than 200 chars is dropped (logged);
re.testruns on at most the first 4096 chars of the prompt; phase keys must match^[a-z][a-z0-9_-]{0,31}$(others dropped, logged); the override file is refused above 65536 bytes (logged, defaults kept); the route tool schema carriesadditionalProperties: false; the Skill hook checkstypeof e.skill === 'string'. CHECK: cd mods/model-router && grep -q "additionalProperties: false" hooks/register.ts && grep -qE "[a-z][a-z0-9_-]{0,31}" hooks/register.ts && grep -qE "4096|4_096" hooks/register.ts && grep -qE "65536|65_536|64 * 1024" hooks/register.ts && grep -qE "200" hooks/register.ts && grep -q "typeof e.skill === 'string'" hooks/register.ts && echo CONFIG-HARDEN-OK EXPECT: CONFIG-HARDEN-OK EVIDENCE: pending - Visible fail-open: every
.catchlogs once per session per hook ($.ui.log('model-router: <hook> failed (<kind>): routing skipped for this event'), awarned: Set<string>in the state) before passing through or answering; the three silent config drops (non-object top level, wrong-typed table, non-arrayprompt) log a line. CHECK: cd mods/model-router && [ "$(grep -c '.catch(' hooks/register.ts)" -ge 12 ] && grep -q "warned" hooks/register.ts && grep -q "routing skipped" hooks/register.ts && echo CATCH-LOG-OK EXPECT: CATCH-LOG-OK EVIDENCE: pending - Post-
nextbookkeeping (loop tracking and logs afterawait next(...)inagent.spawnand the Skill hook) runs inside its own try/catch so a logging failure can never make the.catchre-runnext. Judged by reading, with criteria 1-6 still MET (validate, tsc, tests ≥ 13, style, AC6 minus the removed model axis).
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