SLOPSHOPPER

checklist

Live plan checklist published as a sidebar section, with the subagents working each step

newguardcommandstatusprompttool
★ 1v0.1.0no licenseupdated 2026-10-10hunterbrewer04/claude-toolkit/plugins/checklist
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · checklist
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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 › /checklist ⎿ checklist: The checklist is empty. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

BrewKit

My personal Claude Code setup, packaged as a plugin marketplace so I can install exactly the pieces a given machine needs.

This is a public snapshot of how I extend Claude Code. It ships 11 plugins covering 20 skills, 1 sub-agent, 4 hooks, 5 mods, and a custom status line.

Most pieces follow my own paths and conventions, so treat them as reference patterns to borrow from rather than drop-in installs.

Install

/plugin marketplace add hunterbrewer04/claude-toolkit
/plugin install school@brewkit

Install only the plugins that machine actually needs. Nothing here assumes the others are present.

Plugins

school

Coursework tooling.

SkillWhat it does
course-setupScaffold a new course folder and clean Canvas file dumps; owns the school.json registry
grade-calcExact grade math from syllabus weights: current grade, what-ifs, target scores
rubric-checkGrade a draft against its assignment spec before submitting
study-guideTurn lecture PDFs into a self-contained interactive HTML study package
notebooklm-course-syncKeep a course's NotebookLM notebook in sync with local files
sapling-ai-detectorScan text for AI-generated content with a per-sentence report

work

Client and project delivery.

SkillWhat it does
linear-assistantCreate, update, and query Linear issues, projects, milestones, and cycles

personal

Personal-life tooling.

SkillWhat it does
apple-calendarRead and write Apple Calendar from a machine that reaches it over the network via the apple-calendar MCP server

misc

Everything cross-cutting.

SkillWhat it does
claude-toolkitAdd, sync, and set up this repo's components across machines
claude-documentationGenerate consistent README docs for skills, hooks, and sub-agents
skill-builderBuild a new skill through a structured, validated process
docxCreate, read, and edit Word documents, including tracked changes and comments
notebooklmFull programmatic NotebookLM API: notebooks, sources, artifacts, downloads
tailnetMove files to tailnet servers, serve files over Tailscale, Taildrop to a phone

Also ships three hooks that apply everywhere: a PreToolUse guard against committing .env files, a SessionStart agent-state tracker, and a Stop desktop notification.

dev-flow

My development workflow chain, split across two sessions with a context clear in between.

SkillWhat it does
specTurn an idea into an approved specification
planBreak an approved spec into waves of file-disjoint tasks
implementExecute the plan across persistent subagent slots in git worktrees
reviewWhole-branch review pass with specialist fan-out
testRun the plan's verification section, then commit and open the PR

Includes the code-reviewer sub-agent used by the review step, and a SessionStart resume hook.

meta-builders

SkillWhat it does
skill-creatorCreate and improve skills, run evals, benchmark performance, grade a SKILL.md against a structural rubric

sidebar

A mod: a docked sidebar for the widget mods. Widgets publish their section and handle their own buttons; the sidebar only draws, so a new widget needs no change here. The layout option picks how: tabs (the default) gives each widget its own pane, shown as tabs titled with the widget's badge (Checklist 93%), opened when the widget has something, focused when it asks for attention, and closed when it empties; stacked draws every section in one pane that opens itself on attention. The order option sets the tab order or the stacking. /sidebar opens it, /sidebar close hides it.

checklist

A mod: a live checklist of the current plan, drawn as a section in the sidebar. Claude posts the steps through its own checklist tool and checks each one off as it finishes, subagents working a step show under it with their status and tool-call count, and the status line carries done/total. /checklist prints the list, /checklist clear empties it.

questions

A mod: questions Claude asks without stopping the work. Each one comes with the default Claude goes with meanwhile and gets an answer field in the sidebar. An answer reaches Claude on its next step, with a note to redo anything built on the default. Questions still open at the end of a turn get a toast.

processes

