Herd mode: /herd on|off|<tasks> makes the session the herd head and routes /work and /finish through it, and a band and side pane keep the head's open…

A cross-platform dotfiles configuration for macOS, Windows (WSL2), and Linux. This configuration emphasizes modern tooling and AI-assisted developer productivity.
packages/claude/.claude/, symlinked into ~/.claude📖 See CLAUDE.md for detailed Claude Code integration setup
xcode-select --install)sudo for the apt step; without it the bootstrap prints the packages to install manually git clone https://github.com/barrydobson/dotfiles-public.git ~/dotfiles
cd ~/dotfiles
./install.sh
install.sh runs the platform script first (Homebrew or apt, plus stow, zsh and git), stows the packages, and only then installs tools - the Brewfile and mise config have to be symlinked into $HOME before they can be used. The platform scripts are runnable on their own if you only want the prerequisites:
./install/mac.sh # macOS
./install/ubuntu.sh # Ubuntu/Debian
exec zsh
| Traditional | Modern Alternative | Purpose |
|---|---|---|
ls, ll, la, tree | eza | Directory listing with icons and git status |
find | fd | Fast file finder; backs fzf |
grep | ripgrep (rg, rga) | Fast text search - grep itself is not aliased |
cd | zoxide | Smart directory navigation (--cmd cd, so cd is zoxide) |
ps | procs | Process viewer |
rm | trash | Recoverable deletes when trash is installed |
Ctrl-R | atuin | Searchable, synced shell history |
packages/starship/Ghostty is the only terminal with a config here (packages/ghostty/):
voltaic-dark / voltaic-light, following the system appearancesuper+d right, super+shift+d down, ctrl+hjkl to navigatecmd+a sends the Ctrl-A prefix, and Shift+Enter is fixed upA minimal, beginner-friendly config in one file, packages/nvim/.config/nvim/init.lua. Needs Neovim 0.12+ for the built-in vim.pack plugin manager. Plugins: catppuccin, which-key, telescope, gitsigns and neo-tree. See KEYMAPS.md.
config includes .gitalias and a gitignored .gitconfig-localpull.rebase, rebase.autosquash, fetch.prune, push.autoSetupRemotea, b, c, d, ...) plus git cleanup# Deployment (run from packages/)
cd packages
stow -v -t "${HOME}" git zsh # Deploy specific packages
stow -v -D -t "${HOME}" git # Remove a package
stow -v -R -t "${HOME}" git # Redeploy a package
# Development
shellcheck -x --source-path=SCRIPTDIR install.sh install/*.sh # Lint shell scripts (same as CI)
wt (worktrunk) manages worktrees; its shell hook is wired up in .zshrcgit cleanup to prune merged branches.gitconfig-local for machine-specific settings (gitignored)cd <partial-path> - zoxide-backed jumping (zoxide takes over cd)ll, la, tree - eza listingsCtrl-R - atuin history search; Ctrl-T file search and Alt-C directory jump via fzfea, ez, ev - edit the alias, zsh and Neovim configsmnt - autoloaded function for mounting external drives; extract and +x in ~/.local/binpackages/ghostty/.config/ghostty/configCtrl-A prefix, | and - to split, r to reload~/.Brewfile~/.1password/agent.sock1Password, claude, ghostty, k9s, lazygit, ssh, vscode, zed, and the Claude helpers (see MAC_PACKAGES in install.sh)install.sh after stowing, into ~/.local/bin where relevantpackages/<tool>/ with the config at its real dotfile pathCOMMON_PACKAGES or MAC_PACKAGES in install.shThemes are set per application, so changing one means editing each config:
vim.cmd.colorscheme in packages/nvim/.config/nvim/init.lua@catppuccin_* settings in packages/tmux/.config/tmux/tmux.confpalette in packages/starship/.config/starship/starship.tomltheme in packages/ghostty/.config/ghostty/configui.skin in packages/k9s/.config/k9s/config.yaml, skins under skins/All gitignored:
packages/git/.config/git/.gitconfig-local - included by the main config for machine-specific settingspackages/zsh/.config/zsh/conf.d/99_private-environment.zsh - loaded automatically by the conf.d glob~/.env - sourced by 10_environment.zsh if present, one export per lineStow conflicts:
cd packages
stow -v -R -t "${HOME}" <package> # Restow (unstow + stow)
Stow refuses to overwrite a real file. Move or delete the conflicting file, then restow.
Font not displaying:
fc-cache -f (Linux)Shell completions missing:
exec zsh${XDG_DATA_HOME:-~/.local/share}/zinit/zinit.gitrm "${XDG_CACHE_HOME:-$HOME/.cache}/zsh/zcompdump"bash <(curl -s https://raw.githubusercontent.com/JuliusBrussee/caveman/main/hooks/install.sh)This is a personal dotfiles repository, but suggestions and improvements are welcome:
This project is licensed under the MIT License - see the LICENSE file for details.
hooks/register.tsx 439 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Finding, Question } from '../types'
5
6const PANE = 'herd-mode'
7const TOOL = {
8 raise: 'mcp__herd-mode__raise_question',
9 resolve: 'mcp__herd-mode__resolve_question',
10 park: 'mcp__herd-mode__park_finding',
11 instructions: 'mcp__herd-mode__herd_instructions',
12} as const
13
14const isOn = atom({ plugin: 'herd-mode', key: 'isOn' } as const, false)
15// Change a tag when that value's fields change: it declines what an earlier version stored.
16const questions = atom({ plugin: 'herd-mode', key: 'questions' } as const, [] as Question[], { shape: 'questions-1' })
17const findings = atom({ plugin: 'herd-mode', key: 'findings' } as const, [] as Finding[], { shape: 'findings-1' })
18const isEvidenceShown = atom({ plugin: 'herd-mode', key: 'isEvidenceShown' } as const, false)
19const lastId = atom({ plugin: 'herd-mode', key: 'lastId' } as const, { question: 0, finding: 0 }, { shape: 'last-id-1' })
20
21const label = (q: Question) => `Q${q.id}${q.about ? ` (${q.about})` : ''}`
22const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
23
24/** Answers that have not reached Claude yet, as one message, and their ids. */
25function answers(list: readonly Question[]): { text: string; ids: number[] } | undefined {
26 const unsent = list.filter(q => q.answer !== undefined && !q.isSent)
27 if (unsent.length === 0) return undefined
28 return {
29 text: `The person answered your open questions:\n${unsent.map(q => `- ${label(q)} "${q.text}": ${q.answer}`).join('\n')}\nAct on these answers.`,
30 ids: unsent.map(q => q.id),
31 }
32}
33
34// An answered question is done once Claude has it, so it leaves the board.
35const dropSent = ($: EngineInterface, ids: number[]) => update($, questions, list => list.filter(q => !ids.includes(q.id)))
36
37const answer = ($: EngineInterface, id: number, text: string) =>
38 text ? update($, questions, list => list.map(q => (q.id === id ? { ...q, answer: text, isSent: false } : q))) : undefined
39
40const nextId = async ($: EngineInterface, kind: 'question' | 'finding') =>
41 (await update($, lastId, ids => ({ ...ids, [kind]: ids[kind] + 1 })))[kind]
42
43// Pressing the mark a finding already has clears it.
44const mark = ($: EngineInterface, id: number, to: 'ticket' | 'ignore') =>
45 update($, findings, list =>
46 list.map(f => {
47 if (f.id !== id) return f
48 const { mark: was, ...rest } = f
49 return was === to ? rest : { ...rest, mark: to }
50 }),
51 )
52
53const openPane = ($: EngineInterface, focus?: true) =>
54 $.ui.open({ id: PANE, title: 'Herd board', ...(focus && { focus, closeOnEscape: true }) })
55
56// The tote-issue-tracker commands herd mode routes; matched by name so a plugin prefix or none both work.
57const ROUTED = /(^|:)(work|finish)$/
58
59// The instructions reach the model as user-role context: the managed security plugin
60// (cc-plugin-sec-default) passes plugin hooks on prompt.compose, prompt.context and skill.prompt
61// straight past, so no system-prompt section or skill rewrite arrives.
62const REMINDER = `Herd mode is on: you are the herd head and follow the herd instructions from /herd on. If they are no longer in your context (after a compaction), call ${TOOL.instructions} before anything else.`
63const ROUTE_LINE = 'Run this command through the herd instructions: /work through "/work through herd", /finish by sending it to the repo\'s worker. Do no repo work in this session yourself.'
64
65const HERD_TOOLS: readonly string[] = Object.values(TOOL)
66
67/** herd.md, the head's instructions; read on each use so an edit applies at once. */
68const herdText = ($: EngineInterface) => $.fs.read(`${$.plugin.root}/herd.md`)
69
70// The board's tools are listed while the mode is on and behind ToolSearch while it is off.
71async function setMode($: EngineInterface, value: boolean) {
72 await update($, isOn, () => value)
73 $.ui.invalidate('tool.describe')
74}
75
76const insideHerdr = async ($: EngineInterface) => (await $.process.run(['printenv', 'HERDR_ENV'])).stdout.trim() === '1'
77
78const triagePrompt = (list: readonly Finding[]) =>
79 `I want these parked findings ticketed. Propose tickets for them, one repo per ticket, and don't publish until I approve:\n${list
80 .map(f => `- F${f.id} [${f.repo}] ${f.text}${f.evidence ? ` (evidence: ${f.evidence})` : ''}`)
81 .join('\n')}`
82
83export const register: Register = on => {
84 on('session.start', async ($, e, next) => {
85 await $.command.register({
86 name: 'herd',
87 description: 'Herd mode: on, off, or tasks to fan out to worker sessions in other repos',
88 argumentHint: 'on | off | <tasks>',
89 })
90 // A resumed session asks again, so the tools' listing matches the mode it kept.
91 if (await read($, isOn)) $.ui.invalidate('tool.describe')
92 // Registered deferred; tool.describe lists them while the mode is on.
93 await $.tool.register({
94 name: 'raise_question',
95 description:
96 'Put a question for the person on the herd board instead of asking it in chat, where it scrolls away. Returns its stable id (Q<n>). Carry on without waiting: the answer arrives with their next prompt. Re-raise nothing; a question stays on the board until answered or resolved.',
97 inputSchema: {
98 type: 'object',
99 properties: {
100 question: { type: 'string', description: 'One line: the decision and the risk, e.g. "Merge PR #19236? Compliance dashboards drop for up to 10 min."' },
101 about: { type: 'string', description: 'The ticket key or worker tab it concerns, e.g. "PI-2201".' },
102 options: { type: 'array', items: { type: 'string' }, description: 'Optional answers to offer as buttons, e.g. ["yes", "no"].' },
103 },
104 required: ['question'],
105 },
106 isDeferred: true,
107 })
108 await $.tool.register({
109 name: 'resolve_question',
110 description: 'Take a question off the herd board when it was answered in chat or no longer matters.',
111 inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'The question number, 3 for Q3.' } }, required: ['id'] },
112 isDeferred: true,
113 })
114 await $.tool.register({
115 name: 'herd_instructions',
116 description: 'Returns the herd head\'s instructions. Call it when herd mode is on and they are no longer in your context, for example after a compaction.',
117 inputSchema: { type: 'object' },
118 isDeferred: true,
119 })
120 await $.tool.register({
121 name: 'park_finding',
122 description:
123 'Park an out-of-scope finding for triage later, instead of offering a ticket now. The person marks each one ticket or ignore and sends the ticket ones back in one go.',
124 inputSchema: {
125 type: 'object',
126 properties: {
127 repo: { type: 'string', description: 'The repo the finding belongs to.' },
128 finding: { type: 'string', description: 'The finding in one sentence.' },
129 evidence: { type: 'string', description: 'Optional: the error text, query result or file:line behind it.' },
130 },
131 required: ['repo', 'finding'],
132 },
133 isDeferred: true,
134 })
135 return next(e)
136 })
137
138 on('tool.call', { tool: TOOL.raise }, async ($, e) => {
139 const text = str(e.question)
140 if (!text) return { deny: 'Pass `question`: the decision and its risk in one line.' }
141 const about = str(e.about)
142 const options = Array.isArray(e.options) ? e.options.map(str).filter(Boolean) : []
143 const id = await nextId($, 'question')
144 await update($, questions, list => [
145 ...list,
146 { id, text, isSent: false, ...(about && { about }), ...(options.length > 0 && { options }) },
147 ])
148 void openPane($)
149 return { result: `Raised as Q${id}. Carry on; the answer comes back with the person's next prompt.` }
150 })
151
152 on('tool.call', { tool: TOOL.resolve }, async ($, e) => {
153 const id = Number(e.id)
154 const list = await read($, questions)
155 if (!list.some(q => q.id === id)) return { deny: `No Q${e.id} on the board. Open: ${list.map(label).join(', ') || 'none'}.` }
156 await update($, questions, l => l.filter(q => q.id !== id))
157 return { result: `Q${id} is off the board.` }
158 })
159
160 on('tool.call', { tool: TOOL.instructions }, async $ => {
161 try {
162 return { result: await herdText($) }
163 } catch (err) {
164 return { deny: `herd.md in the herd-mode mod could not be read: ${String(err)}. Tell the user.` }
165 }
166 })
167
168 on('tool.call', { tool: TOOL.park }, async ($, e) => {
169 const repo = str(e.repo)
170 const text = str(e.finding)
171 if (!repo || !text) return { deny: 'Pass `repo` and `finding`.' }
172 const evidence = str(e.evidence)
173 const id = await nextId($, 'finding')
174 await update($, findings, list => [...list, { id, repo, text, ...(evidence && { evidence }) }])
175 return { result: `Parked as F${id}. Mention it in one line; don't offer a ticket now.` }
176 })
177
178 // /herd on|off|<tasks> is the mod's own command; nothing runs beneath it.
179 on('command.run', { command: 'herd' }, async ($, e) => {
180 const args = e.args.trim()
181 const arg = args.toLowerCase()
182 if (arg === '') {
183 return { text: `Herd mode is ${(await read($, isOn)) ? 'on' : 'off'}. Use /herd on, /herd off, or /herd <tasks>.` }
184 }
185 if (arg === 'off') {
186 await setMode($, false)
187 return {
188 text: 'Herd mode is off. Worker tabs still open stay open: close them with herdr tab close.',
189 context: ['Herd mode is now off: the herd instructions no longer apply. Do repo work here as normal.'],
190 }
191 }
192 if (!(await insideHerdr($))) return { text: 'Herd mode stays off: this session is not running inside Herdr (HERDR_ENV is not 1).' }
193 await setMode($, true)
194 let instructions: string
195 try {
196 instructions = await herdText($)
197 } catch (err) {
198 instructions = `herd.md could not be read (${String(err)}); call ${TOOL.instructions}, and tell the user if that fails too.`
199 }
200 const context = [`Herd mode is now on. These are your instructions as the herd head until /herd off:\n\n${instructions}`]
201 if (arg === 'on') {
202 return { text: 'Herd mode is on. /work and /finish now run through herd; /herd off ends it.', context }
203 }
204 // A command can't submit a prompt while it holds the turn, so the tasks go once it has ended.
205 $.clock.after(0, () =>
206 $.prompt.submit({ text: args }).catch(err => $.ui.toast(`Herd mode is on. Send the tasks again as a prompt. (${String(err)})`)),
207 )
208 return { text: 'Herd mode is on. Sending your tasks to the head.', context }
209 })
210
211 on('tool.describe', async ($, e, next) => {
212 const described = await next(e)
213 if (!HERD_TOOLS.includes(e.tool)) return described
214 return { ...described, isDeferred: !(await read($, isOn)) }
215 })
216
217 // Unseen by the person, the next prompt carries: answers not sent yet, and while herd mode is
218 // on, the reminder (plus the routing line for a typed /work or /finish).
219 on('prompt.submit', async ($, e, next) => {
220 const pending = answers(await read($, questions))
221 const herdOn = await read($, isOn)
222 const extra = [
223 ...(pending ? [pending.text] : []),
224 ...(herdOn ? [REMINDER] : []),
225 ...(herdOn && e.command && ROUTED.test(e.command.name) ? [ROUTE_LINE] : []),
226 ]
227 if (extra.length === 0) return next(e)
228 const submitted = await next({ ...e, context: [...(e.context ?? []), ...extra] })
229 if (pending && submitted.drop === undefined) await dropSent($, pending.ids)
230 return submitted
231 })
232
233 // A /clear starts a new conversation: its board starts over too.
234 on('session.end', async ($, e, next) => {
235 if (e.reason === 'clear') {
236 await update($, questions, () => [])
237 await update($, findings, () => [])
238 }
239 return next(e)
240 })
241
242 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
243 const qs = await read($, questions)
244 const fs = await read($, findings)
245 const isHerdOn = await read($, isOn)
246 const isEmpty = qs.length === 0 && fs.length === 0
247 if (e.props.hasSurvey || (!isHerdOn && isEmpty)) return next(e)
248
249 const { Box, Button, Text } = $.ui.resolve(e)
250 const waiting = qs.filter(q => q.answer === undefined).length
251 const unsent = qs.length - waiting
252 const parts = [
253 `${waiting} waiting on you`,
254 ...(unsent > 0 ? [`${unsent} answered, not sent`] : []),
255 ...(fs.length > 0 ? [`${fs.length} parked`] : []),
256 ]
257
258 return (
259 <Box>
260 {isHerdOn ? (
261 <Text inverse bold color="claude">
262 {' HERD ON '}
263 </Text>
264 ) : null}
265 {isHerdOn ? <Text> </Text> : null}
266 {isEmpty ? (
267 <Text dimColor>nothing waiting</Text>
268 ) : (
269 <Box>
270 <Text color={waiting > 0 ? 'warning' : undefined} dimColor={waiting === 0}>
271 Herd: {parts.join(' · ')}{' '}
272 </Text>
273 <Button key="show" label="Show" hotkey="w" onPress={() => openPane($, true)} />
274 </Box>
275 )}
276 </Box>
277 )
278 })
279
280 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
281 const qs = await read($, questions)
282 const fs = await read($, findings)
283 const { Box, Button, Text } = $.ui.resolve(e)
284 const Input = e.surface === 'mobile' ? undefined : $.ui.resolve(e).Input
285 const pending = answers(qs)
286
287 const send = async () => {
288 const now = answers(await read($, questions))
289 if (now === undefined) return
290 await dropSent($, now.ids) // first, so the prompt.submit hook doesn't attach them again
291 await $.prompt.submit({ text: now.text })
292 }
293 // Marked findings leave the board; unmarked ones stay parked.
294 const apply = async () => {
295 const list = await read($, findings)
296 const toTicket = list.filter(f => f.mark === 'ticket')
297 await update($, findings, l => l.filter(f => f.mark === undefined))
298 if (toTicket.length > 0) await $.prompt.submit({ text: triagePrompt(toTicket) })
299 }
300
301 const waiting = qs.filter(q => q.answer === undefined).length
302 const repos = [...new Set(fs.map(f => f.repo))]
303 const showEvidence = await read($, isEvidenceShown)
304 const hasEvidence = fs.some(f => f.evidence)
305 const ticketed = fs.filter(f => f.mark === 'ticket').length
306 const ignored = fs.filter(f => f.mark === 'ignore').length
307
308 // The id sits in its own column so wrapped text lines up under the first line, not under the id.
309 const row = (key: string, id: string, body: RenderElement) => (
310 <Box key={key}>
311 <Box width={5} flexShrink={0}>
312 <Text bold color="claude">
313 {id}
314 </Text>
315 </Box>
316 <Box flexShrink={1}>{body}</Box>
317 </Box>
318 )
319
320 return (
321 <Box flexDirection="column">
322 <Text bold>
323 Waiting on you <Text dimColor>({waiting})</Text>
324 </Text>
325 {qs.length === 0 && <Text dimColor>Nothing to answer.</Text>}
326 {qs.map(q => (
327 <Box key={`q:${q.id}`} flexDirection="column" marginTop={1}>
328 {row(
329 `qr:${q.id}`,
330 `Q${q.id}`,
331 <Box flexDirection="column">
332 {q.about ? <Text dimColor>{q.about}</Text> : null}
333 <Text>{q.text}</Text>
334 {q.answer === undefined ? null : (
335 <Text color="success">
336 → {q.answer}
337 <Text dimColor> (not sent yet)</Text>
338 </Text>
339 )}
340 {q.options === undefined ? null : (
341 <Box columnGap={1}>
342 {q.options.map((o, i) => (
343 <Button
344 key={`opt:${q.id}:${i}`}
345 label={o}
346 variant={q.answer === o ? 'primary' : 'secondary'}
347 onPress={() => answer($, q.id, o)}
348 />
349 ))}
350 </Box>
351 )}
352 {Input === undefined ? null : (
353 <Input
354 key={`ans:${q.id}`}
355 label="Answer: "
356 placeholder={q.options ? 'or type your own' : 'type your answer'}
357 submitLabel="answer"
358 value={q.answer ?? ''}
359 onSubmit={value => answer($, q.id, value.trim())}
360 />
361 )}
362 </Box>,
363 )}
364 </Box>
365 ))}
366 {pending === undefined ? null : (
367 <Box marginTop={1}>
368 <Button key="send" label={`Send answers (${pending.ids.length})`} variant="primary" onPress={send} />
369 <Text dimColor> or they go with your next prompt</Text>
370 </Box>
371 )}
372
373 <Box marginTop={2}>
374 <Text bold>
375 Parked <Text dimColor>({fs.length})</Text>
376 </Text>
377 {hasEvidence ? (
378 <Box marginLeft={1}>
379 <Button
380 key="evidence"
381 label={showEvidence ? 'Hide evidence' : 'Show evidence'}
382 plain
383 onPress={() => update($, isEvidenceShown, v => !v)}
384 />
385 </Box>
386 ) : null}
387 </Box>
388 {fs.length === 0 && <Text dimColor>Nothing parked.</Text>}
389 {repos.map(repo => (
390 <Box key={`r:${repo}`} flexDirection="column" marginTop={1}>
391 <Text underline>{repo}</Text>
392 {fs
393 .filter(f => f.repo === repo)
394 .map(f =>
395 row(
396 `f:${f.id}`,
397 `F${f.id}`,
398 <Box flexDirection="column" marginBottom={1}>
399 <Text dimColor={f.mark === 'ignore'} strikethrough={f.mark === 'ignore'}>
400 {f.text}
401 </Text>
402 {showEvidence && f.evidence ? <Text dimColor>{f.evidence}</Text> : null}
403 <Box columnGap={1}>
404 <Button
405 key={`tk:${f.id}`}
406 label="ticket"
407 variant={f.mark === 'ticket' ? 'primary' : 'secondary'}
408 onPress={() => mark($, f.id, 'ticket')}
409 />
410 <Button
411 key={`ig:${f.id}`}
412 label="ignore"
413 variant={f.mark === 'ignore' ? 'primary' : 'secondary'}
414 onPress={() => mark($, f.id, 'ignore')}
415 />
416 </Box>
417 </Box>,
418 ),
419 )}
420 </Box>
421 ))}
422 {fs.length === 0 ? null : ticketed + ignored === 0 ? (
423 <Text dimColor>Mark findings ticket or ignore. Unmarked ones stay parked.</Text>
424 ) : (
425 <Box>
426 <Button
427 key="apply"
428 label={[ticketed > 0 && `Ticket ${ticketed}`, ignored > 0 && `drop ${ignored}`].filter(Boolean).join(', ')}
429 variant="primary"
430 onPress={apply}
431 />
432 <Text dimColor> {ticketed > 0 ? 'sends the ticket ones back as proposals, evidence included' : 'removes them from the board'}</Text>
433 </Box>
434 )}
435 </Box>
436 )
437 })
438}
439types/index.d.ts 39 lines1/** A question the head raised for the person, and their answer once given. */
2export type Question = {
3 /** Stable for the session: Q1, Q2... never reused. */
4 id: number
5 /** The ticket or worker tab it is about, when the head named one. */
6 about?: string
7 text: string
8 /** Answers to offer as buttons; the person may also type their own. */
9 options?: string[]
10 answer?: string
11 /** Whether the answer has reached Claude. */
12 isSent: boolean
13}
14
15/** An out-of-scope finding parked for triage when the herd is done. */
16export type Finding = {
17 id: number
18 repo: string
19 text: string
20 evidence?: string
21 /** What the person chose: ticket it, or ignore it. Unmarked findings stay parked. */
22 mark?: 'ticket' | 'ignore'
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'herd-mode': {
28 /** Whether herd mode is on: /work and /finish run through the herd skill. */
29 isOn: boolean
30 questions: Shaped<Question[]>
31 findings: Shaped<Finding[]>
32 /** Whether the pane shows each parked finding's evidence. */
33 isEvidenceShown: boolean
34 /** The last id handed out, so a closed question's id is never reused. */
35 lastId: Shaped<{ question: number; finding: number }>
36 }
37 }
38}
39