// 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 } /** 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 }