SLOPSHOPPER

autoroute

Picks the model and effort per prompt, routes subagents to cheap models, and gates Fable and extra-usage spend.

newcommandtoaststatuspromptmodel
★ 1v0.1.0MITupdated 2026-10-15daydeda/daymods/mods/autoroute
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · autoroute
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /route ⎿ autoroute: Routing: on (classifier: rules + Haiku) ⎿ autoroute: Last route: Sonnet · medium · normal — "fix" (rules) ⎿ autoroute: Plan: unknown → Fable blocked (Max plan only) ⎿ autoroute: Extra usage: off → spend guard idle (caps at Sonnet past 90%) ⎿ autoroute: Override one prompt with !haiku, !sonnet, !opus, !fable or !max at its start. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-34d399?style=flat-square"></a> <img alt="Claude Code 2.1.300+" src="https://img.shields.io/badge/Claude%20Code-2.1.300%2B-818cf8?style=flat-square"> <img alt="mods" src="https://img.shields.io/badge/mods-4-f472b6?style=flat-square">

A collection of small mods for Claude Code. Each mod is its own plugin, so you install only the ones you want.

Mods use Claude Code's hooks-module API, which is early access and may change between releases. They were built and tested on Claude Code 2.1.300.

🧩 Mods

ModWhat it does
autoroutePicks the model and effort for each prompt, sends subagents to cheaper models, and keeps Fable and extra-usage spend in check.
prompt-upgradeCatches vague prompts, asks one quick multiple-choice question, rewrites the prompt with a working method, and lets you review it before it runs.
box-toolsA filled ✦ Tools button at the right end of the prompt footer. Click it for a small box where you switch the model, the effort, and each of your daymods on or off.
pace-statuslineA 4-line status line with limit bars, reset times and a pace prediction (⚠ runs out today 4:19pm) so you know before you hit the wall. Lives in its own repo.

More mods will be added here as they are ready.

📦 Install

Add the marketplace once, then install any mod from it.

In a Claude Code session:

/plugin install autoroute --marketplace daydeda/daymods

Or from a shell:

claude plugin marketplace add daydeda/daymods
claude plugin install autoroute@daymods
claude plugin install prompt-upgrade@daymods
claude plugin install box-tools@daymods
claude plugin install pace-statusline@daymods

Answer y when asked to add the marketplace and pick a scope. The mod is active right away. Mods installed at the user scope load in every later session.

To remove one: claude plugin uninstall autoroute.

🧭 autoroute

Routes each prompt to a suitable model and effort, so small jobs stop burning Opus tokens and big jobs get a plan.

How it works

  1. Pick a route per prompt. When you send a message, quick keyword and length rules choose a tier. If the rules cannot tell, a low-effort Haiku call decides. The choice is locked for the whole reply, so the prompt cache stays warm instead of being rebuilt on every step.
TierTypical promptsModelEffort
triviala question, typo, rename, quick lookupHaikulow
normala feature or fix in 1-3 filesSonnetmedium
hardtricky debugging, design, refactorsOpushigh
largemigrations, rewrites, architectureOpushigh, plus a delegation plan

Short follow-ups like "continue" or "yes" keep the previous route.

  1. Delegate large work. For the large tier, autoroute tells the model to work as a pipeline: Explore subagents map the code, a plan is written to a file, then a Sonnet subagent implements it. Other tiers are told to work directly.
  1. Route subagents. Explore agents run on Haiku at low effort, Plan agents on Opus, and any other subagent with no model set runs on Sonnet.
flowchart LR
  A([your prompt]) --> B{rules}
  B -->|clear| D[tier]
  B -->|unclear| C[Haiku classifier] --> D
  D --> E{plan and spend gate}
  E -->|Fable, not Max| F[Opus]
  E -->|limit near, extra usage on| G[Sonnet cap]
  E -->|ok| H[chosen model]

Two spend rules

These apply to every request, whatever chose its model, and stay on even when routing is turned off.

  • Fable needs a Max plan. autoroute reads your plan from the local Claude login record (~/.claude.json). Anything other than a Max plan, or a plan it cannot read, is downgraded from Fable to Opus. This covers the router, !fable, Fable picked in /model, and Fable subagents.
  • Spend guard. If extra usage is switched on for your account and any limit window (5-hour or weekly) passes the threshold (90% by default), routes are capped at Sonnet and a warning shows in the status line.

