SLOPSHOPPER

spawn-policy

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

newpromptagents
★ 1v0.1.0MITupdated 2026-10-09alienfast/claude/mods/spawn-policy
A shopper browsing a rack in a slop shop
README

spawn-policy

A hooks-only mod on agent.spawn that applies the review tiers mechanically instead of by prose, and logs each decision.

Rules

RuleConditionRewrite
modelThe 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
syncrun_in_background is true on a main-loop spawn of quality-reviewer, quality-verifier or developerbackground 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.

Configuration

userConfig: enforce, developer_model (sonnet), verifier_model (sonnet), reviewer_model (opus), sync_review_dispatch (true), ledger (true).

Ledger

<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"}

Loading and checks

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-policy
Source 2 files
hooks/register.ts 58 lines
1import 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}
58
hooks/policy.ts 76 lines
1export 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