// 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: ` 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 } /** A verifier reply: a verdict that could be read, or the reason it could not. */ export type Reading = ({ isReadable: true } & Verdict) | { isReadable: false; preview: string } /** A JSON object with a PASS or FAIL verdict, or undefined. */ const asVerdict = (json: string): Verdict | undefined => { let v: unknown try { v = JSON.parse(json) } catch { return undefined } if (!v || typeof v !== 'object' || Array.isArray(v)) return undefined const o = v as { verdict?: unknown; issues?: unknown; rewrite_brief?: unknown } const word = typeof o.verdict === 'string' ? o.verdict.trim().toUpperCase() : '' if (word !== 'PASS' && word !== 'FAIL') return undefined return { pass: word === 'PASS', issues: Array.isArray(o.issues) ? o.issues.map(String) : [], brief: typeof o.rewrite_brief === 'string' ? o.rewrite_brief : '', } } /** * Every balanced {...} in the text, in order of where it starts. Braces inside * JSON strings do not count, so `"a } b"` stays inside its object. */ export const balancedObjects = (text: string): string[] => { const found: string[] = [] for (let start = text.indexOf('{'); start !== -1; start = text.indexOf('{', start + 1)) { let depth = 0 let isInString = false for (let i = start; i < text.length; i++) { const ch = text[i] if (isInString) { if (ch === '\\') i++ else if (ch === '"') isInString = false continue } if (ch === '"') isInString = true else if (ch === '{') depth++ else if (ch === '}' && --depth === 0) { found.push(text.slice(start, i + 1)) break } } } return found } const FENCE = /```[ \t]*(?:json)?[ \t]*\r?\n([\s\S]*?)```/gi /** * Reads the verifier's reply, tolerating prose before and after: a fenced * ```json block first, then the first balanced {...} that parses and has a * PASS or FAIL verdict. Anything else is unreadable, which is not a FAIL. */ export const parseVerdict = (text: string): Reading => { for (const m of text.matchAll(FENCE)) { const body = m[1] ?? '' const v = asVerdict(body.trim()) ?? balancedObjects(body).map(asVerdict).find(Boolean) if (v) return { isReadable: true, ...v } } for (const candidate of balancedObjects(text)) { const v = asVerdict(candidate) if (v) return { isReadable: true, ...v } } return { isReadable: false, preview: text.slice(0, 300) } } 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 } // ---------- the hand-back line (TOOL-12) ---------- const LABEL: Record = { haiku: 'Haiku', sonnet: 'Sonnet', opus: 'Opus', fable: 'Fable' } /** * The model a run actually used, from the model id its end reports; the * routing choice only when the run reported none. */ export const ranTier = (reportedModel: string | undefined, routed: Tier): { tier: Tier; isMismatch: boolean } => { const seen = asTier(reportedModel) return seen ? { tier: seen, isMismatch: seen !== routed } : { tier: routed, isMismatch: false } } /** * The one line every dev hand-back gets at once, so silence never stands for * both "still verifying" and "will never verify". Undefined: no line (not a dev). */ export const handBackLine = (args: { taskId: string role?: Role ran: Tier routed: Tier skip?: Skip ended: string }): string | undefined => { const { taskId, role, ran, routed, skip, ended } = args if (role !== 'dev') return undefined const note = ran !== routed ? ` (routed to ${LABEL[routed]}, ran on ${LABEL[ran]})` : '' const head = `[model-router] ${taskId}` if (!skip) return `${head} verifying on ${LABEL[verifierFor(ran)]}...${note}` if (skip === 'opus-unverified') return `${head} no verification: dev ran on ${LABEL[ran]}. Go straight to review.${note}` if (skip === 'agent-ended') return `${head} no verification: the dev run ended without an answer (${ended}).${note}` if (skip === 'no-clone') return `${head} no verification: the brief names no clone path.${note}` return undefined }