The plan check reads a local file the user can edit. It prevents accidental charges; it is not a security boundary.

Controls

  • Status line: by default autoroute adds no row of its own. It writes the current route to ~/.claude/autoroute.state, and a status line script can show it inline. pace-statusline does: Opus ▶ high ⇢ Haiku·low ▶ ⌂ daymods, plus a ⚠ note for downgrades or spend warnings. The ⇢ part is hidden once the running model matches the route. Set display to line to get autoroute's own row instead, for example autoroute: Haiku · low · "typo" (rules).
  • Compact status line: with pace-statusline, STATUSLINE_COMPACT=1 folds ctx, 5-hour, weekly and session stats into one row with short bars. The reset countdown shows only past 50% used.
  • Override one prompt: start a message with !haiku, !sonnet, !opus, !fable or !max. !auto routes normally.
  • /route on|off|status: turn routing on or off, or show the last route, your plan, and the spend guard state.
  • Options (in /config):
  • classifier: rules+haiku (default) or rules only. Rules-only makes no model call.
  • display: statusline (default) writes the route for your status line to show; line shows autoroute's own row.
  • spendGuardPercent: threshold for the spend guard, default 90.

Notes and limits

  • The keyword rules are a starting point and will misjudge some prompts. Use ! overrides, or /route status to see why a route was chosen.
  • The model IDs are set in mods/autoroute/hooks/route.ts. If your Claude Code build uses different IDs, change them there.
  • The Haiku classifier sends the start of your prompt (up to 4000 characters) to Haiku through your own Claude session. Nothing goes to any other service.

✨ prompt-upgrade

Turns a vague prompt like "make a todo app" into a clear one before Claude starts, and lets you check it first.

How it works

  1. Spot vague prompts. Quick local rules let most prompts straight through: follow-ups like "yes" or "continue", slash commands, questions, and anything that names a file, function, error or URL. Short, broad prompts ("make it better", "build a website") count as vague. Borderline ones get a quick Haiku check, which can also decide the prompt is already clear. "fix it" right after an error is left alone, because the conversation already says what "it" is.
  2. Ask a few quick questions. Haiku drafts as few as will do: one when one thing is open, up to three for a very vague prompt. Each is about a specific open decision in your prompt (where it runs, what it saves, how big), and one may check its reading: "Do you mean something like this: …?". They appear one at a time in a side panel (see below). For each, pick a numbered choice, pick Type my own answer… (a wrapped text box appears under it), or Skip. Press Esc to send your prompt untouched.
  3. Rewrite. Sonnet rewrites the prompt with your answer. It keeps your intent and adds the goal, where in the project, constraints and how to check it's done. It doesn't invent requirements: anything still open is left for Claude to pick a sensible default and say which.
  4. Review. The panel shows the rewritten prompt and its method:
  5. Run it: sends the rewrite.
  6. Edit it myself: puts the rewrite in the prompt box for you to change and send.
  7. Use my original: sends what you typed, unchanged.
  8. Cancel: sends nothing, and your text goes back in the prompt box.
  9. Ask for a change…: type what to change ("use Go", "smaller scope") in the text box under it, and it rewrites again.

The panel opens beside the transcript (or above the prompt). A step bar at the top shows where you are: ✓ Your prompt → ● Questions 2/3 → ○ Rewrite → ○ Review. Keys:

  • Questions: 1-3 pick a choice, the next number types your own answer, s skips the question, x skips the rest and rewrites now, b goes back to the previous question (your earlier answer is marked ✓).
  • Review: 1-4 as above, 5 asks for a change, b goes back to the questions with your answers kept, then rewrites again.
  • Always: Esc sends your prompt as typed. Back, Skip the rest and Esc sit together in a bar at the bottom of the panel.

Each question is its own card (QUESTION 2 OF 3, one dot per question). The panel uses Claude's orange for what is current and green for what is done, and opens at about 60% of the terminal width once you have run /refine once (100 columns before that).

Typing your own answer. Claude Code's text field is one line and cuts long text off, so the panel draws its own wrapped text box instead. Press the typing key (or click the box), type, and press Enter. It supports typing, Backspace and Ctrl+U to clear; there is no cursor movement. It is not available in the VS Code and mobile layouts, which only get the numbered choices and Skip.

