6.4 KiB
PLAN — model-router wave 1-B1: user effort floor for the turn (dispatch-ready)
Contract: .claude/tasks/contracts/2026-10-08-model-router-floor-1835.md Code: mods/model-router/hooks/register.ts (read it in full first) and register.test.ts. API truth: the engine-laid declarations under mods/model-router/.claude-plugin/types/ (claude-code/index.d.ts, claude-code-tools/index.d.ts).
Why
Today the prompt rule (ultrathink → escalate) and a typed /effort-<l>
share ONE slot (turnMain) with the model's route calls and the
Skill(effort-*) bridge: last writer wins, and a later skill load resets
the slot to the session default. A user's explicit level is therefore lost
mid-turn. The user chose FLOOR semantics: their level is a minimum for the
whole main turn; derived routes may go above it, never below; it also
lifts a lower sticky /route.
Precedence after the change (main loop only)
- model axis (unchanged order, floor last):
userMain ?? turnMain ?? turnFloorroute'smodel, applied only withmainModelSwitchand the window guard. - effort axis:
base = (userMain ?? turnMain)?.route.effort ?? e.effort;effort = floored(base, turnFloor?.route.effort). floored(effort, floor): no floor →effort;effortis a Level whose LEVELS index ≥ the floor's →effort; otherwise (lower Level, a number, or undefined) →floor.- The haiku omission (
effort: undefinedwhen the model sent starts withclaude-haiku) still runs AFTER flooring. - Sub-agent steps (
e.agentIdset) never readturnFloor.
Changes in register.ts (names as in the current file)
State: addturnFloor: Routed | nullwith the commentuser-explicit level for this turn (prompt rule, typed /effort-<l>): a floor, main loop only; reword theturnMaincomment tomodel route tool, skill table row, Skill(effort-*) bridge; dropped at turn end.newState:turnFloor: null.- Helpers (new, small):
const rank = (l: Level): number => LEVELS.indexOf(l);function floored(effort: StepIn['effort'], floor: Level | undefined)per the rule above. A helperfloorLevel(st)returningst.turnFloor?.route.effortis allowed if it keeps call sites short. mainPlan: computebaseandeffort = floored(base, floorLevel(st));wanted = set?.route.model ?? st.turnFloor?.route.model; the rest (switch, window guard) unchanged.registerPrompt/prompt.submit: the non-queued branch writesst.turnFloor = routed(instead ofst.turnMain); the queued branch (e.turnId !== undefined && e.wait) keeps writingst.pendingPrompt.slashEffort: writest.turnFloor = { phase: skill, route: { effort: level }, source: 'slash' }; returned text line becomesEffort floor <level> set by model-router for this turn: nothing below it runs.followed by\n+ the original text (prepend, never replace).onSkillLoad(main branch):st.turnMain = nullunconditionally (the slot no longer holds prompt or slash routes), then the table row as now.turnFlooris never touched there.clearRoutes(/route clear): alsost.turnFloor = null.clearLoop(modelroute({clear}), main branch):turnMainonly, as now.endMainTurn:st.turnFloor = st.pendingPrompt; st.pendingPrompt = null; st.turnMain = null;then the existing resets.- Truthful answers (main branch only; agent branches unchanged):
effortBridge: keep the sticky sentence whenst.userMainis set; else when the floor ranks abovelevel:model-router: <skill> recorded, but the user's floor <f> for this turn keeps main at <f>; the <skill> skill text was not loaded.; else the current sentence.routedText: keep the sticky branch; else whenp.route.effortis set and the floor ranks above it, print the effort as<f> (user floor; asked <asked>).
- Display:
mainTextappends· floor <f> (<phase>)whenturnFlooris set (also aftermain: session defaults);statusLineappends· floor <f>.
Tests in register.test.ts (keep every existing test; adapt only what
the new slot changes, e.g. the ultrathink test now expects the floor on
the main: line). Add at least six tests whose names contain floor,
using the existing boot helper, full typed inputs and bottom hooks, and
asserting on the main: line or on what the bottom turn.step hook
receives (drain the stream with for await, then .result):
floor: ultrathink survives a model route— promptultrathink(composer,wait: false, noturnId) then route toolorchestrate→ a main step with engine efforthighreaches the bottom atmax.floor: a typed /effort-medium floors a lower route and allows a higher one—$.skill.prompt({ skill: 'effort-medium', text: 'x' })(no Skill call in flight) → route toolmechanical→ main step atmedium; then route toolescalate→ main step atmax.floor: survives a skill load— ultrathink, route toolorchestrate, then a non-effortSkillcall (bottomtool.callhook registered) → main step atmax, andmain:line no longer namesorchestrate.floor: lifts a lower sticky route, then ends with the turn—/route effort=lowthen ultrathink → main step atmax; fire a mainturn.complete→ next main step atlow.floor: main only— ultrathink, spawnExplore(bottomagent.spawnhook returning anagentId), then a step for thatagentId→ reaches the bottom atmedium, notmax.floor: /route clear removes it— ultrathink,/route clear→ main step keeps the engine effort. Optional seventh: the bridge context line names the floor when it wins.
Constraints
- Style: ≤ 25 logic lines per function, 80 chars per line, no
any, no module-level mutable state, doc comments state intent. - Do not touch: the agent axis, the spawn table, config loading, the hardening (caps, warnOnce, safely), the route tool schema.
- Verify (paste outputs):
claude plugin validate ., the contract's tsc command,claude plugin test ., then from the repo rootbash ~/.claude/lib/gates.sh run .claude/tasks/contracts/2026-10-08-model-router-floor-1835.md.
Disposition
- honors BDR-115 (one writer per axis, calling-loop writes, truthful answers) and the wave plan's routing rule (explicit user choice beats the derived phase); supersedes the 1-A contract's one-slot precedence for prompt and slash sources.
- LRN-206 (kit facts) applies to every new test.