Runs review and verifier subagents on the review model, counts the agents a turn spawns, and shows the batch's progress in the status line under the prompt.

A Claude Code mod that turns one standing rule into engine behaviour: review and verifier subagents run on Opus. The skills that spawn them through the Agent tool (subagent-review-panel in subagent mode, sre-panel, work-on's review gates) say sonnet or nothing. The mod re-points the spawn on its way to the engine. Without the mod, every spawn runs exactly as the skill wrote it.
A mod may change the model of a subagent spawn only behind a config key, only when the caller set no model or a model the key lists, and never for a model the caller chose explicitly. Here the key is enforce, the list is overrideFrom, and the target is reviewModel.
On agent.spawn the mod classifies the spawn from its description and the first line of its prompt; the rest of the prompt is the artifact and is never read. A description that opens with a work verb (implement, build, fix, update, find, write, scaffold, refactor, research, explore, promote) is work, whatever it names: "Implement review comment threading" stays put. Otherwise a review word in the description makes a reviewer: review, verify, critic, panel, audit, challenger, second opinion, analyst, assessor, adherence, validation, with their plurals. Failing that, the prompt's first line decides by its opener: You are a/the ... reviewer|critic|auditor|verifier|analyst|assessor, Review, Audit, Verify, or Role:. The word lists and the tests against the skills' literal labels live in lib/policy.ts and test/policy.test.mjs.
A reviewer moves to reviewModel when the caller set a model overrideFrom lists, or set none (or inherit) on a general-purpose spawn. Explore, Plan and plugin agents keep their own model unless the caller set a listed one. A model the caller chose that the list leaves out, say haiku, always stands. Teammates and forks are never touched: a fork inherits whatever a hook sets. The engine registers an agent spawned with a name as a teammate. The mod leaves teammates, forks, and explicitly chosen models alone, so a named reviewer keeps its own model. Seen live: a named reviewer ran on Fable while an unnamed one was re-pointed to Opus.
/config lists the mod's options, stored under pluginConfigs["panel-policy"].options:
| Option | Default | Effect |
|---|---|---|
enforce | true | false leaves every model as the skill set it |
reviewModel | opus | the alias or id reviewers run on |
overrideFrom | sonnet | comma-separated models a reviewer is moved off when the caller set one of them |
fanoutToast | 4 | toast once when a turn's spawn count reaches this; 0 is off |
fanoutHold | 0 | ask Proceed or Cancel on the Nth spawn of a turn; 0 never asks |
staleMinutes | 30 | a member in flight longer than this is stale: it leaves the in-flight count and no longer holds the band open; 0 never marks one stale |
The fan-out count takes the main loop's own spawns, not a subagent's or a teammate's. With fanoutHold set, spawns the model issues in parallel after the Nth proceed while the dialog is open; Cancel refuses that one spawn only.
While a batch is in flight, and for a minute after its last member returned, the mod pins one line in the status row under the prompt:
panel-policy: 2/5 returned · reviewers on opus
The engine adds the name; the text the mod composes starts at the count. The batch is the spawns of the newest turn that spawned anything. The suffix names the review model only while enforce is on and the batch holds a reviewer. A member in flight past staleMinutes is stale: the line reads 1/3 returned, 1 stale, and a batch whose members are all returned or stale clears after the linger. Nothing is pinned while idle, and nothing is drawn above the prompt: the mod registers no ui.render hook. The line is recomposed on every spawn and return; one timer, armed for the next linger end or stale boundary, re-checks it in between. Nothing polls.
The state keeps the newest 100 members: role, the first 60 characters of the description, start and end times, and the turn. There is no pane; the status line and the debug log are the whole view.
Each member is keyed by the agentId the engine returns from the spawn. Two signals mark it returned, whichever arrives first. The subagent's turn.complete carries the id as agentId; the classic SubagentStop settings event carries it as agent_id, and a background subagent's end reaches the main session that way. The second signal is a no-op. A spawn that resolves without an id started no agent and is not tracked. The debug log names which signal matched, or which running ids a signal failed to match.
claude --plugin-dir mods/panel-policy # from the repo root; reloads on save
claude plugin validate mods/panel-policy # what the engine would refuse
node --test mods/panel-policy/test/*.test.mjs
The engine writes .claude-plugin/types/ beside the mod on load; those files are the authority on event shapes for the build you run.
fanoutHold is off, and the hold's Cancel is the only path to { deny }.catch that logs to the debug log and passes the event on; when the hold cannot ask (a -p run), the spawn proceedsmodel lines still stand without ithooks/register.tsx 171 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { PanelPolicyMember, PanelPolicyTurn } from '../types'
5import {
6 addMember,
7 classifyRole,
8 completeMember,
9 countsTowardFanout,
10 decideModel,
11 formatDuration,
12 isFanoutHoldDue,
13 isFanoutToastDue,
14 memberLabel,
15 nextChangeMs,
16 readConfig,
17 staleAfterMs,
18 statusText,
19} from '../lib/policy'
20import type { PolicyConfig } from '../lib/policy'
21
22const HOLD_PROCEED = 'Proceed'
23const HOLD_CANCEL = 'Cancel'
24
25const members = atom({ plugin: 'panel-policy', key: 'members' } as const, [] as PanelPolicyMember[])
26const turn = atom({ plugin: 'panel-policy', key: 'turn' } as const, { id: null, spawns: 0 } as PanelPolicyTurn)
27
28type Logger = { ui: { log: (text: string, options: { to: 'debug' }) => void } }
29type Failed<E, R> = ((e: E) => R) & { event: string; error: { kind: string; message?: string } }
30
31// The one .catch every hook below carries: say why in the debug log, then
32// let the chain beneath answer as if the hook were absent. A spawn is never
33// denied by a failure here.
34const skipOnFailure = <E, R>($: Logger, e: E, next: Failed<E, R>): R => {
35 const why = next.error.message === undefined ? next.error.kind : `${next.error.kind}: ${next.error.message}`
36 $.ui.log(`panel-policy: ${next.event} skipped (${why})`, { to: 'debug' })
37 return next(e)
38}
39
40const describe = (err: unknown): string => (err instanceof Error ? err.message : String(err))
41
42// The text this module instance last pinned (null before its first pin),
43// and the one-shot timer for the next moment the text changes on its own:
44// a linger ending, a member going stale. A hot reload drops both, and
45// session.start pins and re-arms.
46let pinned: string | undefined | null = null
47let recheck: Timer | null = null
48
49// Recomposes the status line and pins it when it changed; undefined clears
50// it. Then arms one timer for the next change the clock alone brings.
51async function refreshStatus($: EngineInterface, config: PolicyConfig): Promise<void> {
52 const now = await $.clock.now()
53 const list = await read($, members)
54 const text = statusText(list, now, config)
55 if (pinned === null || text !== pinned) {
56 pinned = text
57 $.ui.status(text)
58 }
59 recheck?.cancel()
60 recheck = null
61 const delay = nextChangeMs(list, now, staleAfterMs(config))
62 if (delay !== null) {
63 recheck = $.clock.after(delay, () => {
64 void refreshStatus($, config)
65 })
66 }
67}
68
69// Marks the member `id` names as returned, from whichever signal came first:
70// the subagent's turn.complete or the classic SubagentStop event. The second
71// one finds the member already returned and only says so in the debug log.
72async function markReturned($: EngineInterface, id: string, signal: string, config: PolicyConfig): Promise<void> {
73 const now = await $.clock.now()
74 const known = (await read($, members)).find(m => m.id === id)
75 if (known === undefined) {
76 const running = (await read($, members)).filter(m => m.endedAt === null).map(m => m.id)
77 $.ui.log(`panel-policy: ${signal} for ${id} matched no member (running: ${running.join(',') || 'none'})`, { to: 'debug' })
78 return
79 }
80 if (known.endedAt !== null) {
81 $.ui.log(`panel-policy: ${signal} for ${id} found "${known.label}" already returned`, { to: 'debug' })
82 return
83 }
84 await update($, members, list => completeMember(list, id, now))
85 $.ui.log(
86 `panel-policy: ${known.role} "${known.label}" returned on ${signal} (${id}, ${formatDuration(now - known.startedAt)})`,
87 { to: 'debug' },
88 )
89 await refreshStatus($, config)
90}
91
92export const register: Register = (on, options) => {
93 const config = readConfig(options)
94
95 on('session.start', async ($, e, next) => {
96 const started = await next(e)
97 $.ui.log(
98 `panel-policy: loaded (enforce ${config.enforce}, reviewModel ${config.reviewModel}, overrideFrom ${config.overrideFrom.join(',')}, fanoutToast ${config.fanoutToast}, fanoutHold ${config.fanoutHold}, staleMinutes ${config.staleMinutes})`,
99 { to: 'debug' },
100 )
101 // A batch may be mid-flight after a reload; this pins its line again.
102 await refreshStatus($, config)
103 return started
104 }).catch(skipOnFailure)
105
106 on('turn.start', async ($, e, next) => {
107 await update($, turn, () => ({ id: e.turnId, spawns: 0 }))
108 return next(e)
109 }).catch(skipOnFailure)
110
111 on('agent.spawn', async ($, e, next) => {
112 if (countsTowardFanout(e)) {
113 const counted = await update($, turn, t => ({ id: t.id, spawns: t.spawns + 1 }))
114 const count = counted.spawns
115 if (isFanoutHoldDue(count, config)) {
116 let answer = HOLD_PROCEED
117 try {
118 answer = await $.ui.ask(`${count} agents spawned this turn. Proceed?`, [HOLD_PROCEED, HOLD_CANCEL])
119 } catch (err) {
120 $.ui.log(`panel-policy: fan-out hold not asked (${describe(err)}); proceeding`, { to: 'debug' })
121 }
122 if (answer === HOLD_CANCEL) return { deny: 'panel-policy: spawn cancelled at the fan-out hold' }
123 } else if (isFanoutToastDue(count, config)) {
124 $.ui.toast(`${count} agents spawned this turn`)
125 }
126 }
127
128 const role = classifyRole(e)
129 const model = decideModel(
130 { role, model: e.model, subagentType: e.subagentType, isTeammate: e.isTeammate, fork: e.fork },
131 config,
132 )
133 if (model !== null) {
134 $.ui.log(
135 `panel-policy: ${e.subagentType} "${e.description}" is a reviewer; model ${e.model ?? 'inherit'} -> ${model}`,
136 { to: 'debug' },
137 )
138 }
139 const started = model === null ? await next(e) : await next({ ...e, model })
140
141 if (started.deny !== undefined || e.isTeammate === true) return started
142 if (started.agentId === undefined) {
143 $.ui.log(`panel-policy: spawn "${e.description}" resolved without an agentId; not tracked`, { to: 'debug' })
144 return started
145 }
146 const member: PanelPolicyMember = {
147 id: started.agentId,
148 role,
149 label: memberLabel(e.description, e.subagentType),
150 startedAt: await $.clock.now(),
151 endedAt: null,
152 turn: (await read($, turn)).id,
153 }
154 await update($, members, list => addMember(list, member))
155 await refreshStatus($, config)
156 return started
157 }).catch(skipOnFailure)
158
159 on('turn.complete', async ($, e, next) => {
160 if (e.agentId !== undefined) await markReturned($, e.agentId, 'turn.complete', config)
161 return next(e)
162 }).catch(skipOnFailure)
163
164 // The settings-hook view of the same end: a background subagent's stop
165 // reaches the main session here with the id spelled `agent_id`.
166 on('classic.SubagentStop', async ($, e, next) => {
167 await markReturned($, e.agent_id, 'SubagentStop', config)
168 return next(e)
169 }).catch(skipOnFailure)
170}
171lib/policy.ts 269 lines1// Pure helpers behind the panel-policy mod: no `$`, no I/O, so `node --test`
2// covers them without the engine. register.tsx is the only caller.
3
4export type Role = 'review' | 'other'
5
6export type Member = {
7 /** The subagent's engine id: `agentId` on its `turn.complete`, `agent_id` on `classic.SubagentStop`. */
8 id: string
9 role: Role
10 /** The first characters of the spawn's description. */
11 label: string
12 startedAt: number
13 endedAt: number | null
14 /** The main-loop turn the spawn happened in; null when none was seen yet. */
15 turn: string | null
16}
17
18export type PolicyConfig = {
19 enforce: boolean
20 reviewModel: string
21 /** Models a reviewer is moved off when the caller set one of them. */
22 overrideFrom: string[]
23 fanoutToast: number
24 fanoutHold: number
25 /** Minutes a member may stay in flight before it counts as stale; 0 never. */
26 staleMinutes: number
27}
28
29/** Returned, still in flight, or in flight past the stale budget. */
30export type MemberStatus = 'running' | 'done' | 'stale'
31
32/** How long a finished batch stays in the status line after its last member returned. */
33export const STATUS_LINGER_MS = 60_000
34
35/** How many characters of the prompt's first line the classifier reads. */
36export const PROMPT_LEAD_CHARS = 200
37
38/** How many characters of a description a member keeps as its label. */
39export const LABEL_CHARS = 60
40
41/** How many members the state keeps; older ones are forgotten. */
42export const MEMBER_CAP = 100
43
44/** The agent type whose model the mod may pick when the caller set none. */
45export const GENERAL_PURPOSE = 'general-purpose'
46
47const DEFAULTS: PolicyConfig = {
48 enforce: true,
49 reviewModel: 'opus',
50 overrideFrom: ['sonnet'],
51 fanoutToast: 4,
52 fanoutHold: 0,
53 staleMinutes: 30,
54}
55
56const MINUTE_MS = 60_000
57
58// The description's review vocabulary, on word boundaries so "verified",
59// "verification" (the brain-research promoter's bundle) and "critical"
60// (every severity scale) stay out. Plurals and -ing forms are in because the
61// skills use them ("reviewers", "reviews"); analyst, assessor, adherence and
62// validation name the sre-panel and work-on agents whose labels say nothing
63// of reviewing.
64const REVIEW_WORDS =
65 /\b(?:reviews?|reviewers?|reviewing|verify|verifies|verifying|verifiers?|critics?|panels?|audits?|auditors?|auditing|challengers?|second opinion|analysts?|assessors?|adherence|validation)\b/i
66
67// A description that opens with a work verb is work, whatever it goes on to
68// name: "Implement review comment threading", "Fix cosign verify step",
69// "Build the panel-policy mod". The verb is followed by a space or a colon,
70// so a compound such as "Build-gate verifier" is not a lead.
71const WORK_VERB_LEAD =
72 /^(?:implement|build|fix|update|find|write|rewrite|scaffold|refactor|research|explore|promote|promoter|add|create|draft|run|migrate)(?:\s|:)/i
73
74// The prompt's first line, where a reviewer prompt names its role or opens
75// with the imperative; the rest of the prompt is the artifact and is never
76// read. The role clause stops at sentence punctuation, so "You are a fresh
77// reader. Answer about the reviewer tool" does not match.
78const ROLE_LEAD =
79 /^(?:You are [^.:;!?]*?\b(?:reviewer|critic|auditor|verifier|analyst|assessor)s?\b|Review\b|Audit\b|Verify\b|\**Role:?\**:?\s)/i
80
81/** The prompt's first non-empty line, capped. */
82export function promptLead(prompt: string): string {
83 for (const line of prompt.split('\n')) {
84 const trimmed = line.trim()
85 if (trimmed !== '') return trimmed.slice(0, PROMPT_LEAD_CHARS)
86 }
87 return ''
88}
89
90/**
91 * Whether a spawn is a reviewer. The description decides first: a leading
92 * work verb settles it as other, then a review word makes it a reviewer.
93 * Otherwise the prompt's first line decides, by a role or imperative opener.
94 */
95export function classifyRole(spawn: { description: string; prompt: string }): Role {
96 const description = spawn.description.trim()
97 if (WORK_VERB_LEAD.test(description)) return 'other'
98 if (REVIEW_WORDS.test(description)) return 'review'
99 return ROLE_LEAD.test(promptLead(spawn.prompt)) ? 'review' : 'other'
100}
101
102/** True when `model` already names the review model, as an alias or a full id. */
103export function isOnModel(model: string | undefined, reviewModel: string): boolean {
104 if (model === undefined || model === '') return false
105 return model === reviewModel || model.includes(reviewModel)
106}
107
108/** True for a spawn whose type lets the parent's model decide. */
109export function isGeneralPurpose(subagentType: string | undefined): boolean {
110 const type = subagentType?.trim() ?? ''
111 return type === '' || type === GENERAL_PURPOSE
112}
113
114/**
115 * The model to re-point a spawn to, or null to leave it alone. A reviewer
116 * moves when the caller set a model `overrideFrom` lists, or set none (or
117 * `inherit`) on a general-purpose spawn. A model the caller chose that the
118 * key does not list stays, as does an agent type with a model of its own
119 * (Explore, Plan, a plugin's agent) unless the caller set a listed model.
120 * Teammates and forks are never touched: a fork inherits whatever a hook
121 * sets, and nothing moves while `enforce` is off.
122 */
123export function decideModel(
124 spawn: { role: Role; model?: string; subagentType?: string; isTeammate?: boolean; fork?: boolean },
125 config: Pick<PolicyConfig, 'enforce' | 'reviewModel' | 'overrideFrom'>,
126): string | null {
127 if (!config.enforce || spawn.role !== 'review') return null
128 if (spawn.isTeammate === true || spawn.fork === true) return null
129 const model = spawn.model?.trim() ?? ''
130 if (isOnModel(model, config.reviewModel)) return null
131 const isListed = config.overrideFrom.some(m => m.toLowerCase() === model.toLowerCase())
132 if (isListed) return config.reviewModel
133 const isUnset = model === '' || model.toLowerCase() === 'inherit'
134 return isUnset && isGeneralPurpose(spawn.subagentType) ? config.reviewModel : null
135}
136
137/** True for a spawn the fan-out count takes: the main loop's own, no teammate. */
138export function countsTowardFanout(spawn: { isTeammate?: boolean; parentAgentId?: string }): boolean {
139 return spawn.isTeammate !== true && spawn.parentAgentId === undefined
140}
141
142/** The spawn count at which the fan-out toast shows, once per turn. */
143export function isFanoutToastDue(count: number, config: Pick<PolicyConfig, 'fanoutToast'>): boolean {
144 return config.fanoutToast > 0 && count === config.fanoutToast
145}
146
147/** The spawn count at which the hold asks, once per turn; never while off. */
148export function isFanoutHoldDue(count: number, config: Pick<PolicyConfig, 'fanoutHold'>): boolean {
149 return config.fanoutHold > 0 && count === config.fanoutHold
150}
151
152/** The plugin's options with defaults filled in and types settled. */
153export function readConfig(options: Readonly<Record<string, unknown>>): PolicyConfig {
154 const bool = (value: unknown, fallback: boolean): boolean =>
155 typeof value === 'boolean' ? value : value === 'true' ? true : value === 'false' ? false : fallback
156 const text = (value: unknown, fallback: string): string =>
157 typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback
158 const count = (value: unknown, fallback: number): number => {
159 const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN
160 return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback
161 }
162 const list = (value: unknown, fallback: string[]): string[] => {
163 const items = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : null
164 if (items === null) return fallback
165 return items.filter((item): item is string => typeof item === 'string').map(item => item.trim()).filter(item => item !== '')
166 }
167 return {
168 enforce: bool(options.enforce, DEFAULTS.enforce),
169 reviewModel: text(options.reviewModel, DEFAULTS.reviewModel),
170 overrideFrom: list(options.overrideFrom, DEFAULTS.overrideFrom),
171 fanoutToast: count(options.fanoutToast, DEFAULTS.fanoutToast),
172 fanoutHold: count(options.fanoutHold, DEFAULTS.fanoutHold),
173 staleMinutes: count(options.staleMinutes, DEFAULTS.staleMinutes),
174 }
175}
176
177/** How long a member may be in flight before it is stale, in ms; 0 means never. */
178export function staleAfterMs(config: Pick<PolicyConfig, 'staleMinutes'>): number {
179 return config.staleMinutes * MINUTE_MS
180}
181
182/** What a member is at `now`; a member never returned goes stale past `staleMs`. */
183export function memberStatus(member: Member, now: number, staleMs: number): MemberStatus {
184 if (member.endedAt !== null) return 'done'
185 return staleMs > 0 && now - member.startedAt > staleMs ? 'stale' : 'running'
186}
187
188/** The label a member shows: the head of the description, or the agent type. */
189export function memberLabel(description: string, subagentType: string): string {
190 const head = description.trim() === '' ? subagentType : description.trim()
191 return head.slice(0, LABEL_CHARS)
192}
193
194/** The list with one more member, capped to the newest MEMBER_CAP. */
195export function addMember(members: readonly Member[], member: Member): Member[] {
196 return [...members, member].slice(-MEMBER_CAP)
197}
198
199/** Marks the running member `agentId` names as done; unchanged when none is. */
200export function completeMember(members: readonly Member[], agentId: string, now: number): Member[] {
201 return members.map(m => (m.id === agentId && m.endedAt === null ? { ...m, endedAt: now } : m))
202}
203
204/** The members of the latest batch: those spawned in the newest member's turn. */
205export function currentBatch(members: readonly Member[]): Member[] {
206 const last = members[members.length - 1]
207 if (last === undefined) return []
208 return members.filter(m => m.turn === last.turn)
209}
210
211/**
212 * True while the latest batch has a member in flight (not yet stale) or one
213 * returned within STATUS_LINGER_MS. A stale member holds nothing open.
214 */
215export function isBatchActive(members: readonly Member[], now: number, staleMs = 0): boolean {
216 const batch = currentBatch(members)
217 if (batch.length === 0) return false
218 const isRunning = batch.some(m => memberStatus(m, now, staleMs) === 'running')
219 const isRecent = batch.some(m => m.endedAt !== null && now - m.endedAt <= STATUS_LINGER_MS)
220 return isRunning || isRecent
221}
222
223/**
224 * The status line, or undefined (clear it) while the batch is neither in
225 * flight nor fresh: `2/5 returned · reviewers on opus`, with `, 1 stale`
226 * after the count when a member outran the stale budget. The suffix names
227 * the review model only when the batch holds a reviewer and `enforce` is
228 * on. The engine prefixes the line with the mod's name, so the text never
229 * repeats it.
230 */
231export function statusText(
232 members: readonly Member[],
233 now: number,
234 config: Pick<PolicyConfig, 'enforce' | 'reviewModel' | 'staleMinutes'>,
235): string | undefined {
236 const staleMs = staleAfterMs(config)
237 if (!isBatchActive(members, now, staleMs)) return undefined
238 const batch = currentBatch(members)
239 const done = batch.filter(m => m.endedAt !== null).length
240 const stale = batch.filter(m => memberStatus(m, now, staleMs) === 'stale').length
241 const staleNote = stale > 0 ? `, ${stale} stale` : ''
242 const hasReviewer = batch.some(m => m.role === 'review')
243 const suffix = config.enforce && hasReviewer ? ` · reviewers on ${config.reviewModel}` : ''
244 return `${done}/${batch.length} returned${staleNote}${suffix}`
245}
246
247/**
248 * Milliseconds until the status line next changes on its own: a returned
249 * member's linger running out, or a running member going stale. Null when
250 * no such moment lies ahead; a moment already passed is not one.
251 */
252export function nextChangeMs(members: readonly Member[], now: number, staleMs: number): number | null {
253 let soonest: number | null = null
254 for (const m of currentBatch(members)) {
255 const at = m.endedAt !== null ? m.endedAt + STATUS_LINGER_MS + 1 : staleMs > 0 ? m.startedAt + staleMs + 1 : null
256 if (at === null || at <= now) continue
257 if (soonest === null || at - now < soonest) soonest = at - now
258 }
259 return soonest
260}
261
262/** `12 s` under a minute, `1m 05s` past it; the debug log's duration of a returned member. */
263export function formatDuration(ms: number): string {
264 const seconds = Math.max(0, Math.round(ms / 1000))
265 if (seconds < 60) return `${seconds} s`
266 const minutes = Math.floor(seconds / 60)
267 return `${minutes}m ${String(seconds % 60).padStart(2, '0')}s`
268}
269types/index.d.ts 22 lines1export type PanelPolicyRole = 'review' | 'other'
2
3export type PanelPolicyMember = {
4 id: string
5 role: PanelPolicyRole
6 label: string
7 startedAt: number
8 endedAt: number | null
9 turn: string | null
10}
11
12export type PanelPolicyTurn = { id: string | null; spawns: number }
13
14declare module 'claude-code' {
15 interface PluginState {
16 'panel-policy': {
17 members: PanelPolicyMember[]
18 turn: PanelPolicyTurn
19 }
20 }
21}
22