The panel needs a terminal about 110 columns wide; on a narrower one it falls back to Claude Code's question dialog, with the rewrite shown inside the question. Set ui to dialog to always use the dialog.

Methods

Each rewrite picks one working method and writes its step into the prompt:

MethodIdeaWhat the prompt asks for
Directprompt engineeringA clear spec and nothing else, for small well-defined changes.
Context firstcontext engineeringRead specific code, docs or data before deciding.
Plan firstplan, then buildA short plan and a pause for your OK before editing, for big or open-ended work.
Verify looploop engineeringA named check (tests, build, lint) and iterating until it passes, stopping after 3 failed tries.
Make it a ruleharness engineeringDo the task now, then suggest making it permanent as a CLAUDE.md rule, hook, skill or /loop.

Controls

  • /refine <prompt>: upgrade one prompt on demand, whatever the mode.
  • /refine auto|always|off: when it steps in. auto (default) handles vague prompts only, always every new task you type, off only /refine <prompt>.
  • /refine status: the mode, and why the last prompt was or wasn't upgraded.
  • Options (in /config): mode, detector (rules+haiku or rules alone), ui (panel or dialog), and rewriteModel (sonnet by default, or haiku or opus).

Notes and limits

  • It only steps in on prompts you type while Claude is idle. Prompts with images attached, prompts typed mid-turn, and prompts from other plugins go through untouched.
  • A leading !opus-style autoroute override is kept in front of the rewritten prompt.
  • An upgrade costs one small Haiku call, plus one Sonnet call if it rewrites, and adds a few seconds before the turn starts. The calls go through your own Claude session and send the prompt, the names of the top-level files in your project and the last few messages. Nothing goes to any other service.

🧰 box-tools

A filled orange ✦ Tools pill sits at the right end of the prompt footer. Click it and a small box opens beside the transcript:

✦ Tools                                       Opus · high
╭─────────────────────────────────────────────────────────╮
│ ◆  Model                                            1-5 │
│ 1: ○ Default 2: ○ Haiku 3: ○ Sonnet 4: ● Opus 5: ○ Fable │
╰─────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────╮
│ ⚡ Effort                                    q w e r t │
│ q: ○ low  w: ○ medium  e: ● high  r: ○ xhigh  t: ○ max  │
╰─────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────╮
│ ❖  Mods                                         a s d … │
│ a: ● on   autoroute                                     │
╰─────────────────────────────────────────────────────────╯

Controls

  • Open: click ✦ Tools, or run /tools. Clicking the button again closes the box.
  • Pick: click a choice, press its hotkey, or Tab to it and press Enter. Hotkeys: 1-5 for models, q w e r t for effort, a s d… for mods.
  • Close: Esc, a click anywhere outside the box (the transcript or the prompt), x, or the button again.

What each control does

  • Model sets the model row of /config, the same as picking it there. It becomes your default for new sessions too.
  • Effort runs the built-in /effort <level>, as if you typed it. Claude Code saves the level for the current model, so it carries over to new sessions.
  • Mods lists the daymods you have installed (Box Tools itself is left out). A toggle runs claude plugin enable or claude plugin disable, then /reload-plugins, so the change applies right away and stays on or off in later sessions.

Notes and limits

  • When autoroute is on, it picks the model and effort for each prompt and overrides what you pick here. The box says so. Turn autoroute off in the same box to keep your pick.
  • A change made while Claude is working waits until the turn ends, the same as a typed command.
  • Mods loaded with --plugin-dir aren't installed, so they don't appear in the list.
  • Options (in /config): button (show or hide). With hide, the footer is left alone and /tools opens the box.

🛠 Development

Each mod lives in mods/<name>/ with the standard layout:

mods/<name>/
├── .claude-plugin/plugin.json
├── hooks/hooks.json
├── hooks/register.ts
└── tests/

Check and test a mod with:

claude plugin validate mods/autoroute
claude plugin test mods/autoroute

Try one without installing it: claude --plugin-dir mods/autoroute.

📄 License

MIT

