chore(tasks): model-router w1a contract + plan r3
This commit is contained in:
@@ -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.
|
||||||
Reference in New Issue
Block a user