Watches subagent model routing and mid-session model/effort switches against the CLAUDE.md §2 tier rules; warns by default, enforces when asked

/plugin install tier-guard --marketplace steven0224-glitch/chan-mods
Older CLIs: claude plugin marketplace add steven0224-glitch/chan-mods then claude plugin install tier-guard@chan-mods.
Watches model routing against a three-tier rule and tells you when it drifts. Written for my CLAUDE.md §2:
| subagent | expected family |
|---|---|
deep-reasoner | opus |
default-worker | sonnet |
task-worker | haiku (effort medium, never low) |
built-in Explore | not fable/opus — cheap exploration belongs to task-worker |
What it does:
type → model, with ⚠ … when the tier is wrong.low toasts once per agent.세션 중간 전환 감지: … (switching mid-session re-bills the whole prompt-cache prefix; do it at session start or right after /compact). Tracking resets on compact, /clear, resume and fork./tier lists the session's spawns, warnings and whether enforce is on.| option | default | meaning |
|---|---|---|
enforce | false | true: run Explore on haiku (non-fork spawns) and raise a task-worker request's effort from low to medium. false: warn only |
"pluginConfigs": { "tier-guard@chan-mods": { "options": { "enforce": true } } }
Edit hooks/rules.ts to change the expected tiers.
hooks/register.ts 68 lines1import type { Register } from 'claude-code'
2
3import { isLowEffortTaskWorker, spawnWarning, switchNote } from './rules.ts'
4
5type Spawn = { type: string; model: string; warning: string }
6
7export const register: Register = (on, options) => {
8 const enforce = options.enforce === true
9 // Module variables on purpose: a reload starts the tracking over instead of warning on stale data
10 const typeByAgent = new Map<string, string>()
11 const effortWarned = new Set<string>()
12 const spawns: Spawn[] = []
13 let lastMain: { model: string; effort?: unknown } | null = null
14
15 on('session.start', async ($, e, next) => {
16 try {
17 await $.command.register({ name: 'tier', description: '이 세션의 서브에이전트 모델 라우팅과 경고 보기', immediate: true })
18 } catch (err) {
19 $.ui.log('/tier 등록 실패: ' + String(err))
20 }
21 return next(e)
22 })
23
24 // Compaction and /clear are the sanctioned moments to switch model or effort
25 on('classic.SessionStart', { source: ['compact', 'clear', 'resume', 'fork'] }, async ($, e, next) => {
26 lastMain = null
27 return next(e)
28 })
29
30 on('agent.spawn', async ($, e, next) => {
31 const forced = enforce && e.subagentType === 'Explore' && !e.fork
32 const r = await next(forced ? { ...e, model: 'haiku' } : e)
33 if (r.deny !== undefined) return r
34 if (r.agentId) typeByAgent.set(r.agentId, e.subagentType)
35 const warning = spawnWarning(e.subagentType, r.model)
36 spawns.push({ type: e.subagentType, model: r.model, warning })
37 $.ui.log(`${e.subagentType} → ${r.model}${forced ? ' (Explore→haiku 강제)' : ''}${warning ? ' ⚠ ' + warning : ''}`)
38 return r
39 })
40
41 on('turn.step', async function* ($, e, next) {
42 let step = e
43 if (e.agentId) {
44 const type = typeByAgent.get(e.agentId)
45 if (isLowEffortTaskWorker(type, e.effort)) {
46 if (enforce) step = { ...e, effort: 'medium' }
47 if (!effortWarned.has(e.agentId)) {
48 effortWarned.add(e.agentId)
49 $.ui.toast(`task-worker 가 effort low 로 실행${enforce ? ' → medium 으로 올림' : ''} (§2: low 금지)`)
50 }
51 }
52 } else {
53 const note = switchNote(lastMain, { model: e.model, effort: e.effort })
54 // A fallback model after an overload also shows up here, so the wording only reports
55 if (note) $.ui.toast(`세션 중간 전환 감지: ${note} — 캐시 프리픽스 재과금. 전환은 세션 시작·/compact 직후 (§2)`)
56 lastMain = { model: e.model, effort: e.effort }
57 }
58 return yield* next(step)
59 })
60
61 on('command.run', { command: 'tier' }, async () => {
62 if (spawns.length === 0) return { text: '이 세션에 서브에이전트 실행 없음' }
63 const warned = spawns.filter(s => s.warning).length
64 const rows = spawns.slice(-20).map(s => `${s.warning ? '⚠' : '·'} ${s.type} → ${s.model}${s.warning ? ' — ' + s.warning : ''}`)
65 return { text: [`서브에이전트 ${spawns.length}건 · 경고 ${warned}건 · enforce ${enforce ? 'on' : 'off'}`, ...rows].join('\n') }
66 })
67}
68hooks/rules.ts 37 lines1// CLAUDE.md §2 three-tier routing (2026-10-08)
2export const EXPECTED: Record<string, string> = {
3 'deep-reasoner': 'opus',
4 'default-worker': 'sonnet',
5 'task-worker': 'haiku',
6}
7
8const FAMILIES = ['fable', 'opus', 'sonnet', 'haiku']
9export const familyOf = (model: string | undefined) =>
10 FAMILIES.find(f => (model ?? '').toLowerCase().includes(f)) ?? ''
11
12const EXPENSIVE = ['fable', 'opus']
13
14// A warning for a spawn that ran on `model`, or '' when it follows the rules
15export function spawnWarning(subagentType: string, model: string): string {
16 const fam = familyOf(model)
17 if (subagentType === 'Explore' && EXPENSIVE.includes(fam)) {
18 return `Explore 가 ${model} 로 실행 — 싼 탐색은 task-worker (§2)`
19 }
20 const want = EXPECTED[subagentType]
21 if (want && fam && fam !== want) return `${subagentType} 는 ${want} 여야 하는데 ${model} (§2)`
22 return ''
23}
24
25export const isLowEffortTaskWorker = (subagentType: string | undefined, effort: unknown) =>
26 subagentType === 'task-worker' && effort === 'low'
27
28type Step = { model: string; effort?: unknown }
29// What changed between two main-loop requests, or '' when nothing did
30export function switchNote(prev: Step | null, next: Step): string {
31 if (!prev) return ''
32 const parts: string[] = []
33 if (prev.model !== next.model) parts.push(`모델 ${prev.model}→${next.model}`)
34 if (prev.effort !== next.effort) parts.push(`effort ${String(prev.effort ?? '-')}→${String(next.effort ?? '-')}`)
35 return parts.join(', ')
36}
37