feat(agents): pin explicit model on every agent and workflow call

Agents and workflows previously carried no model field, so every
dispatch inherited the session model — including fable, which is
banned for all plugin agents and workflows by owner decree. Every
dispatch now pins opus or sonnet explicitly.

- opus (low-volume judgment gates whose misses silently poison
  downstream work): architect, bencher, debugger, fieldtester,
  grounding-check, plan-recon, quality-reviewer, spec-skeptic,
  tdd-author
- sonnet (mechanical scope, in-loop or fanned out): docwriter,
  glossary-extractor, implementer, spec-reviewer, synthetic-user,
  tester
- workflows: all 13 agent() call sites pin a model — sonnet
  everywhere except the quality-reviewer gate in implement-loop,
  the loop's last correctness check (spec-reviewer only gates
  task-text correspondence; real-bug finding is the documented
  opus strength)
- docs/agent-template.md: model is now a mandatory frontmatter
  field, with the assignment rule and the fable ban recorded
This commit is contained in:
2026-07-02 12:14:36 +02:00
parent ea54be6535
commit 6bfec9655c
18 changed files with 72 additions and 13 deletions
+1
View File
@@ -2,6 +2,7 @@
name: implementer
description: Carries out a tightly scoped implementation task. Reads the task extract handed by the controller, implements, builds, tests, reports a structured status with the diff. NOT for architecture decisions, NOT for self-curated scope; this agent executes a plan that has already been made.
tools: Read, Edit, Write, Bash, Glob, Grep
model: sonnet
---
# implementer
+1
View File
@@ -2,6 +2,7 @@
name: quality-reviewer
description: Read-only code-quality reviewer for project diffs. Reports Strengths, Issues by severity (Important / Minor / Nit), and a Recommendation. Runs after spec-reviewer is green; spec-compliance is NOT this agent's concern. Does NOT propose fixes.
tools: Read, Glob, Grep, Bash
model: opus
---
# quality-reviewer
+1
View File
@@ -2,6 +2,7 @@
name: spec-reviewer
description: Read-only spec-compliance reviewer. Compares a recent diff against the task text from a plan under docs/plans handed by the controller. Reports missing requirements and unrequested extras. Does NOT review code quality (that is quality-reviewer's job) and does NOT propose fixes (the implementer fixes; the orchestrator coordinates).
tools: Read, Glob, Grep, Bash
model: sonnet
---
# spec-reviewer
+1
View File
@@ -2,6 +2,7 @@
name: tester
description: Writes new fixtures and E2E tests after a cycle or feature ships. Verifies a feature works from build through to observable output. Each test protects a named property; tests check observable behaviour, not implementation internals.
tools: Read, Edit, Write, Bash, Glob, Grep
model: sonnet
---
# tester
+7 -2
View File
@@ -39,6 +39,11 @@
// stay unstaged for the orchestrator. The script has no shell of its own —
// the edit and the verify both run inside agent() calls.
//
// Model policy: every agent() call pins an explicit model — never the
// session-model inherit (which could route to an unintended tier, e.g. fable —
// banned for all plugin agents and workflows). Both phases here are mechanical
// (compiler-enumerated propagation, build/suite re-run), so both run sonnet.
//
// Carrier — Workflow `args`:
// edit_description: what type/signature change to make
// def_site: the definition site (file + symbol) it originates from
@@ -120,7 +125,7 @@ const edit = await agent(
'Then run the project build and report: is the build clean, does any hole need a decision (with detail + bounce_to), ' +
'how many sites were touched, and — checked against `git status --porcelain` / `git diff HEAD`, NOT from memory — ' +
'whether the working tree actually changed (applied_changes).',
{ agentType: 'implementer', label: 'edit+propagate', phase: 'Edit + propagate', schema: EDIT_SCHEMA },
{ agentType: 'implementer', model: 'sonnet', label: 'edit+propagate', phase: 'Edit + propagate', schema: EDIT_SCHEMA },
)
if (!edit) return { status: 'BLOCKED', reason: 'edit agent did not return' }
if (edit.hole_needs_decision) {
@@ -162,7 +167,7 @@ const verify = await agent(
'every test passes and NO test was added, removed, or weakened versus the pre-edit baseline (compare `git diff ' +
'HEAD` for test-file changes). Also report working_tree_dirty — whether `git status --porcelain` / `git diff HEAD` ' +
'shows ANY change at all (a clean tree means no edit landed). Do NOT edit anything; do NOT commit.',
{ label: 'verify(build+suite)', phase: 'Verify', schema: VERIFY_SCHEMA },
{ model: 'sonnet', label: 'verify(build+suite)', phase: 'Verify', schema: VERIFY_SCHEMA },
)
// ---- Phase 3 — verdict --------------------------------------------------
+18 -11
View File
@@ -42,6 +42,11 @@
// • The script has no filesystem or shell access of its own — every
// working-tree mutation (code, stats.json, BLOCKED.md) happens inside
// an agent() call.
// • Model policy: every agent() call pins an explicit model — never the
// session-model inherit (which could route to an unintended tier, e.g.
// fable — banned for all plugin agents and workflows). Mechanical and
// in-loop steps run sonnet; the quality gate is the one deliberate opus
// call (last correctness check before a task is marked DONE).
//
// Carrier — passed as the Workflow `args` object by the orchestrator:
// mode: 'standard' | 'mini'
@@ -245,7 +250,7 @@ const pre = await agent(
'Run `git status --porcelain`, `git rev-parse HEAD`, and `git rev-parse --abbrev-ref HEAD`. ' +
'Report whether the tree is clean (no porcelain output), the HEAD sha, the branch, and any dirty paths. ' +
'Do NOT modify anything, do NOT commit. The HEAD sha is an informational anchor only — never a reset target.',
{ label: 'preflight', phase: 'Preflight', schema: PREFLIGHT_SCHEMA },
{ model: 'sonnet', label: 'preflight', phase: 'Preflight', schema: PREFLIGHT_SCHEMA },
)
if (!pre) return { status: 'BLOCKED', iter_id, reason: 'infra: preflight agent did not return' }
if (!pre.clean) {
@@ -276,7 +281,7 @@ if (mode === 'mini') {
`${STANDING}\n\nRead the plan at ${plan_path}. Extract ` +
(task_range ? `tasks ${task_range[0]}..${task_range[1]} (inclusive, 1-based)` : 'every task') +
', each as its VERBATIM block with the task id and a one-line title. Do not summarise or reorder. Do not edit anything.',
{ label: 'plan-extract', phase: 'Per-task loop', schema: TASKS_SCHEMA },
{ model: 'sonnet', label: 'plan-extract', phase: 'Per-task loop', schema: TASKS_SCHEMA },
)
if (!extracted || !extracted.tasks || extracted.tasks.length === 0) {
return { status: 'NEEDS_CONTEXT', iter_id, reason: `no tasks extracted from ${plan_path}` }
@@ -294,7 +299,7 @@ async function runTask(task) {
'inner-loop discipline — add the failing test inline even if the task text forgot to script it). ' +
'Build and test must be green before you report DONE. Leave all edits UNSTAGED; never commit.\n\n' +
`mode: ${mode}\niter_id: ${iter_id}\n\nTASK ${task.id}${task.title}\n${task.text}`,
{ agentType: 'implementer', label: `impl:${task.id}`, phase: 'Per-task loop', schema: IMPL_SCHEMA },
{ agentType: 'implementer', model: 'sonnet', label: `impl:${task.id}`, phase: 'Per-task loop', schema: IMPL_SCHEMA },
)
if (!impl) return { id: task.id, title: task.title, outcome: 'BLOCKED', reason: 'implementer agent did not return' }
if (impl.status === 'BLOCKED') return { id: task.id, title: task.title, outcome: 'BLOCKED', reason: impl.reason || 'worker-blocked' }
@@ -313,7 +318,7 @@ async function runTask(task) {
`${STANDING}\n\nSpec-compliance review of task ${task.id}. Compare \`git diff HEAD\` against the task ` +
'text ONLY — list missing requirements and unrequested extras. No quality opinions, no fix proposals. ' +
`Focus on the files this task claims to touch.\n\nTASK ${task.id}${task.title}\n${task.text}`,
{ agentType: 'spec-reviewer', label: `spec:${task.id}`, phase: 'Per-task loop', schema: SPEC_SCHEMA },
{ agentType: 'spec-reviewer', model: 'sonnet', label: `spec:${task.id}`, phase: 'Per-task loop', schema: SPEC_SCHEMA },
)
if (spec && spec.status === 'compliant') break
if (spec && spec.status === 'unclear') return { id: task.id, title: task.title, outcome: 'BLOCKED', reason: 'spec-ambiguous', detail: spec.ambiguity }
@@ -323,7 +328,7 @@ async function runTask(task) {
`${STANDING}\n\nRepair task ${task.id} to satisfy the spec-compliance review. Address EACH finding; ` +
'do not widen scope. Keep build + tests green; leave edits unstaged.\n\n' +
`TASK ${task.id}${task.title}\n${task.text}\n\nFINDINGS:\n- ${(spec?.findings || ['(none reported)']).join('\n- ')}`,
{ agentType: 'implementer', label: `impl-fix-spec:${task.id}`, phase: 'Per-task loop', schema: IMPL_SCHEMA },
{ agentType: 'implementer', model: 'sonnet', label: `impl-fix-spec:${task.id}`, phase: 'Per-task loop', schema: IMPL_SCHEMA },
)
if (specFix && specFix.applied_changes === true) wroteAnyFile = true
}
@@ -346,7 +351,9 @@ async function runTask(task) {
'Nits never gate. Also report `diff_fingerprint` (REQUIRED): the sha256 hex of the FULL `git diff HEAD` ' +
'(`git diff HEAD | sha256sum` — the whole working-tree diff, NOT narrowed to this task\'s footprint). ' +
'The loop compares it across rounds to detect a no-op/cyclic repair.',
{ agentType: 'quality-reviewer', label: `qual:${task.id}`, phase: 'Per-task loop', schema: QUAL_SCHEMA },
// opus by design: the loop's last correctness gate (spec-reviewer only checks
// task-text correspondence) — real-bug finding is the documented opus strength.
{ agentType: 'quality-reviewer', model: 'opus', label: `qual:${task.id}`, phase: 'Per-task loop', schema: QUAL_SCHEMA },
)
if (qual && qual.status === 'approved') break
// No-op / cyclic-repair backstop: a changes_requested verdict over a diff-state already reviewed-and-flagged
@@ -380,7 +387,7 @@ async function runTask(task) {
'A finding whose only remedy is to diverge from the ratified plan must be held or escalated, never chased ' +
'into a deviation the next review flags.\n\n' +
`TASK ${task.id}${task.title}\n${task.text}\n\nISSUES:\n- ` + (qual?.issues || ['(none reported)']).join('\n- '),
{ agentType: 'implementer', label: `impl-fix-qual:${task.id}`, phase: 'Per-task loop', schema: IMPL_SCHEMA },
{ agentType: 'implementer', model: 'sonnet', label: `impl-fix-qual:${task.id}`, phase: 'Per-task loop', schema: IMPL_SCHEMA },
)
if (fix && fix.applied_changes === true) wroteAnyFile = true
if (fix && (fix.status === 'BLOCKED' || fix.status === 'NEEDS_CONTEXT')) {
@@ -464,7 +471,7 @@ if (mode === 'mini' && outcome === 'DONE') {
'fix). Then run the full suite and confirm it is green (no regression). Also report working_tree_dirty — ' +
'whether `git status --porcelain` (NOT `git diff HEAD`, which misses new untracked files) shows any change at ' +
'all (a clean tree means no fix landed). Do NOT edit anything; do NOT commit.',
{ label: 'mini-verify', phase: 'Verify', schema: MINI_VERIFY_SCHEMA },
{ model: 'sonnet', label: 'mini-verify', phase: 'Verify', schema: MINI_VERIFY_SCHEMA },
)
if (!miniVerify || !miniVerify.red_test_now_green || !miniVerify.suite_green || !miniVerify.working_tree_dirty) {
outcome = 'BLOCKED'
@@ -491,7 +498,7 @@ if (mode === 'mini' && outcome === 'DONE') {
'`git status --porcelain` and report files_touched = its line count (every changed, added, or untracked ' +
'path). Use `git status --porcelain`, NOT `git diff`, so brand-new untracked files are counted. Do NOT edit, ' +
'do NOT commit.',
{ label: 'tree-check', phase: 'Verify', schema: TREE_SCHEMA },
{ model: 'sonnet', label: 'tree-check', phase: 'Verify', schema: TREE_SCHEMA },
)
if (!treeState || treeState.files_touched === 0) {
outcome = 'BLOCKED'
@@ -519,7 +526,7 @@ if (mode === 'standard' && outcome === 'DONE') {
'E2E fixtures for them in the project\'s canonical fixture form; each test\'s doc comment names the property ' +
'it protects. Run the project\'s test command — all green. Leave edits unstaged; never commit.\n\n' +
`iter_id: ${iter_id}\nshipped: ${completed.map((c) => `#${c.id} ${c.title}`).join('; ')}`,
{ agentType: 'tester', label: 'e2e', phase: 'E2E coverage', schema: E2E_SCHEMA },
{ agentType: 'tester', model: 'sonnet', label: 'e2e', phase: 'E2E coverage', schema: E2E_SCHEMA },
)
}
@@ -575,7 +582,7 @@ const fin = await agent(
'and `## Files touched` (from `git status --porcelain`).\n') +
'Report the stats path and whether you wrote BLOCKED.md. Do NOT commit anything.\n\nAGGREGATE:\n' +
JSON.stringify(aggregate, null, 2),
{ label: 'finalize', phase: 'Report', schema: FINALIZE_SCHEMA },
{ model: 'sonnet', label: 'finalize', phase: 'Report', schema: FINALIZE_SCHEMA },
)
// The no-op verdict was already taken before finalize (the tree-check / mini-verify