SLOPSHOPPER

herd-mode

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…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · herd-mode
│ ┃ herd-mode ✕ › fix the failing auth test and add an audit log call │ ┃ Waiting on you (0) │ ┃ Nothing to answer. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Parked (0) ⎿ Added 2 lines, removed 1 line │ ┃ Nothing parked. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /herd │ ⎿ herd-mode: Herd mode is off. Use /herd on, /herd off, or /herd < │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · herd-mode
Waiting on you (0) Nothing to answer. Parked (0) Nothing parked.
README

Personal Dotfiles

A cross-platform dotfiles configuration for macOS, Windows (WSL2), and Linux. This configuration emphasizes modern tooling and AI-assisted developer productivity.

🤖 AI Development Features

  • Claude Code: Global config, skills, agents and rules in packages/claude/.claude/, symlinked into ~/.claude

📖 See CLAUDE.md for detailed Claude Code integration setup

Features

  • Cross-Platform Support: Works on macOS, Windows (WSL2), and Linux
  • Modern CLI Tools: Replaces traditional tools with faster, feature-rich alternatives
  • Theming: Catppuccin Mocha in Neovim, tmux and Starship; voltaic-dark in Ghostty and k9s
  • Editor: Minimal single-file Neovim configuration
  • Git Workflow: Signed commits, linear-history defaults and worktree tooling
  • Shell Enhancement: Zsh with modern completions and smart directory navigation

Quick Start

Prerequisites

  • OS: macOS, Ubuntu/Debian, or Windows 10+ with WSL2
  • Git: For cloning the repository
  • Stow: Installed by the bootstrap (Homebrew on macOS, apt on Debian)
  • macOS: Xcode Command Line Tools (xcode-select --install)
  • Debian/Ubuntu: sudo for the apt step; without it the bootstrap prints the packages to install manually

Installation

  1. Clone the repository:
   git clone https://github.com/barrydobson/dotfiles-public.git ~/dotfiles
   cd ~/dotfiles
  1. Bootstrap (detects the OS, installs prerequisites, stows configs, then installs tools):
   ./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
  1. Reload the shell:
   exec zsh

Key Tools and Replacements

TraditionalModern AlternativePurpose
ls, ll, la, treeezaDirectory listing with icons and git status
findfdFast file finder; backs fzf
grepripgrep (rg, rga)Fast text search - grep itself is not aliased
cdzoxideSmart directory navigation (--cmd cd, so cd is zoxide)
psprocsProcess viewer
rmtrashRecoverable deletes when trash is installed
Ctrl-RatuinSearchable, synced shell history

Configuration Highlights

Shell (Zsh)

  • Plugin Manager: Zinit for fast plugin loading
  • Prompt: Starship, configured in packages/starship/
  • Completions: Modern tab completion system
  • Aliases: Shortcuts for modern CLI tools

Terminal

Ghostty is the only terminal with a config here (packages/ghostty/):

  • Font: JetBrainsMono Nerd Font (consistent across platforms)
  • Theme: voltaic-dark / voltaic-light, following the system appearance
  • Splits: super+d right, super+shift+d down, ctrl+hjkl to navigate
  • tmux friendly: cmd+a sends the Ctrl-A prefix, and Shift+Enter is fixed up

Text Editors

Neovim

A 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.

Git Configuration

  • Modular Config: config includes .gitalias and a gitignored .gitconfig-local
  • Signed Commits: SSH-format signing, 1Password agent supplies the key
  • Linear History: pull.rebase, rebase.autosquash, fetch.prune, push.autoSetupRemote
  • Short Aliases: single-letter aliases (a, b, c, d, ...) plus git cleanup

Development Workflow

Daily Commands

# 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)

Git Integration

  • wt (worktrunk) manages worktrees; its shell hook is wired up in .zshrc
  • Single-letter aliases plus git cleanup to prune merged branches
  • .gitconfig-local for machine-specific settings (gitignored)

Shell Productivity

  • cd <partial-path> - zoxide-backed jumping (zoxide takes over cd)
  • ll, la, tree - eza listings
  • Ctrl-R - atuin history search; Ctrl-T file search and Alt-C directory jump via fzf
  • ea, ez, ev - edit the alias, zsh and Neovim configs
  • mnt - autoloaded function for mounting external drives; extract and +x in ~/.local/bin

Key Bindings and Shortcuts

  • 📖 Neovim Keymap Reference - keymaps and plugins
  • Ghostty: split and tab bindings in packages/ghostty/.config/ghostty/config
  • tmux: Ctrl-A prefix, | and - to split, r to reload

Platform-Specific Notes

macOS

  • Homebrew manages CLI tools, casks and fonts via ~/.Brewfile
  • The 1Password SSH agent socket is linked into ~/.1password/agent.sock
  • Extra packages stowed only here: 1Password, claude, ghostty, k9s, lazygit, ssh, vscode, zed, and the Claude helpers (see MAC_PACKAGES in install.sh)

Linux

  • apt installs the minimum (curl, fzf, git, gnupg, stow, zsh); everything else comes from mise
  • Starship and the 1Password CLI are installed by install.sh after stowing, into ~/.local/bin where relevant
  • No fonts are installed - install JetBrainsMono Nerd Font yourself if the terminal needs it

Windows

  • Works via WSL2 with the Debian/Ubuntu path
  • No Windows Terminal configuration in this repo

Customization

Adding New Tools

  1. Create packages/<tool>/ with the config at its real dotfile path
  2. Add the package name to COMMON_PACKAGES or MAC_PACKAGES in install.sh
  3. Add installation commands to the appropriate install script
  4. Update README with tool information

Theme Customization

Themes are set per application, so changing one means editing each config:

  • Neovim: vim.cmd.colorscheme in packages/nvim/.config/nvim/init.lua
  • tmux: the @catppuccin_* settings in packages/tmux/.config/tmux/tmux.conf
  • Starship: palette in packages/starship/.config/starship/starship.toml
  • Ghostty: theme in packages/ghostty/.config/ghostty/config
  • k9s: ui.skin in packages/k9s/.config/k9s/config.yaml, skins under skins/

Local Overrides

All gitignored:

  • Git: packages/git/.config/git/.gitconfig-local - included by the main config for machine-specific settings
  • Shell: packages/zsh/.config/zsh/conf.d/99_private-environment.zsh - loaded automatically by the conf.d glob
  • Shell: ~/.env - sourced by 10_environment.zsh if present, one export per line

Troubleshooting

Common Issues

Stow 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:

  • Ensure Nerd Fonts are installed
  • Rebuild font cache: fc-cache -f (Linux)
  • Restart terminal application

Shell completions missing:

  • Reload shell: exec zsh
  • Check plugin installation in ${XDG_DATA_HOME:-~/.local/share}/zinit/zinit.git
  • Delete the completion cache and restart: rm "${XDG_CACHE_HOME:-$HOME/.cache}/zsh/zcompdump"

Notes

Contributing

This is a personal dotfiles repository, but suggestions and improvements are welcome:

  1. Fork the repository
  2. Create a feature branch
  3. Test changes across platforms
  4. Submit a pull request

License

This project is licensed under the MIT License - see the LICENSE file for details.

Acknowledgments

  • Catppuccin for the beautiful color scheme
  • GitAlias.com for comprehensive git aliases
  • Zinit for fast zsh plugin management
  • Clank for claude code integration inspiration
  • The open source community for modern CLI tool alternatives
Source 2 files
hooks/register.tsx 439 lines
1import { 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}
439
types/index.d.ts 39 lines
1/** 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