Source 2 files
hooks/register.ts 186 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4  CLASSIFIER_SYSTEM, DELEGATE_CONTEXT, DIRECT_CONTEXT, MODELS, TIER_ROUTE, UNKNOWN_PLAN,
5  classifyByRules, gate, gateAlias, isFable, parseOverride, parsePlan, parseTier, shortName,
6} from './route'
7import type { Effort, Plan, Route } from './route'
8
9const ENABLED_KEY = 'enabled'
10
11let useHaiku = true
12let guardPercent = 90
13let ownLine = false
14
15let plan: Plan = UNKNOWN_PLAN
16let isEnabled = true
17let route: Route | undefined
18let guardNote: string | undefined
19const agentEffort = new Map<string, Effort>()
20
21async function readPlan($: EngineInterface): Promise<Plan> {
22  try {
23    const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (await $.env.get('HOME'))
24    const text = await $.fs.read(`${dir}/.claude.json`)
25    return typeof text === 'string' ? parsePlan(text) : UNKNOWN_PLAN
26  } catch {
27    return UNKNOWN_PLAN
28  }
29}
30
31// With extra usage on, a window near its limit means further work may be billed.
32async function isSpendCapped($: EngineInterface): Promise<boolean> {
33  if (!plan.hasExtraUsage) return false
34  try {
35    const { rateLimits } = await $.session.usage()
36    const hot = rateLimits.find(w => w.percentUsed >= guardPercent)
37    guardNote = hot ? `${hot.kind} at ${hot.percentUsed}% with extra usage on` : undefined
38    return hot !== undefined
39  } catch {
40    return false
41  }
42}
43
44// The statusline reads this file to show the route inline: one `key=value` per line.
45async function statePath($: EngineInterface): Promise<string> {
46  const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
47  return `${dir}/autoroute.state`
48}
49
50let writing: Promise<void> = Promise.resolve()
51
52async function writeState($: EngineInterface, notes: readonly string[]) {
53  const clean = (v: string) => v.replace(/[\r\n]+/g, ' ')
54  const lines = route && isEnabled
55    ? [`model=${shortName(route.model)}`, `effort=${route.effort}`, `tier=${route.tier}`, `reason=${clean(route.reason)}`, `source=${route.source}`]
56    : ['off=1']
57  const warn = [...notes, ...(guardNote ? [guardNote] : [])]
58  if (warn.length > 0) lines.push(`warn=${clean(warn.join('; '))}`)
59  const text = `${lines.join('\n')}\n`
60  // Chained so a slow earlier write can never land after a newer one.
61  writing = writing.then(async () => $.fs.write(await statePath($), text)).catch(() => undefined)
62  await writing
63}
64
65function showStatus($: EngineInterface, notes: readonly string[] = []) {
66  void writeState($, notes)
67  if (!ownLine) return
68  const parts = [
69    route && isEnabled ? `${shortName(route.model)} · ${route.effort} · ${route.reason} (${route.source})` : 'routing off',
70    ...notes,
71    ...(guardNote ? [`⚠ ${guardNote}`] : []),
72  ]
73  $.ui.status(`autoroute: ${parts.join(' | ')}`)
74}
75
76async function classify($: EngineInterface, text: string): Promise<Route | undefined> {
77  const verdict = classifyByRules(text)
78  if (verdict.tier === 'keep') return route
79  if (verdict.tier !== 'unsure') return { tier: verdict.tier, ...TIER_ROUTE[verdict.tier], reason: verdict.reason, source: 'rules' }
80  if (useHaiku) {
81    const r = await $.model
82      .complete({ model: 'haiku', system: CLASSIFIER_SYSTEM, prompt: text.slice(0, 4000), effort: 'low', maxTokens: 8, timeoutMs: 8000 })
83      .catch(() => undefined)
84    const tier = r?.isAnswered ? parseTier(r.text) : undefined
85    if (tier) return { tier, ...TIER_ROUTE[tier], reason: tier, source: 'haiku' }
86  }
87  return route ?? { tier: 'normal', ...TIER_ROUTE.normal, reason: 'default', source: 'fallback' }
88}
89
90export const register: Register = (on, options) => {
91  useHaiku = options.classifier !== 'rules'
92  guardPercent = typeof options.spendGuardPercent === 'number' ? options.spendGuardPercent : 90
93  ownLine = options.display === 'line'
94
95  on('session.start', async ($, e, next) => {
96    plan = await readPlan($)
97    isEnabled = (await $.store.get(ENABLED_KEY)) !== false
98    await $.command.register({
99      name: 'route',
100      description: 'autoroute: turn auto model routing on or off, or show its status',
101      argumentHint: '[on|off|status]',
102    })
103    showStatus($)
104    return next(e)
105  })
106
107  on('command.run', { command: 'route' }, async ($, e) => {
108    const arg = e.args.trim().toLowerCase()
109    if (arg === 'on' || arg === 'off') {
110      isEnabled = arg === 'on'
111      await $.store.set(ENABLED_KEY, isEnabled)
112      showStatus($)
113      return { text: `autoroute: routing ${arg}. The Fable and spend rules stay on either way.` }
114    }
115    if (arg !== '' && arg !== 'status') return { text: 'Usage: /route [on|off|status]' }
116
117    plan = await readPlan($)
118    const isCapped = await isSpendCapped($)
119    return {
120      text: [
121        `Routing: ${isEnabled ? 'on' : 'off'} (classifier: ${useHaiku ? 'rules + Haiku' : 'rules only'})`,
122        `Last route: ${route ? `${shortName(route.model)} · ${route.effort} · ${route.tier} — ${route.reason} (${route.source})` : 'none yet'}`,
123        `Plan: ${plan.type ?? 'unknown'} → Fable ${plan.isMax ? 'allowed' : 'blocked (Max plan only)'}`,
124        `Extra usage: ${plan.hasExtraUsage ? 'on' : 'off'} → spend guard ${isCapped ? `ACTIVE (${guardNote}), capping at Sonnet` : `idle (caps at Sonnet past ${guardPercent}%)`}`,
125        'Override one prompt with !haiku, !sonnet, !opus, !fable or !max at its start.',
126      ].join('\n'),
127    }
128  })
129
130  on('prompt.submit', async ($, e, next) => {
131    if (e.command || e.isMeta) return next(e)
132
133    const override = parseOverride(e.text)
134    const text = override ? override.text : e.text
135    const notes: string[] = []
136
137    if (override && override.route !== 'auto') {
138      route = { ...override.route, reason: 'override', source: 'you' }
139    } else if (isEnabled) {
140      route = await classify($, text)
141    }
142
143    if (route && !plan.isMax && route.model === MODELS.fable) {
144      notes.push(`Fable is Max-only (${plan.type ?? 'plan unknown'}) → Opus`)
145      $.ui.toast('autoroute: Fable needs a Max plan; using Opus instead.')
146    }
147    await isSpendCapped($)
148    showStatus($, notes)
149
150    const context = !route || !isEnabled ? [] : route.tier === 'large' ? [DELEGATE_CONTEXT] : route.tier === 'top' ? [] : [DIRECT_CONTEXT(route.tier)]
151    return next({ ...e, text, context: [...(e.context ?? []), ...context] })
152  })
153
154  on('turn.step', async function* ($, e, next) {
155    const isMain = e.agentId === undefined
156    const wanted = isMain && isEnabled && route ? route : undefined
157    const model = wanted?.model ?? e.model
158    const effort = wanted?.effort ?? (e.agentId ? agentEffort.get(e.agentId) : undefined) ?? e.effort
159    const gated = gate(model, effort, plan, await isSpendCapped($))
160
161    if (gated.notes.length > 0 && isMain) showStatus($, gated.notes)
162    if (gated.model === e.model && gated.effort === e.effort) return yield* next(e)
163    return yield* next({ ...e, model: gated.model, ...(gated.effort === undefined ? {} : { effort: gated.effort }) })
164  })
165
166  on('agent.spawn', async ($, e, next) => {
167    if (e.fork || e.resume) return next(e)
168
169    let model = e.model
170    let effort: Effort | undefined
171    if (isEnabled) {
172      if (e.subagentType === 'Explore') [model, effort] = ['haiku', 'low']
173      else if (e.subagentType === 'Plan') [model, effort] = ['opus', 'high']
174      else if (model === undefined) [model, effort] = ['sonnet', 'medium']
175    }
176    model = gateAlias(model, e.parentModel, plan, await isSpendCapped($))
177
178    const started = await next(model === e.model ? e : { ...e, model })
179    if (effort && 'agentId' in started && started.agentId) agentEffort.set(started.agentId, effort)
180    return started
181  }).catch(($, e, next) =>
182    // Fail closed on Fable: without a plan reading, a Fable subagent runs on Opus.
183    next.called ? next(e) : next(isFable(e.model) ? { ...e, model: 'opus' } : e),
184  )
185}
186
hooks/route.ts 125 lines
1// Pure routing logic: no `$`, so it can be read and tested on its own.
2
3export type Tier = 'trivial' | 'normal' | 'hard' | 'large' | 'top'
4export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
5export type Route = { tier: Tier; model: string; effort: Effort; reason: string; source: string }
6export type Plan = { type: string | undefined; hasExtraUsage: boolean; isMax: boolean }
7
8export const MODELS = {
9  haiku: 'claude-haiku-5-5',
10  sonnet: 'claude-sonnet-5-5',
11  opus: 'claude-opus-5-5',
12  fable: 'claude-fable-5-1',
13} as const
14
15export const TIER_ROUTE: Record<Exclude<Tier, 'top'>, { model: string; effort: Effort }> = {
16  trivial: { model: MODELS.haiku, effort: 'low' },
17  normal: { model: MODELS.sonnet, effort: 'medium' },
18  hard: { model: MODELS.opus, effort: 'high' },
19  large: { model: MODELS.opus, effort: 'high' },
20}
21
22const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
23
24const LARGE = /\b(architect(ure)?|re-?design|rewrite|migrat(e|ion)|from scratch|end[- ]to[- ]end|across (the )?(whole |entire )?(codebase|repo|project)|(entire|whole) (codebase|repo|app|project|system)|multi-?(service|module|package)|monorepo|build (an?|the) (new )?(app|system|platform|service|backend|frontend))\b/i
25const HARD = /\b(debug|race condition|deadlock|leak|performance|optimi[sz]e|security|vulnerab\w*|concurren\w*|refactor\w*|investigate|root cause|flaky|intermittent|design|trade-?offs?|algorithm|plan|why (does|is|do|are))\b/i
26const TRIVIAL = /\b(typo|rename|spelling|what is|what's|what does|explain|how do i|where is|list|show me|format|lint|commit|bump|quick|small|one-?liner)\b/i
27const NORMAL = /\b(add|implement|fix|update|change|create|write|tests?|remove|delete|support|handle|wire|hook up)\b/i
28const FOLLOW_UP = /^(go|yes|y|no|n|ok(ay)?|sure|continue|proceed|do it|next|keep going|lgtm|sounds good|approved?|thanks?|thank you)\b[\s.!]*$/i
29
30export type Verdict = { tier: Exclude<Tier, 'top'> | 'keep' | 'unsure'; reason: string }
31
32export function classifyByRules(text: string): Verdict {
33  const t = text.trim()
34  if (FOLLOW_UP.test(t)) return { tier: 'keep', reason: 'follow-up' }
35  if (t.length > 2000) return { tier: 'large', reason: 'long brief' }
36  const large = t.match(LARGE)
37  if (large) return { tier: 'large', reason: `"${large[0]}"` }
38  const hard = t.match(HARD)
39  if (hard) return { tier: 'hard', reason: `"${hard[0]}"` }
40  const trivial = t.match(TRIVIAL)
41  if (trivial && t.length < 200) return { tier: 'trivial', reason: `"${trivial[0]}"` }
42  const normal = t.match(NORMAL)
43  if (normal && t.length < 800) return { tier: 'normal', reason: `"${normal[0]}"` }
44  return { tier: 'unsure', reason: 'no rule matched' }
45}
46
47export const CLASSIFIER_SYSTEM = `You route coding-assistant requests to a model tier. Reply with exactly one word:
48trivial - a question, explanation, typo, rename or tiny edit
49normal - a feature or bug fix touching about 1-3 files
50hard - tricky debugging, design decisions, or changes across many files
51large - architectural or multi-part work that needs planning before implementation`
52
53export function parseTier(reply: string): Exclude<Tier, 'top'> | undefined {
54  const word = reply.trim().toLowerCase().match(/trivial|normal|hard|large/)
55  return word ? (word[0] as Exclude<Tier, 'top'>) : undefined
56}
57
58export type Override = { route: Omit<Route, 'reason' | 'source'> | 'auto'; text: string }
59
60// `!haiku fix this` forces a route for that one prompt; `!auto` routes as usual.
61export function parseOverride(text: string): Override | undefined {
62  const m = text.match(/^!(haiku|sonnet|opus|fable|max|auto)\b\s*/i)
63  if (!m) return undefined
64  const rest = text.slice(m[0].length)
65  switch ((m[1] ?? '').toLowerCase()) {
66    case 'haiku': return { route: { tier: 'trivial', model: MODELS.haiku, effort: 'low' }, text: rest }
67    case 'sonnet': return { route: { tier: 'normal', model: MODELS.sonnet, effort: 'medium' }, text: rest }
68    case 'opus': return { route: { tier: 'hard', model: MODELS.opus, effort: 'high' }, text: rest }
69    case 'fable': return { route: { tier: 'top', model: MODELS.fable, effort: 'high' }, text: rest }
70    case 'max': return { route: { tier: 'top', model: MODELS.fable, effort: 'max' }, text: rest }
71    default: return { route: 'auto', text: rest }
72  }
73}
74
75export const isFable = (model: string | undefined) => model !== undefined && /fable/i.test(model)
76const isAboveSonnet = (model: string | undefined) => model !== undefined && /opus|fable/i.test(model)
77
78export function parsePlan(json: string): Plan {
79  const account = (JSON.parse(json) as { oauthAccount?: Record<string, unknown> }).oauthAccount ?? {}
80  const type = typeof account.organizationType === 'string' ? account.organizationType : undefined
81  return { type, hasExtraUsage: account.hasExtraUsageEnabled === true, isMax: type === 'claude_max' }
82}
83
84// Fails closed: an unknown plan is treated as not Max.
85export const UNKNOWN_PLAN: Plan = { type: undefined, hasExtraUsage: false, isMax: false }
86
87export type Gated = { model: string; effort?: Effort | number; notes: string[] }
88
89// The two money rules, applied to every request whatever chose its model.
90export function gate(model: string, effort: Effort | number | undefined, plan: Plan, isSpendCapped: boolean): Gated {
91  const notes: string[] = []
92  let m = model
93  let eff = effort
94  if (isFable(m) && !plan.isMax) {
95    m = MODELS.opus
96    notes.push(`Fable is Max-only (${plan.type ?? 'plan unknown'}) → Opus`)
97  }
98  if (isSpendCapped && isAboveSonnet(m)) {
99    m = MODELS.sonnet
100    if (typeof eff === 'string' && EFFORTS.indexOf(eff) > EFFORTS.indexOf('high')) eff = 'high'
101    notes.push('spend guard → Sonnet')
102  }
103  return { model: m, effort: eff, notes }
104}
105
106// The same rules for a subagent's `model` parameter, which takes aliases.
107export function gateAlias(model: string | undefined, parentModel: string, plan: Plan, isSpendCapped: boolean) {
108  const effective = model === undefined || model === 'inherit' ? parentModel : model
109  if (isFable(effective) && !plan.isMax) return isSpendCapped ? 'sonnet' : 'opus'
110  if (isSpendCapped && isAboveSonnet(effective)) return 'sonnet'
111  return model
112}
113
114export const shortName = (model: string) =>
115  (model.match(/haiku|sonnet|opus|fable/i)?.[0] ?? model).replace(/^./, c => c.toUpperCase())
116
117export const DELEGATE_CONTEXT = `[autoroute] This task was classified LARGE. Work it as a pipeline, skipping any step that does not pay for itself:
1181. Map the relevant code with Explore subagents (they run on Haiku) instead of reading broadly yourself.
1192. Write a concrete implementation plan to a file (or spawn a Plan subagent, which runs on Opus).
1203. Spawn a general-purpose subagent with model "sonnet", pointed at that plan file, to implement; keep delicate parts for yourself.
1214. Review the result against the plan before reporting.`
122
123export const DIRECT_CONTEXT = (tier: Tier) =>
124  `[autoroute] This task was classified ${tier.toUpperCase()}: handle it directly, without spawning subagents unless the user asks.`
125