11 KiB
PLAN r3 — model-router wave 4-B: prompt classifier (2026-10-11)
r1 → r2 after simplicity CONCERNS(4), robustness FATAL(6) (BLOCKER: the
classify await is unbounded on the critical path), correctness CONCERNS(4).
r2 → r3 after the confirmation (robustness CONCERNS(4)). Precedence
r3 > r2 > r1. Contract .claude/tasks/contracts/2026-10-11-model-router- w4b-classifier-1858.md. Base: feature/model-router-w4 at 86845fc.
Corrected facts: default keyword rules never run mid-turn and only write
plan/reflect (they never lower); mainEffort ranks sticky > turn > run >
floor > engine and mainModel ranks turn above floor; runMain holds a
typed best-tier skill's route across the run's turns; $ calls do not
count against the hook budget (an unbounded classify await freezes the
prompt), while $.clock.sleep does; ClassifyOptions has model only;
claude -p prompts arrive with origin sdk; the breaker is fed by
classic.StopFailure only; warnOnce fires once per session; config
switches (enabled, verbose, mainModelSwitch) are Config keys read
through pickSwitch with session toggles; confirmPhase (T3) is
confirm-only, so a dialog on a classified phase would decide nothing.
Decisions
- D1 no first-use dialog on classified phases (Later and Keep would apply
the same route). Visibility = the status line (
route: classifier <phase>) and the W4-A transition line (needs/route verbose on). - D2
classifyis a Config key (defaulttrue), read from the machine override throughpickSwitch(invalid → default + one log), with a session toggle/route classify on|offnext toswitch/verbose, printed on therouter:line of/route show. Persistent off ="classify": falsein~/.claude/model-router.json. routing.json is NOT touched; no new writer. - D3 the classifier fills only an EMPTY route: it runs only when no route
is in force on main (
mainRoute(st) === null && st.turnFloor === null: no sticky/route, no turn route from a rule/skill/tool, no run slot from a typed skill, no floor), evaluated afterrouteFromPrompt. Real coverage, stated in the docs: a typed best-tier skill (feat, bugfix, ship-feature…) holds its run slot across turns, so nothing is classified until/route clear,/route off, a user/modelor a typed non-best skill; a non-best skill's row is turn-scoped, so its follow-up prompts ARE classified (accepted: those rows are low/medium and a complex follow-up may rightly rise). Never mid-turn (e.turnId !== undefined→ skip, as the rules). Never for a non-composer origin (so never under-p). Never for a sub-agent prompt (prompt.submit is main only; a test proves an agent's prompt is not classified). - D4 a classified route carries the EFFORT ONLY:
turnMain = { phase, route: { effort: cfg.phases[phase].effort }, source: 'classifier' }. No tier, so the main model never moves because of a guess (no cold context re-reads on opus/sonnet sessions, no cost surprise). It may lower the effort (coding = medium, bookkeeping = low); turn-scoped. An explicit user/effortbeats it:st.engineBase= the engine effort of the first main step of the session (kept across /clear); atturn.step, whene.effort !== st.engineBase(the user moved the builtin effort) aclassifier-sourced turn route is ignored for that step (the engine effort applies) — "un choix explicite bat la phase déduite" (BDR-115 amendment b). - D5 bounded call:
label = await Promise.race([classify, $.clock.sleep( CLASSIFY_MS = 3000, { signal: next.signal }).then(() => TIMEOUT)])inside try/catch: in the catch,next.signal.abortedis tested FIRST (a user interrupt: no count, no write, return); a TIMEOUT counts as a failure (D7) and gives no route; after the await every guard of D8 is RE-CHECKED (st.off, the switch, no route in force, not paused) before the write, andnext.signal.abortedskips it (a late result never lands in a later turn). The losing classify promise is left to settle (the kit offers no cancel); stated. - D6 labels are descriptive and mapped:
design→ plan,analysis→ reflect,coding→ implement,bookkeeping→ apply,chat→ none (lookup withhasKey; a mapped phase without an effort → no route). The call passes nomodeloption (the engine's small fast model; no alias from a hook, LRN-203). The sample = the whole trimmed text when ≤CLASSIFY_MAX = 2000code points, else the firstCLASSIFY_HEAD = 500+ lastCLASSIFY_TAIL = 1500code points (cut by code points, never a lone surrogate). A pasted tail can steer the label for one turn (effort only, no file write): accepted and documented. - D7 failures: a rejected call (classify's own
abortedcause excepted) or a TIMEOUT incrementsst.classifyFails; a settled answer resets it; atCLASSIFY_FAILS = 3the classifier pauses untilclock.now() + cooldownMinutes * 60000(st.classifyUntil) with one toast "classifier paused min (3 failures)" and the status markerclassifier: paused; both fields are inresetSession's kept set and cleared by/route reload(like the breaker); the expiry check runs BEFORE the route-in-force guard (the marker never stays stale); an unknown label neither warns nor counts; the classifier is skipped whilecfg.models.haiku(a stated proxy for the engine's small model) is inst.down, and when the main loop itself runs on haiku (the effort is stripped there anyway). - D8 guards summary (in order):
st.off→cfg.classify(+ toggle) → composer →e.turnId === undefined→ not a slash → trimmed length ≥ 12 → no rule matched this prompt → no route in force → not paused, model not down → classify.
Steps
- S1 config:
classifykey (DEFAULT true, pickSwitch), toggle,/route classify on|off, showrouter:line, argumentHint. - S2
classifyPrompt($, st, e, next)in the prompt.submit hook afterrouteFromPromptand the typed marker;LABELS/LABEL_PHASEtables; the sample helper; the race; the failure counter and pause; the effort-only route write with sourceclassifier;Sourcegains'classifier';slotWordmaps it toclassifier; the status line already printsroute: <source> <phase>. - S3 docs: README (classifier paragraph: what, one small-model call
per typed prompt when no route is in force, effort only,
/route classify offor"classify": falseper machine, where to see it: status line,/route verbose on); the/routesubcommand lists (README ×2, USAGE) gainclassify on|off; the machine keys lists (README, USAGE) gainclassify; USAGE "Niveau d'effort" one sentence; CHANGELOG Unreleased Added;lib/effort-shift.md≤ 2 lines (the plain conversation routes by content now). - S4 kit tests (a bottom
model.classifymock;$.clockmock for the race), one per clause: called once with the head+tail sample and the five labels, no model option; each label → its phase effort only (no tier on the route) andchat/undefined→ none; never resolving → no route after 3 s, prompt proceeds; rejection ×3 → pause + toast + status marker, then no call during the cooldown, a success resets the counter;aborted→ no count, no warn; small model down → not called; not called: short text, slash, sdk origin, mid-turn (turnId), rule matched (keyword and ultrathink), sticky/route, run slot held, floor carried,classifyoff (config, toggle), mod off, an agent's prompt; turn-scoped (gone at turn.complete); effort lowered to low for bookkeeping; the main model unchanged on a sonnet session fordesign;/route showprintsclassify: on; status lineroute: classifier reflect; W4-A line "(reflect: classifier)"; invalid config value; 3 timeouts → pause; a dispatch abort mid-race → no count, no write; a/route <phase>landing during the await → the classifier result dropped; pause expiry at cooldown-1 ms (still paused) and +1 ms (resumes);/clearkeeps the pause,/route reloadclears it; a user/effortchange (engine effort ≠ baseline) → the classified effort ignored at the step; a 2000-code-point text sent whole, a longer one head+tail with no lone surrogate;constructorlabel → none; a phase without effort → none; main on haiku → not called; a non-best skill's follow-up prompt classified; a plain prompt after a finished best-tier run not classified until/route clear. - Edge cases: a prompt that is only a URL or code (the classifier
decides; no special-casing); every classified prompt waits one small-
model round trip (typically under a second, 3 s at most: stated in the
README and measured in the AC9 live check); a
[1m]session (no effect: effort only). - Disposition: honors BDR-115 (no alias from a hook, no model write, no file write), BDR-116 (writers unchanged; no new dialog), LRN-203, LRN-210, LRN-212 (live mod: the first plain prompt after the feater saves is classified).
- Docs note for the user: a misclassification lowers one turn's effort
only;
/route <phase>orultrathinkoverrides it at once.
Challenge ledger (r1 → r2)
- simp 1, corr 3/7, rob 6 (dialog decides nothing / misstates) → D1 dropped.
- simp 2, corr 11, rob 11 (flag plumbing, new writer) → D2 Config key.
- simp 3/4, corr 1/2/12, rob 3/4 (mid-turn, run slot, floor) → D3.
- rob 5 (model flips per turn) → D4 effort only.
- rob 1 BLOCKER, corr 9 (unbounded await, late write) → D5.
- simp 5, corr 4 (alias from a hook) → D6 no model option.
- corr 8, simp 8 (label words) → D6 descriptive labels mapped.
- rob 7 (head-only sample) → D6 head + tail.
- rob 2, corr 5 (failure handling, warn spent) → D7.
- corr 6, rob 9 (
-p) → D3 never. - corr 10 (sub-agent clause) → S4 test.
- simp 6, rob 10 (visibility) → status line + verbose note.
- simp 7 (doc lists) → S3 lists named.
Confirmation ledger (r2 → r3)
- conf 1 (timeouts uncounted) → timeouts count toward the pause.
- conf 2 (abort lands in the counted branch) →
next.signal.abortedtested first in the catch. - conf 3 (classifier overrides a user
/effort) →engineBaseguard at the step: an explicit builtin effort beats a classified route. - conf 4 (run coverage) → D3 states the real coverage; docs name it.
- conf 5 (guards not re-checked after the await) → re-checked.
- conf 6 (pause bookkeeping) → ×60000, kept across /clear, cleared on reload, expiry checked first, boundary tests.
- conf 7 (sample, labels) → whole text ≤ 2000 cp, code-point cuts, hasKey, no-effort phase skipped.
- conf 8 (down check) → haiku id as a stated proxy; skip on a haiku main.
- conf 9 (pasted tail) → accepted, documented.
- conf 10 (latency) → stated in the README and measured live.