Files
claude/.claude/tasks/contracts/2026-10-08-model-router-w1a-1533.md
T

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

  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 ':\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
  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]

HARDENING ROUND (security gate 2026-10-08, user go "oui durcis") — criteria 7-11

  1. /route is user-only: command.run answers { text: 'route: user-only command' } without acting when e.origin.kind !== 'composer'; a test proves it (origin { kind: 'plugin', name: 'x' } or the kit's non-composer origin → text contains user-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
  2. An in-agent route call or skill table entry never changes that agent's MODEL: the Loop type has no model axis and no explicitModel flag, turn.step on an agent loop rewrites effort only, 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 carrying agentId: 'a1' with phase: 'judge' followed by a turn.step for a1 leaves model as given and sets effort to xhigh. 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
  3. Config hardening: a prompt rule pattern longer than 200 chars is dropped (logged); re.test runs 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 carries additionalProperties: false; the Skill hook checks typeof 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
  4. Visible fail-open: every .catch logs once per session per hook ($.ui.log('model-router: <hook> failed (<kind>): routing skipped for this event'), a warned: Set<string> in the state) before passing through or answering; the three silent config drops (non-object top level, wrong-typed table, non-array prompt) 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
  5. Post-next bookkeeping (loop tracking and logs after await next(...) in agent.spawn and the Skill hook) runs inside its own try/catch so a logging failure can never make the .catch re-run next. 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