Files
model-router/hooks/router.ts
T
adminandClaude Opus 5.5 92bc4dc348 model-router v0.4.4
Claude Code plugin: routes subagents by how well-defined the brief is,
verifies dev work on a second model in the agent's own clone, and
estimates savings against Opus 5.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdqPWvuDpbpRvDEdURoAwo
2026-11-06 23:46:30 +00:00

212 lines
8.9 KiB
TypeScript

// Pure routing logic: no `$`, so every rule here is unit-tested.
export type Tier = 'haiku' | 'sonnet' | 'opus' | 'fable'
export type Role = 'dev' | 'review' | 'test'
export const TIERS: readonly Tier[] = ['haiku', 'sonnet', 'opus', 'fable']
export const rank = (t: Tier) => TIERS.indexOf(t)
export const asTier = (s: string | undefined): Tier | undefined => {
if (!s) return undefined
const lower = s.toLowerCase()
return TIERS.find(t => lower.includes(t))
}
export const parsePatterns = (csv: string): string[] =>
csv
.split(',')
.map(s => s.trim().toLowerCase())
.filter(s => s.length > 0)
/** The first safety pattern the text mentions, or undefined. */
export const safetyHit = (text: string, patterns: readonly string[]): string | undefined => {
const lower = text.toLowerCase()
return patterns.find(p => lower.includes(p))
}
// ---------- the brief ----------
export type Brief = {
clone?: string
ticket?: string
role?: Role
baseBranch?: string
/** Several clone paths and no `Clone:` line: the role cannot be told. */
isAmbiguous?: boolean
}
// A ticket is LETTERS-digits plus any extra parts (OPT-11-P4); the role is the last part.
const TICKET = '[A-Za-z]+-\\d+(?:-[A-Za-z0-9]+)*?'
const CLONE_PATH = new RegExp(`(\\S*?\\/clones\\/(${TICKET})-(dev|review|test))\\/?`)
const CLONE_PATHS = new RegExp(CLONE_PATH.source, 'g')
// The explicit line every brief carries: `Clone: <path>` with an optional `(Role: dev)`.
const CLONE_LINE = /^[ \t>*-]*Clone:[ \t]*`?([^\s`]+?)\/?`?[ \t]*(?:\(Role:[ \t]*(dev|review|test)\))?[ \t]*$/im
const BASE =
/\b(?:base(?:\s+branch)?|target(?:\s+branch)?|merge\s+into|PR\s+(?:against|into))\s*[:=]?\s*`?((?:release|main|master|develop)[\w./-]*)/i
const trimPath = (p: string) => p.replace(/^[`'"(]+/, '').replace(/[`'")]+$/, '').replace(/\/+$/, '')
/**
* Reads the clone path, ticket, role and base branch out of a brief.
*
* The `Clone:` line decides when present: its `(Role: ...)`, else the role in
* its path. Without one, a brief that names one clone is read from that path;
* one that names several is ambiguous and gets no role.
*/
export const parseBrief = (prompt: string): Brief => {
const b = prompt.match(BASE)
// a sentence's full stop or a trailing slash is no part of the branch
const baseBranch = b?.[1]?.replace(/[./-]+$/, '')
const line = prompt.match(CLONE_LINE)
if (line?.[1]) {
const clone = trimPath(line[1])
const fromPath = clone.match(CLONE_PATH)
const role = (line[2] ?? fromPath?.[3]) as Role | undefined
return { clone, ticket: fromPath?.[2], role, baseBranch }
}
const paths = [...new Set([...prompt.matchAll(CLONE_PATHS)].map(m => trimPath(m[1] ?? '')))]
if (paths.length > 1) return { baseBranch, isAmbiguous: true }
const only = paths[0]?.match(CLONE_PATH)
if (!only) return { baseBranch }
return { clone: trimPath(only[1] ?? ''), ticket: only[2], role: only[3] as Role, baseBranch }
}
// ---------- routing ----------
const READ_ONLY_TYPES = /^(explore|scout|runner|claude-code-guide|statusline-setup)$|:(scout|runner)$/i
// Light work, only for tasks outside the dev/review/test roles. No bare
// "docs" or "run tests": both appear in nearly every brief.
const LIGHT = [
/\b(find|locate|grep|where is|which (files|modules)|summari[sz]e|read and report)\b/,
/\b(fix (a |the )?typo|update (the )?(readme|changelog)|add docstrings?)\b/,
/\b(run (the )?(unit )?tests? and report|report (the )?test results)\b/,
]
// Opus writes only when the approach itself is still open. Domain words
// (control loop, asyncio, RS485) are everywhere in this codebase and say nothing.
const HEAVY = [
/\b(design|architect|redesign|decide (how|whether)|propose an approach|trade-?offs?)\b/,
/\b(root cause|unclear why|no idea why|investigate why|figure out why|intermittent|flaky)\b/,
]
export type Route = { tier: Tier; reason: string }
/** A clear signal from the text, or undefined (ask the classifier). */
export const heuristic = (subagentType: string | undefined, prompt: string, role?: Role): Route | undefined => {
if (!role && subagentType && READ_ONLY_TYPES.test(subagentType))
return { tier: 'haiku', reason: `read-only agent type ${subagentType}` }
const lower = prompt.toLowerCase()
const heavy = HEAVY.find(r => r.test(lower))
if (heavy) return { tier: 'opus', reason: 'approach still open' }
if (role) return undefined // dev briefs never take the light path
const light = LIGHT.find(r => r.test(lower))
if (light && prompt.length < 1500) return { tier: 'haiku', reason: 'light task' }
return undefined
}
export type Decision =
| { kind: 'fixed'; tier: Tier; reason: string; opusRequested?: boolean }
| { kind: 'classify'; opusRequested?: boolean }
/**
* Who decides the writer's model.
* - review and test agents: a named model is kept; unnamed, they run on Opus
* (they are the gates, and a test agent's work has no verifier behind it).
* - dev agents: always routed; a named Opus is only logged.
* - anything else: routed, unless respectExplicit and a model is named.
*/
export const decide = (args: {
role?: Role
named?: Tier
subagentType?: string
prompt: string
respectExplicit: boolean
isAmbiguous?: boolean
}): Decision => {
const { role, named, subagentType, prompt, respectExplicit, isAmbiguous } = args
// Could be a test or review agent: never risk a weaker model on it.
if (isAmbiguous)
return { kind: 'fixed', tier: named && named !== 'haiku' && named !== 'sonnet' ? named : 'opus', reason: 'several clone paths and no Clone: line' }
if (role === 'review' || role === 'test')
return named
? { kind: 'fixed', tier: named, reason: `named model kept for ${role} agent` }
: { kind: 'fixed', tier: 'opus', reason: `${role} agent default` }
if (!role && respectExplicit && named) return { kind: 'fixed', tier: named, reason: 'model chosen by orchestrator' }
const opusRequested = role === 'dev' && (named === 'opus' || named === 'fable')
const h = heuristic(subagentType, prompt, role)
return h ? { kind: 'fixed', ...h, opusRequested } : { kind: 'classify', opusRequested }
}
// ---------- verification ----------
export type Skip = 'test-agent' | 'review-agent' | 'no-clone' | 'opus-unverified' | 'agent-ended'
/** Why an agent run is not verified, or undefined when it is. */
export const skipReason = (rec: { role?: Role; clone?: string; tier: Tier }, ended: string): Skip | undefined => {
if (ended !== 'answer') return 'agent-ended'
if (rec.role === 'test') return 'test-agent'
if (rec.role === 'review') return 'review-agent'
if (!rec.clone) return 'no-clone'
if (rank(rec.tier) >= rank('opus')) return 'opus-unverified'
return undefined
}
/** The verifier never runs on the writer's model; below Opus it is Opus. */
export const verifierFor = (writer: Tier): Tier => (rank(writer) >= rank('opus') ? 'sonnet' : 'opus')
/** The model a rewrite runs on: one tier up, never the same model. */
export const rewriteTier = (writer: Tier, allowFable: boolean): Tier | undefined => {
if (writer === 'haiku') return 'sonnet'
if (writer === 'sonnet') return 'opus'
if (writer === 'opus' && allowFable) return 'fable'
return undefined
}
export type FailStep = { kind: 'resume' } | { kind: 'new-agent'; tier: Tier } | { kind: 'hand-back' }
/**
* After the n-th failed verdict in a task's chain: the first resumes the same
* agent, the second starts a new agent one model up in the same clone, and
* past maxFails (or with no model above) it goes back to the manager.
*/
export const onFail = (fails: number, writer: Tier, maxFails: number, allowFable: boolean): FailStep => {
if (fails > maxFails) return { kind: 'hand-back' }
if (fails === 1) return { kind: 'resume' }
const up = rewriteTier(writer, allowFable)
return up ? { kind: 'new-agent', tier: up } : { kind: 'hand-back' }
}
export type Verdict = { pass: boolean; issues: string[]; brief: string }
/** Reads the verifier's reply; anything unreadable counts as a failure. */
export const parseVerdict = (text: string): Verdict => {
const match = text.match(/\{[\s\S]*\}/)
if (match) {
try {
const v = JSON.parse(match[0]) as { verdict?: string; issues?: unknown; rewrite_brief?: unknown }
const issues = Array.isArray(v.issues) ? v.issues.map(String) : []
const pass = String(v.verdict ?? '').toUpperCase() === 'PASS'
return { pass, issues, brief: typeof v.rewrite_brief === 'string' ? v.rewrite_brief : '' }
} catch {
// fall through
}
}
return { pass: false, issues: ['Verifier reply was not valid JSON: ' + text.slice(0, 300)], brief: '' }
}
export const REWRITE_MARK = /^\s*\[router-rewrite (T\d+)\]/
export const clip = (s: string, n: number) =>
s.length <= n ? s : s.slice(0, n) + `\n... [${s.length - n} more characters cut]`
/** A usable test time limit in seconds: anything not a positive number is the default. */
export function validTimeout(raw: unknown): number {
const n = Number(raw)
return Number.isFinite(n) && n >= 1 ? Math.round(n) : 1800
}