Enforces the role-to-model map and synchronous review dispatch on agent.spawn in autonomous sessions, and logs every rewrite

A hooks-only mod on agent.spawn that applies the review tiers mechanically instead of by prose, and logs each decision.
| Rule | Condition | Rewrite |
|---|---|---|
| model | The spawn's subagentType has a configured model and the call's model is unset or does not carry it (sonnet is satisfied by claude-sonnet-5-5) | model set to the configured alias |
| sync | run_in_background is true on a main-loop spawn of quality-reviewer, quality-verifier or developer | background set to false |
Forks, teammates and Workflow-started agents are left alone: the engine ignores model on the first two and every change on the third. An unset model is never compliant, because the agent definition's own pin then decides, and developer.md pins opus.
enforce decides whether a rule rewrites or only logs: auto rewrites once the auto skill has expanded in the session or when the spawn's permission mode is auto, and logs a would rewrite line elsewhere; always and never do what they say.
userConfig: enforce, developer_model (sonnet), verifier_model (sonnet), reviewer_model (opus), sync_review_dispatch (true), ledger (true).
<session root>/tmp/spawn-policy-<sessionId>.jsonl, one row per spawn a rule fired on:
{"ts":"...","session":"...","type":"developer","description":"fix batch 2","modelIn":null,"modelOut":"sonnet","backgroundIn":true,"backgroundOut":false,"rules":["model:developer->sonnet","sync:developer"],"enforced":true,"parentAgentId":null,"resolvedModel":"claude-sonnet-5-5"}
Installed user-wide by update.sh and loaded in place from this folder; claude --plugin-dir ~/.claude/mods/spawn-policy loads it for one session on a machine without the install. The retro reads the ledger as a Spawn policy line and spawn_policy in fleet-metrics.py's JSON.
claude plugin validate mods/spawn-policy && claude plugin test mods/spawn-policy && mods/typecheck.sh mods/spawn-policyhooks/register.ts 58 lines1import type { Register } from 'claude-code'
2import { decide, readOptions } from './policy'
3
4let autonomous = false
5let sessionId = ''
6let ledgerPath = ''
7let rows: string[] = []
8
9export const register: Register = (on, options) => {
10 const opts = readOptions(options)
11
12 on('session.start', async ($, e, next) => {
13 sessionId = await $.session.id()
14 if (opts.ledger) {
15 ledgerPath = `${await $.session.root()}/tmp/spawn-policy-${sessionId}.jsonl`
16 if (await $.fs.exists(ledgerPath)) rows = (await $.fs.read(ledgerPath)).split('\n').filter((line) => line !== '')
17 }
18 return next(e)
19 })
20
21 on('skill.prompt', { skill: 'auto' }, async ($, e, next) => {
22 autonomous = true
23 return next(e)
24 })
25
26 on('agent.spawn', async ($, e, next) => {
27 const decision = decide(e, opts.policy)
28 if (decision.rules.length === 0) return next(e)
29 const enforced = opts.enforce === 'always' || (opts.enforce === 'auto' && (autonomous || e.permissionMode === 'auto'))
30 const modelOut = enforced && decision.model !== undefined ? decision.model : e.model
31 const backgroundOut = enforced && decision.background === false ? false : e.background
32 const verb = enforced ? 'rewrote' : 'would rewrite'
33 $.ui.log(`${verb} ${e.subagentType}: ${decision.rules.join(', ')}`)
34 const rewritten = { ...e, background: backgroundOut }
35 const result = await next(!enforced ? e : modelOut === undefined ? rewritten : { ...rewritten, model: modelOut })
36 if (opts.ledger) {
37 rows.push(
38 JSON.stringify({
39 ts: new Date().toISOString(),
40 session: sessionId,
41 type: e.subagentType,
42 description: e.description,
43 modelIn: e.model ?? null,
44 modelOut: modelOut ?? null,
45 backgroundIn: e.background,
46 backgroundOut,
47 rules: decision.rules,
48 enforced,
49 parentAgentId: e.parentAgentId ?? null,
50 resolvedModel: result.model,
51 }),
52 )
53 await $.fs.write(ledgerPath, rows.join('\n') + '\n')
54 }
55 return result
56 }).catch(($, e, next) => next(e))
57}
58hooks/policy.ts 76 lines1export type Spawn = {
2 readonly subagentType: string
3 readonly model?: string
4 readonly background: boolean
5 readonly fork: boolean
6 readonly isTeammate?: true
7 readonly workflow?: unknown
8 readonly parentAgentId?: string
9}
10
11export type Policy = {
12 readonly models: Readonly<Record<string, string>>
13 readonly syncTypes: ReadonlySet<string>
14 readonly syncReview: boolean
15}
16
17export type Decision = {
18 readonly model?: string
19 readonly background?: false
20 readonly rules: readonly string[]
21}
22
23// An alias (`sonnet`) is compliant with itself and with any id or bracketed form that carries it (`claude-sonnet-5-5`,
24// `opus[1m]`); an unset model is never compliant, because the agent definition's own pin then decides (developer.md pins opus).
25export function isCompliant(given: string | undefined, want: string): boolean {
26 return given !== undefined && (given === want || given.includes(want))
27}
28
29export function decide(e: Spawn, policy: Policy): Decision {
30 if (e.fork || e.isTeammate === true || e.workflow !== undefined) return { rules: [] }
31 const rules: string[] = []
32 let model: string | undefined
33 let background: false | undefined
34 const want = policy.models[e.subagentType]
35 if (want !== undefined && !isCompliant(e.model, want)) {
36 model = want
37 rules.push(`model:${e.subagentType}->${want}`)
38 }
39 if (policy.syncReview && e.background && e.parentAgentId === undefined && policy.syncTypes.has(e.subagentType)) {
40 background = false
41 rules.push(`sync:${e.subagentType}`)
42 }
43 return background === false ? { model, background, rules } : { model, rules }
44}
45
46export type Enforce = 'auto' | 'always' | 'never'
47
48export type Options = {
49 readonly enforce: Enforce
50 readonly policy: Policy
51 readonly ledger: boolean
52}
53
54const REVIEW_TYPES = ['quality-reviewer', 'quality-verifier', 'developer'] as const
55
56function str(value: unknown, fallback: string): string {
57 return typeof value === 'string' && value !== '' ? value : fallback
58}
59
60export function readOptions(raw: Readonly<Record<string, unknown>>): Options {
61 const enforce = raw.enforce
62 return {
63 enforce: enforce === 'always' || enforce === 'never' ? enforce : 'auto',
64 policy: {
65 models: {
66 developer: str(raw.developer_model, 'sonnet'),
67 'quality-verifier': str(raw.verifier_model, 'sonnet'),
68 'quality-reviewer': str(raw.reviewer_model, 'opus'),
69 },
70 syncTypes: new Set(REVIEW_TYPES),
71 syncReview: raw.sync_review_dispatch !== false,
72 },
73 ledger: raw.ledger !== false,
74 }
75}
76