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
+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