A mod: the background commands and dev servers Claude started this session, shown in the sidebar with their ports and uptime and a button that kills one. Nothing is ever killed automatically; anything still running when the session ends gets a toast.

review-pair

A mod: a second Claude session that reviews every commit the session you are working in makes. The first commit in a repo asks once (always, not now, never). On always, a Sonnet reviewer opens in a herdr pane beside you (or as a background session outside herdr) and gets each new commit range. Blockers go back to the builder, which fixes them, for up to two rounds; nits and clean reviews go to a Review tab in the sidebar, and nits are posted as one PR comment when the builder opens a PR. The reviewer cannot use the Edit or Write tools and closes when the last builder in that repo ends. /review-pair shows the status; on, off, forget and stop change it.

Status line

statusline/ holds an agnoster-inspired three-row status line: where you are, what you are running, and what you are burning. Point statusLine.command in settings.json at statusline-command.sh.

Machine-local configuration

Nothing machine-specific is committed here. Two files live outside the repo:

FileUsed byNotes
~/.claude/tailnet-servers.jsontailnetServer registry: addresses, SSH aliases, default destinations. See servers.example.json for the schema
~/.claude/settings.jsoneverythingPermissions, env, enabled plugins, status line wiring
Source 2 files
hooks/register.tsx 463 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallInput, ToolCallResult } from 'claude-code'
3
4import type {
5  AgentRun,
6  AgentRunStatus,
7  Checklist,
8  Item,
9  ItemStatus,
10  SidebarRow,
11  SidebarSection,
12  SidebarTone,
13} from '../types'
14
15const TOOL = 'mcp__checklist__checklist'
16const EMPTY: Checklist = { title: '', items: [] }
17const EMPTY_SECTION: SidebarSection = {
18  version: 1,
19  title: 'Checklist',
20  isEmpty: true,
21  attention: 0,
22  rows: [],
23}
24const MAX_AGENTS = 30
25const STATUSES: readonly ItemStatus[] = ['pending', 'in_progress', 'completed']
26
27const MARK: Record<ItemStatus, string> = { pending: '[ ]', in_progress: '[>]', completed: '[x]' }
28const ICON: Record<ItemStatus, string> = { pending: '○', in_progress: '▶', completed: '✔' }
29const ITEM_TONE: Record<ItemStatus, SidebarTone> = {
30  pending: 'muted',
31  in_progress: 'accent',
32  completed: 'done',
33}
34const RUN_ICON: Record<AgentRunStatus, string> = { running: '●', done: '✔', failed: '✗', stopped: '■' }
35const RUN_TONE: Record<AgentRunStatus, SidebarTone> = {
36  running: 'accent',
37  done: 'done',
38  failed: 'error',
39  stopped: 'muted',
40}
41
42const list = atom({ plugin: 'checklist', key: 'list' } as const, EMPTY)
43const agents = atom({ plugin: 'checklist', key: 'agents' } as const, [])
44const section = atom({ plugin: 'checklist', key: 'section' } as const, EMPTY_SECTION)
45
46const GUIDE = [
47  'The person watches a live checklist in the sidebar, fed by the mcp__checklist__checklist tool.',
48  'When the person hands you a plan, or a task will take three or more distinct steps, call it with action "set" and one item per step before you start.',
49  'Mark a step in_progress when you start it and completed as soon as it is done, as you go, never in one batch at the end.',
50  "When you hand a step to a subagent, set that step's agent to the exact description you pass to the Agent tool, and complete the step once its result is in.",
51  'Skip the checklist for questions and one-step tasks.',
52].join(' ')
53
54const DESCRIPTION = [
55  'Shows the person a live checklist of the current plan in the sidebar.',
56  '"set" replaces the list with `items` (one short line per step, in order) and an optional `title`.',
57  '"add" appends `items`.',
58  '"update" changes steps by their 1-based number in `updates`: `status` (pending, in_progress, completed) and `agent`, the exact description given to the Agent tool for the subagent working the step ("" unlinks it).',
59  '"clear" empties the list. Subagents may only use "update".',
60].join(' ')
61
62const INPUT_SCHEMA = {
63  type: 'object',
64  properties: {
65    action: { type: 'string', enum: ['set', 'add', 'update', 'clear'] },
66    title: { type: 'string', description: 'A short name for the plan, shown at the top of its sidebar section.' },
67    items: {
68      type: 'array',
69      items: { type: 'string' },
70      description: 'For set and add: one short line per step, in order.',
71    },
72    updates: {
73      type: 'array',
74      description: 'For update: the steps to change, by their 1-based number.',
75      items: {
76        type: 'object',
77        properties: {
78          item: { type: 'integer', minimum: 1 },
79          status: { type: 'string', enum: STATUSES },
80          agent: { type: 'string' },
81        },
82        required: ['item'],
83      },
84    },
85  },
86  required: ['action'],
87}
88
89type Update = { item: number; status?: ItemStatus; agent?: string }
90
91type Request =
92  | { action: 'set' | 'add'; title?: string; items: string[] }
93  | { action: 'update'; title?: string; updates: Update[] }
94  | { action: 'clear' }
95
96export const register: Register = on => {
97  on('session.start', async ($, e, next) => {
98    await $.tool.register({
99      name: 'checklist',
100      description: DESCRIPTION,
101      inputSchema: INPUT_SCHEMA,
102      isDeferred: false,
103    })
104    showStatus($, await read($, list))
105    await $.command.register({
106      name: 'checklist',
107      description: 'Print the plan checklist, or clear it',
108      argumentHint: '[clear]',
109    })
110    await publish($)
111
112    return next(e)
113  })
114
115  on('command.run', { command: 'checklist' }, async ($, e) => {
116    if (e.args.trim() === 'clear') {
117      await reset($)
118      return { text: 'Checklist cleared.' }
119    }
120
121    return { text: summarize(await read($, list)) }
122  })
123
124  on('prompt.compose', async ($, e, next) => {
125    const composed = await next(e)
126    if (!e.tools.includes(TOOL)) return composed
127
128    return {
129      sections: [...composed.sections, { id: 'checklist:guide', text: GUIDE, scope: 'session' }],
130    }
131  })
132
133  on('tool.call', { tool: TOOL }, async ($, e) => {
134    const request = parse(e as unknown as Record<string, unknown>)
135    if (typeof request === 'string') return { deny: request }
136    if (e.agentId !== undefined && request.action !== 'update') {
137      return { deny: 'Only the main session sets the checklist; a subagent may only update steps.' }
138    }
139
140    let error: string | undefined
141    const current = await change(
142      $,
143      value => {
144        const applied = apply(value, request)
145        if (typeof applied === 'string') {
146          error = applied
147          return value
148        }
149        return applied
150      },
151      request.action === 'set',
152    )
153    if (error !== undefined) return { deny: error }
154    if (request.action === 'set') {
155      await update($, agents, runs => runs.filter(run => run.status === 'running'))
156      await publish($)
157    }
158
159    return { result: summarize(current) }
160  }).catch(() => ({ deny: 'The checklist could not be updated.' }))
161
162  on('tool.call', async ($, e, next) => {
163    if (e.agentId !== undefined) await countTool($, e.agentId)
164    const ran = await next(e)
165    if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
166      await mirror($, e, ran)
167    }
168
169    return ran
170  }).catch(($, e, next) => next(e))
171
172  on('agent.spawn', async ($, e, next) => {
173    const spawned = await next(e)
174    const id = spawned.agentId
175    if (spawned.deny === undefined && id !== undefined) {
176      const run: AgentRun = {
177        id,
178        description: e.description,
179        type: e.subagentType,
180        status: 'running',
181        tools: 0,
182      }
183      await update($, agents, runs => [...runs.filter(one => one.id !== id), run].slice(-MAX_AGENTS))
184      await publish($)
185    }
186
187    return spawned
188  }).catch(($, e, next) => next(e))
189
190  on('turn.complete', async ($, e, next) => {
191    const id = e.agentId
192    if (id !== undefined) {
193      const status: AgentRunStatus =
194        e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
195      const runs = await read($, agents)
196      if (runs.some(run => run.id === id)) {
197        await update($, agents, all => all.map(run => (run.id === id ? { ...run, status } : run)))
198        await publish($)
199      }
200    }
201
202    return next(e)
203  })
204
205  on('session.end', async ($, e, next) => {
206    if (e.reason === 'clear') await reset($)
207
208    return next(e)
209  })
210}
211
212function parse(input: Record<string, unknown>): Request | string {
213  const { action, title } = input
214  if (title !== undefined && typeof title !== 'string') return '`title` must be a string.'
215  const named = title === undefined ? {} : { title }
216
217  if (action === 'clear') return { action }
218  if (action === 'set' || action === 'add') {
219    const { items } = input
220    if (!Array.isArray(items) || items.length === 0 || !items.every(item => typeof item === 'string')) {
221      return `"${action}" needs \`items\`: a non-empty list of strings.`
222    }
223    return { action, ...named, items: items.map(item => item.trim()) }
224  }
225  if (action === 'update') {
226    const { updates } = input
227    if (!Array.isArray(updates) || updates.length === 0) {
228      return '"update" needs `updates`: a non-empty list of { item, status?, agent? }.'
229    }
230    const parsed: Update[] = []
231    for (const raw of updates) {
232      if (typeof raw !== 'object' || raw === null) return 'Each update must be an object.'
233      const { item, status, agent } = raw as Record<string, unknown>
234      if (typeof item !== 'number' || !Number.isInteger(item) || item < 1) {
235        return 'Each update needs `item`: the 1-based step number.'
236      }
237      if (status !== undefined && !STATUSES.includes(status as ItemStatus)) {
238        return '`status` must be pending, in_progress or completed.'
239      }
240      if (agent !== undefined && typeof agent !== 'string') return '`agent` must be a string.'
241      parsed.push({
242        item,
243        ...(status === undefined ? {} : { status: status as ItemStatus }),
244        ...(agent === undefined ? {} : { agent }),
245      })
246    }
247    return { action, ...named, updates: parsed }
248  }
249
250  return '`action` must be set, add, update or clear.'
251}
252
253function apply(current: Checklist, request: Request): Checklist | string {
254  if (request.action === 'clear') return EMPTY
255  if (request.action === 'update') return applyUpdates(current, request.updates, request.title)
256  if (request.action === 'set') {
257    return {
258      title: request.title ?? '',
259      items: request.items.map((text, index) => ({ id: `c${index + 1}`, text, status: 'pending' })),
260    }
261  }
262
263  const ids = new Set(current.items.map(item => item.id))
264  let n = current.items.length + 1
265  const added: Item[] = request.items.map(text => {
266    while (ids.has(`c${n}`)) n += 1
267    ids.add(`c${n}`)
268    return { id: `c${n}`, text, status: 'pending' }
269  })
270
271  return { title: request.title ?? current.title, items: [...current.items, ...added] }
272}
273
274function applyUpdates(current: Checklist, updates: Update[], title?: string): Checklist | string {
275  const missing = updates.find(one => one.item > current.items.length)
276  if (missing !== undefined) {
277    return `There is no step ${missing.item}; the list has ${current.items.length}.`
278  }
279  const items = current.items.map((item, index) => {
280    let changed = item
281    for (const one of updates) {
282      if (one.item !== index + 1) continue
283      if (one.status !== undefined) changed = { ...changed, status: one.status }
284      if (one.agent !== undefined) {
285        const { agent: _unlinked, ...rest } = changed
286        changed = one.agent === '' ? rest : { ...rest, agent: one.agent }
287      }
288    }
289    return changed
290  })
291
292  return { title: title ?? current.title, items }
293}
294
295async function change(
296  $: EngineInterface,
297  edit: (current: Checklist) => Checklist,
298  isNewPlan = false,
299): Promise<Checklist> {
300  let wasEmpty = false
301  const current = await update($, list, value => {
302    wasEmpty = value.items.length === 0
303    return edit(value)
304  })
305  showStatus($, current)
306  await publish($, current.items.length > 0 && (wasEmpty || isNewPlan))
307
308  return current
309}
310
311async function reset($: EngineInterface): Promise<void> {
312  await update($, list, () => EMPTY)
313  await update($, agents, () => [])
314  showStatus($, EMPTY)
315  await publish($)
316}
317
318async function publish($: EngineInterface, bump = false): Promise<void> {
319  const current = await read($, list)
320  const runs = await read($, agents)
321  await update($, section, previous => build(current, runs, previous.attention + (bump ? 1 : 0)))
322}
323
324function build(current: Checklist, runs: AgentRun[], attention: number): SidebarSection {
325  const total = current.items.length
326  const done = current.items.filter(item => item.status === 'completed').length
327  const active = current.items.filter(item => item.status === 'in_progress').length
328  const isFinished = total > 0 && done === total
329  const linked = new Set(current.items.flatMap(item => (item.agent ? [item.agent] : [])))
330  const others = runs.filter(run => !linked.has(run.description))
331  const rows: SidebarRow[] = []
332
333  if (total > 0) rows.push({ kind: 'progress', fraction: done / total, label: `${done} of ${total}` })
334  current.items.forEach((item, index) => {
335    const run = item.agent ? runs.filter(one => one.description === item.agent).pop() : undefined
336    rows.push({
337      kind: 'item',
338      icon: ICON[item.status],
339      tone: ITEM_TONE[item.status],
340      number: index + 1,
341      text: item.text,
342      state: item.status === 'completed' ? 'done' : item.status === 'in_progress' ? 'active' : 'normal',
343      ...(item.agent ? { sub: subOf(item.agent, run) } : {}),
344    })
345  })
346  if (others.length > 0) {
347    rows.push({ kind: 'divider', label: 'Subagents' })
348    for (const run of others) {
349      rows.push({
350        kind: 'item',
351        icon: RUN_ICON[run.status],
352        tone: RUN_TONE[run.status],
353        text: `${run.description}  ${describeRun(run)}`,
354        state: run.status === 'running' ? 'normal' : 'done',
355      })
356    }
357  }
358  if (total > 0) {
359    rows.push(
360      isFinished
361        ? { kind: 'text', text: `✔ All ${total} steps done`, tone: 'done', bold: true }
362        : { kind: 'text', text: `${done} done · ${active} active · ${total - done - active} to go` },
363    )
364  }
365
366  return {
367    version: 1,
368    title: current.title || 'Checklist',
369    ...(total > 0 ? { badge: `${Math.round((done / total) * 100)}%` } : {}),
370    tone: isFinished ? 'done' : 'accent',
371    isEmpty: total === 0 && runs.length === 0,
372    attention,
373    rows,
374  }
375}
376
377function subOf(agent: string, run: AgentRun | undefined): { icon: string; tone: SidebarTone; text: string } {
378  if (run === undefined) return { icon: '○', tone: 'muted', text: `${agent} · waiting to start` }
379
380  return { icon: RUN_ICON[run.status], tone: RUN_TONE[run.status], text: describeRun(run) }
381}
382
383async function countTool($: EngineInterface, id: string): Promise<void> {
384  const runs = await read($, agents)
385  if (!runs.some(run => run.id === id)) return
386  await update($, agents, all =>
387    all.map(run =>
388      run.id === id ? { ...run, status: 'running' as const, tools: run.tools + 1 } : run,
389    ),
390  )
391  await publish($)
392}
393
394async function mirror($: EngineInterface, e: ToolCallInput, ran: ToolCallResult): Promise<void> {
395  if (e.tool === 'TodoWrite') {
396    const items: Item[] = e.todos.map((todo, index) => ({
397      id: `w${index + 1}`,
398      text: todo.content,
399      status: todo.status,
400    }))
401    await change($, current => ({ title: current.title, items }))
402  } else if (e.tool === 'TaskCreate') {
403    const id = (ran.result as { task?: { id?: unknown } } | undefined)?.task?.id
404    if (typeof id !== 'string') return
405    const item: Item = { id: `t${id}`, text: e.subject, status: 'pending' }
406    await change($, current => ({ ...current, items: [...current.items, item] }))
407  } else if (e.tool === 'TaskUpdate') {
408    if ((ran.result as { success?: unknown } | undefined)?.success !== true) return
409    const id = `t${e.taskId}`
410    const { status, subject } = e
411    await change($, current => ({
412      ...current,
413      items:
414        status === 'deleted'
415          ? current.items.filter(item => item.id !== id)
416          : current.items.map(item =>
417              item.id === id
418                ? {
419                    ...item,
420                    ...(subject === undefined ? {} : { text: subject }),
421                    ...(status === undefined ? {} : { status }),
422                  }
423                : item,
424            ),
425    }))
426  }
427}
428
429function showStatus($: EngineInterface, current: Checklist): void {
430  const total = current.items.length
431  if (total === 0) {
432    $.ui.status(undefined)
433    return
434  }
435  const done = current.items.filter(item => item.status === 'completed').length
436  const active = current.items.find(item => item.status === 'in_progress')
437  if (done === total) {
438    $.ui.status(`checklist ${done}/${total} done`)
439  } else {
440    $.ui.status(`checklist ${done}/${total}${active ? `: ${clip(active.text, 40)}` : ''}`)
441  }
442}
443
444function summarize(current: Checklist): string {
445  if (current.items.length === 0) return 'The checklist is empty.'
446  const done = current.items.filter(item => item.status === 'completed').length
447  const head = `${current.title || 'Checklist'}: ${done}/${current.items.length} done`
448  const lines = current.items.map(
449    (item, index) =>
450      `${index + 1}. ${MARK[item.status]} ${item.text}${item.agent ? ` (agent: ${item.agent})` : ''}`,
451  )
452
453  return [head, ...lines].join('\n')
454}
455
456function describeRun(run: AgentRun): string {
457  return `${run.type} · ${run.status} · ${run.tools} tool call${run.tools === 1 ? '' : 's'}`
458}
459
460function clip(text: string, max: number): string {
461  return text.length > max ? `${text.slice(0, max - 1)}…` : text
462}
463
types/index.d.ts 54 lines
1export type ItemStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Item = {
4  id: string
5  text: string
6  status: ItemStatus
7  /** The description given to the Agent tool for the subagent working this step. */
8  agent?: string
9}
10
11export type Checklist = {
12  title: string
13  items: Item[]
14}
15
16export type AgentRunStatus = 'running' | 'done' | 'failed' | 'stopped'
17
18export type AgentRun = {
19  id: string
20  description: string
21  type: string
22  status: AgentRunStatus
23  tools: number
24}
25
26export type SidebarTone = 'accent' | 'done' | 'muted' | 'error' | 'warn' | 'text'
27
28export type SidebarRow =
29  | { kind: 'progress'; fraction: number; label?: string }
30  | { kind: 'item'; icon: string; tone?: SidebarTone; number?: number; text: string;
31      state?: 'normal' | 'active' | 'done';
32      sub?: { icon?: string; tone?: SidebarTone; text: string };
33      action?: { key: string; label: string } }
34  | { kind: 'input'; key: string; placeholder?: string; hint?: string }
35  | { kind: 'button'; key: string; label: string; primary?: boolean }
36  | { kind: 'text'; text: string; tone?: SidebarTone; bold?: boolean }
37  | { kind: 'divider'; label: string }
38
39export type SidebarSection = {
40  version: 1
41  title: string
42  badge?: string
43  tone?: SidebarTone
44  isEmpty: boolean
45  attention: number
46  rows: SidebarRow[]
47}
48
49declare module 'claude-code' {
50  interface PluginState {
51    checklist: { list: Checklist; agents: AgentRun[]; section: SidebarSection }
52  }
53}
54