Fix TOOL-12: one line on every dev hand-back, by the model that ran

An Opus-run dev got no verification and no message, so silence meant
both "still verifying" and "will never verify" (SAFETY-135 waited
overnight, SAFETY-126 8 h). Verdicts were also labelled with the
routing choice, not the model the run used.

- Every dev hand-back posts one line at once:
  "[model-router] Tn verifying on Opus..." (a verdict follows), or
  "[model-router] Tn no verification: dev ran on Opus. Go straight to
  review." A run that ends with an error or names no clone says so.
- Each round is decided and labelled by the model the run reported
  (its end usage), the routing choice only as fallback. A difference
  is shown as "(routed to X, ran on Y)", also on later rounds.
- 8 new tests; each new rule was also broken on purpose and caught,
  apart from one equivalent change.

Version 0.4.6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdqPWvuDpbpRvDEdURoAwo
This commit is contained in:
2026-11-11 14:23:51 +00:00
co-authored by Claude Opus 5.5
parent 05f9663129
commit eba7a16b32
5 changed files with 173 additions and 14 deletions
+22 -8
View File
@@ -19,6 +19,8 @@ import {
parseBrief,
parsePatterns,
parseVerdict,
handBackLine,
ranTier,
safetyHit,
skipReason,
validTimeout,
@@ -32,7 +34,10 @@ import {
/** One agent the router started, kept from spawn to its last verified round. */
type AgentRec = {
taskId: string
/** The model the last round actually ran on (the routing choice until a round reports one). */
tier: Tier
/** The model the router chose at spawn; tier can differ when the run used another. */
routed?: Tier
role?: Role
clone?: string
baseBranch?: string
@@ -124,7 +129,7 @@ The model-router mod picks each subagent's model and verifies dev work before it
- Every dev, review and test brief carries one line \`Clone: <path>\` (add \`(Role: dev|review|test)\` if the path does not end in the role). The router reads the role from that line only; a brief naming several clones without it runs on Opus, unverified.
- Dev agents are routed: Sonnet when the brief decides the approach (Expected / acceptance criteria), Opus when the design is still open. Naming a model on a dev brief has no effect. Give each dev brief its clone path and base branch.
- Review and test agents keep the model you name (Opus when you name none).
- When a dev agent below Opus finishes a round, the router verifies its clone's diff on Opus and posts a "[model-router]" verdict. Wait for that verdict before opening or advancing the PR.
- Every dev hand-back gets one "[model-router] Tn" line at once. "verifying on Opus...": wait for the verdict before advancing the PR. "no verification: dev ran on Opus. Go straight to review.": no verdict will come. Lines are decided by the model the dev actually ran on.
- On "[model-router] ... FAIL ... resume agent", resume that same agent with SendMessage, passing the issues. On "start a new agent ... [router-rewrite Tn]", stop the old agent and call Agent with exactly that prompt. On "handed back", stop and show the issues to the person. Do not fix rejected work yourself.`
// ---------- store helpers (top-level, as the engine requires for `$`) ----------
@@ -501,14 +506,21 @@ async function skipRound($: EngineInterface, agentId: string, rec: AgentRec, out
reason: outcome,
outcome,
})
if (rec.role === 'dev' && outcome === 'no-clone')
await tell($, `[model-router] ${rec.taskId} (agent ${agentId}): NOT VERIFIED - the brief names no clone path.`)
}
async function runRound($: EngineInterface, agentId: string, rec: AgentRec, ended: string, cfg: Settings) {
const skip = skipReason(rec, ended)
if (skip) await skipRound($, agentId, rec, skip)
else await verifyRound($, agentId, rec, cfg)
/**
* One finished run of a tracked agent. Decides on the model that actually ran,
* says at once what happens next for a dev, then verifies or logs the skip.
*/
async function runRound($: EngineInterface, agentId: string, rec: AgentRec, ended: string, reported: string | undefined, cfg: Settings) {
const routed = rec.routed ?? rec.tier
const ran = ranTier(reported, routed)
const round: AgentRec = { ...rec, routed, tier: ran.tier }
const skip = skipReason(round, ended)
const line = handBackLine({ taskId: rec.taskId, role: rec.role, ran: ran.tier, routed, skip, ended })
if (line) await tell($, line)
if (skip) await skipRound($, agentId, round, skip)
else await verifyRound($, agentId, round, cfg)
}
// ---------- the hooks ----------
@@ -612,6 +624,7 @@ export const register: Register = (on, options) => {
await putIn<AgentRec>($, S.agents, ran.agentId, {
taskId,
tier,
routed: tier,
role: brief.role,
clone: brief.clone,
baseBranch: brief.baseBranch,
@@ -656,7 +669,8 @@ export const register: Register = (on, options) => {
const rec = (await getMap<AgentRec>($, S.agents))[agentId]
if (!rec) return out
// Verification takes minutes: run it after this event has gone on.
$.clock.after(0, () => void runRound($, agentId, rec, e.reason, cfg))
const reported = e.usage?.model
$.clock.after(0, () => void runRound($, agentId, rec, e.reason, reported, cfg))
return out
})
+36
View File
@@ -262,3 +262,39 @@ export function validTimeout(raw: unknown): number {
return Number.isFinite(n) && n >= 1 ? Math.round(n) : 1800
}
// ---------- the hand-back line (TOOL-12) ----------
const LABEL: Record<Tier, string> = { 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
}