SLOPSHOPPER

questions

Question queue: Claude posts a question with a default, Hunter answers it in the sidebar, and the answer reaches Claude as context

newguardtoastprompttoolagents
★ 1v0.1.0no licenseupdated 2026-10-10hunterbrewer04/claude-toolkit/plugins/questions
A shopper browsing a rack in a slop shop
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 288 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Question, QuestionAgent, SidebarRow, SidebarSection } from '../types'
5
6const TOOL = 'mcp__questions__ask'
7const SECTION = { plugin: 'questions', key: 'section' } as const
8const INPUT_PREFIX = 'questions:q'
9const MAX_AGENTS = 30
10const RECENT = 5
11const KEEP_ANSWERED = 20
12const CLIP = 60
13
14const items = atom({ plugin: 'questions', key: 'items' } as const, [])
15const agents = atom({ plugin: 'questions', key: 'agents' } as const, [])
16const attention = atom({ plugin: 'questions', key: 'attention' } as const, 0)
17
18const GUIDE = [
19  'Use mcp__questions__ask for a decision that has a sensible default and should not stop the work.',
20  'It takes `question` (one line) and `default`, the choice you go with until the person answers.',
21  'Use AskUserQuestion instead when the work cannot sensibly continue without the answer.',
22  'When an answer arrives as context, redo anything built on the default if the answer differs.',
23].join(' ')
24
25const DESCRIPTION = [
26  'Posts a question for the person to answer from the sidebar without stopping your work.',
27  '`question` is what to ask, in one line.',
28  '`default` is the choice you go with until they answer; their answer arrives as context when they send it.',
29].join(' ')
30
31const INPUT_SCHEMA = {
32  type: 'object',
33  properties: {
34    question: { type: 'string', description: 'What to ask, in one line.' },
35    default: {
36      type: 'string',
37      description: 'The choice you go with until the person answers.',
38    },
39  },
40  required: ['question', 'default'],
41}
42
43type Request = { question: string; default: string }
44
45export const register: Register = on => {
46  on('session.start', async ($, e, next) => {
47    await $.tool.register({
48      name: 'ask',
49      description: DESCRIPTION,
50      inputSchema: INPUT_SCHEMA,
51      isDeferred: false,
52    })
53    await publish($)
54
55    return next(e)
56  })
57
58  on('prompt.compose', async ($, e, next) => {
59    const composed = await next(e)
60    if (!e.tools.includes(TOOL)) return composed
61
62    return {
63      sections: [...composed.sections, { id: 'questions:guide', text: GUIDE, scope: 'session' }],
64    }
65  })
66
67  on('tool.call', async ($, e, next) => {
68    // Only the engine's own call reaches the model; another plugin's $.tool.call would take the answer and drop it.
69    const forModel = next.origin.plugin === 'engine'
70    const ran = await next(e)
71    if (!forModel || e.agentId !== undefined || ran.deny !== undefined) return ran
72
73    const text = await takeUndelivered($)
74    if (text === undefined) return ran
75
76    return { ...ran, context: [...(ran.context ?? []), text] }
77  }).catch(($, e, next) => next(e))
78
79  on('tool.call', { tool: TOOL }, async ($, e) => {
80    const request = parse(e as unknown as Record<string, unknown>)
81    if (typeof request === 'string') return { deny: request }
82
83    const current = await read($, items)
84    const n = current.reduce((most, one) => Math.max(most, one.n), 0) + 1
85    const askedBy = e.agentId === undefined ? undefined : await askerOf($, e.agentId)
86    const question: Question = {
87      id: `q${n}`,
88      n,
89      question: request.question,
90      default: request.default,
91      ...(askedBy === undefined ? {} : { askedBy }),
92      status: 'open',
93      delivered: false,
94      askedAt: Date.now(),
95    }
96    await update($, items, all => [...all, question])
97    await update($, attention, count => count + 1)
98    await publish($)
99    $.ui.toast(`New question: ${clip(question.question, CLIP)}`)
100
101    return {
102      result: `Posted as Q${n}. Going with: ${question.default}. If the person answers, the answer arrives as context; revisit anything built on the default then.`,
103    }
104  }).catch(() => ({ deny: 'The question could not be posted.' }))
105
106  on('prompt.submit', async ($, e, next) => {
107    const fresh = await undelivered($)
108    if (fresh.length === 0) return next(e)
109
110    const result = await next({ ...e, context: [...(e.context ?? []), render(fresh)] })
111    if (result.drop === undefined) await markDelivered($, fresh)
112
113    return result
114  }).catch(($, e, next) => next(e))
115
116  on('ui.input', { plugin: 'sidebar' }, async ($, e, next) => {
117    if (e.kind !== 'submit' || !e.element.startsWith(INPUT_PREFIX)) return next(e)
118    const text = e.value.trim()
119    if (text === '') return next(e)
120
121    const id = e.element.slice('questions:'.length)
122    const at = Date.now()
123    await update($, items, all =>
124      prune(
125        all.map(one =>
126          one.id === id
127            ? { ...one, status: 'answered' as const, answer: text, answeredAt: at, delivered: false }
128            : one,
129        ),
130      ),
131    )
132    await publish($)
133
134    return next(e)
135  }).catch(($, e, next) => next(e))
136
137  on('agent.spawn', async ($, e, next) => {
138    const spawned = await next(e)
139    const id = spawned.agentId
140    if (spawned.deny === undefined && id !== undefined) {
141      const run: QuestionAgent = { id, description: e.description }
142      await update($, agents, runs => [...runs.filter(one => one.id !== id), run].slice(-MAX_AGENTS))
143    }
144
145    return spawned
146  }).catch(($, e, next) => next(e))
147
148  on('turn.complete', async ($, e, next) => {
149    if (e.agentId === undefined) {
150      const open = (await read($, items)).filter(one => one.status === 'open').length
151      if (open > 0) $.ui.toast(`${open} ${open === 1 ? 'question' : 'questions'} still open`)
152    }
153
154    return next(e)
155  }).catch(($, e, next) => next(e))
156
157  on('session.end', async ($, e, next) => {
158    if (e.reason === 'clear') await reset($)
159
160    return next(e)
161  })
162}
163
164function parse(input: Record<string, unknown>): Request | string {
165  const { question, default: fallback } = input
166  if (typeof question !== 'string' || question.trim() === '') {
167    return '"ask" needs `question`: a non-empty string.'
168  }
169  if (typeof fallback !== 'string' || fallback.trim() === '') {
170    return '"ask" needs `default`: the choice you go with, a non-empty string.'
171  }
172
173  return { question: question.trim(), default: fallback.trim() }
174}
175
176async function askerOf($: EngineInterface, agentId: string): Promise<string> {
177  const runs = await read($, agents)
178
179  return runs.find(run => run.id === agentId)?.description ?? 'a subagent'
180}
181
182// Picks and marks in one update, so parallel calls cannot both take the same answer.
183async function takeUndelivered($: EngineInterface): Promise<string | undefined> {
184  let fresh: Question[] = []
185  await update($, items, all => {
186    fresh = all.filter(one => one.status === 'answered' && !one.delivered)
187    const ids = new Set(fresh.map(one => one.id))
188    return all.map(one => (ids.has(one.id) ? { ...one, delivered: true } : one))
189  })
190
191  return fresh.length === 0 ? undefined : render(fresh)
192}
193
194async function undelivered($: EngineInterface): Promise<Question[]> {
195  return (await read($, items)).filter(one => one.status === 'answered' && !one.delivered)
196}
197
198async function markDelivered($: EngineInterface, fresh: readonly Question[]): Promise<void> {
199  const ids = new Set(fresh.map(one => one.id))
200  await update($, items, all => all.map(one => (ids.has(one.id) ? { ...one, delivered: true } : one)))
201}
202
203function render(fresh: readonly Question[]): string {
204  return fresh
205    .map(
206      one =>
207        `The person answered Q${one.n} "${one.question}" (you went with "${one.default}"): ${one.answer ?? ''}. Redo anything built on the default if the answer differs.`,
208    )
209    .join('\n')
210}
211
212// Keeps at most KEEP_ANSWERED answered questions, dropping the oldest. The newest question is never
213// dropped, so the next question number cannot repeat one the model has already seen.
214function prune(all: readonly Question[]): Question[] {
215  const answered = all.filter(one => one.status === 'answered')
216  const excess = answered.length - KEEP_ANSWERED
217  if (excess <= 0) return [...all]
218
219  const newest = all.reduce((most, one) => Math.max(most, one.n), 0)
220  const drop = new Set(
221    answered
222      .filter(one => one.n !== newest)
223      .sort((a, b) => (a.answeredAt ?? 0) - (b.answeredAt ?? 0))
224      .slice(0, excess)
225      .map(one => one.id),
226  )
227
228  return all.filter(one => !drop.has(one.id))
229}
230
231async function publish($: EngineInterface): Promise<void> {
232  const all = await read($, items)
233  const count = await read($, attention)
234  await $.state.set(SECTION, build(all, count))
235}
236
237async function reset($: EngineInterface): Promise<void> {
238  await update($, items, () => [])
239  await update($, agents, () => [])
240  await publish($)
241}
242
243function build(all: readonly Question[], count: number): SidebarSection {
244  const open = all.filter(one => one.status === 'open')
245  const answered = all
246    .filter(one => one.status === 'answered')
247    .sort((a, b) => (b.answeredAt ?? 0) - (a.answeredAt ?? 0))
248    .slice(0, RECENT)
249
250  const rows: SidebarRow[] = []
251  for (const one of open) {
252    rows.push({
253      kind: 'item',
254      icon: '?',
255      tone: 'accent',
256      number: one.n,
257      text: one.question,
258      ...(one.askedBy === undefined ? {} : { sub: { text: `asked by ${one.askedBy}` } }),
259    })
260    rows.push({ kind: 'input', key: `questions:${one.id}`, placeholder: 'answer', hint: `going with: ${one.default}` })
261  }
262  if (answered.length > 0) {
263    rows.push({ kind: 'divider', label: 'Answered' })
264    for (const one of answered) {
265      rows.push({
266        kind: 'item',
267        icon: '✓',
268        tone: 'done',
269        state: 'done',
270        text: `Q${one.n} → ${one.answer ?? ''}`,
271      })
272    }
273  }
274
275  return {
276    version: 1,
277    title: 'Questions',
278    badge: `${open.length} open`,
279    isEmpty: all.length === 0,
280    attention: count,
281    rows,
282  }
283}
284
285function clip(text: string, max: number): string {
286  return text.length > max ? `${text.slice(0, max - 1)}…` : text
287}
288
types/index.d.ts 55 lines
1export type SidebarTone = 'accent' | 'done' | 'muted' | 'error' | 'warn' | 'text'
2
3export type SidebarRow =
4  | { kind: 'progress'; fraction: number; label?: string }
5  | { kind: 'item'; icon: string; tone?: SidebarTone; number?: number; text: string;
6      state?: 'normal' | 'active' | 'done';
7      sub?: { icon?: string; tone?: SidebarTone; text: string };
8      action?: { key: string; label: string } }
9  | { kind: 'input'; key: string; placeholder?: string; hint?: string }
10  | { kind: 'button'; key: string; label: string; primary?: boolean }
11  | { kind: 'text'; text: string; tone?: SidebarTone; bold?: boolean }
12  | { kind: 'divider'; label: string }
13
14export type SidebarSection = {
15  version: 1
16  title: string
17  badge?: string
18  tone?: SidebarTone
19  isEmpty: boolean
20  attention: number
21  rows: SidebarRow[]
22}
23
24export type Question = {
25  /** `q<n>`: the key suffix the sidebar's Input is drawn with. */
26  id: string
27  n: number
28  question: string
29  default: string
30  /** The subagent's description when the question came from a subagent. */
31  askedBy?: string
32  status: 'open' | 'answered'
33  answer?: string
34  /** When the answer was submitted; orders the Answered list. */
35  answeredAt?: number
36  delivered: boolean
37  askedAt: number
38}
39
40export type QuestionAgent = {
41  id: string
42  description: string
43}
44
45declare module 'claude-code' {
46  interface PluginState {
47    questions: {
48      items: Question[]
49      agents: QuestionAgent[]
50      attention: number
51      section: SidebarSection
52    }
53  }
54}
55