SLOPSHOPPER

checklist

Your checklist: the decisions, approvals and steps that wait on him, ticked off as he works through sub-sessions

newpanebandguardcommandtoast
v0.3.0no licenseupdated 2026-10-25pnshad/claude-code-mods/checklist
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · checklist
│ ┃ Your checklist ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing waits on you. Claude adds items here │ ┃ when a reply leaves you a decision or a ⏺ Read(src/auth.ts) │ ┃ step. ⎿ 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: Checklist opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Your checklist
Nothing waits on you. Claude adds items here when a reply leaves you a decision or a step.
README

claude-mods

Claude Code mods (plugins), as one local marketplace: cc-mods.

ModWhat it does
discuss-firstAdds a "Discuss first" choice to every Q&A (AskUserQuestion) question. Pick it, talk the question through in chat, and Claude re-asks it with the agreed answer as option 1 "(Agreed)". With 4 options already, type discuss under Other.
harness-coachTeaches Claude Code while you work: one small toast every 3 turns (💡, 9 s) with a tip about keys, slash commands, skills or the harness, chosen by what the turn did (shell, edits, subagents, a long turn, a full context). First-time situations get a one-time nudge at once. Every tip is shown once before any repeats, across sessions. /coach = a tip now · list · off / on · reset · every <n> · find <word>.
checklistA checklist band above the prompt for what waits on you: decisions, approvals, answers, steps. Claude adds items with its add tool whenever a reply hands you something, grouped ("Change requests waiting for your approval"), each with an Open button to the sub-session where you work on it; it ticks them when you answer. Click an item to tick it. /checklist = full list · add <text> · clear. One list per session, kept across restarts.

Install on a machine

git clone https://github.com/pnshad/claude-code-mods.git ~/claude-code-mods
claude plugin marketplace add ~/claude-code-mods
claude plugin install discuss-first@cc-mods --scope user
claude plugin install harness-coach@cc-mods --scope user
claude plugin install checklist@cc-mods --scope user

The plugin is read from this folder: after editing, run /reload-plugins in a session.

Test

claude plugin test discuss-first
claude plugin test harness-coach
claude plugin test checklist
Source 3 files
hooks/register.tsx 308 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { ChecklistItem } from '../types'
5import { addItems, grouped, hideDone, sessionUrl, setDone, short, summary, toggle, visible, workPrompt } from './logic'
6import type { NewItem } from './logic'
7
8const PANE = 'checklist'
9// One-time starting list: { sessions?: string[], items: [...] } (or a bare array for any session).
10// Applied once, to the first session it names that has no list yet; the store's 'seeded' flag stops it after.
11const SEED = '~/.claude/checklist/seed.json'
12const items = atom({ plugin: 'checklist', key: 'items' } as const, [])
13const collapsed = atom({ plugin: 'checklist', key: 'collapsed' } as const, false)
14const showHidden = atom({ plugin: 'checklist', key: 'showHidden' } as const, false)
15
16const TOOL_ADD = 'mcp__checklist__add'
17const TOOL_CHECK = 'mcp__checklist__check'
18
19const CONVENTION = [
20  "# The user's checklist (checklist mod)",
21  'The user has a checklist above the prompt for the things that wait on THEM: decisions, approvals, answers and steps only they can do.',
22  'Whenever a reply leaves him such items, add each one with `mcp__checklist__add`: one item per decision, short imperative text',
23  '("Approve the release plan (Q2–Q4)", "Pick the logo: version A or B"), a `group` that names the batch',
24  '("Plans waiting for your approval", "Launch — due Thu 12:00"), and `session` = the local_… id of the sub-session',
25  'where they will work on it, when there is one. Do not add your own work items (those go on your task list).',
26  'When they answer, approve or do an item — here, or a sub-session reports it — tick it with `mcp__checklist__check`.',
27].join('\n')
28
29// Every write goes to the session's state (what the drawing reads) and to the store (survives a restart).
30async function write($: EngineInterface, fn: (list: ChecklistItem[]) => ChecklistItem[]) {
31  const next = await update($, items, list => fn(list ?? []))
32  const sid = await $.session.id()
33  await $.store.set(`items:${sid}`, next)
34  return next
35}
36
37async function openSession($: EngineInterface, session: string) {
38  const r = await $.process.run(['open', sessionUrl(session)])
39  if (r.exitCode !== 0) $.ui.toast(`Could not open the session: ${r.stderr.trim()}`)
40}
41
42// A click on an item's text: its sub-session opens when it has one; else the item goes into this session as a prompt.
43async function work($: EngineInterface, item: ChecklistItem) {
44  if (item.session) return openSession($, item.session)
45  $.ui.toast(`Working on: ${short(item.text, 60)}`)
46  await $.prompt.submit({ text: workPrompt(item), asUser: true })
47}
48
49async function tick($: EngineInterface, id: string) {
50  const now = await $.clock.now()
51  await write($, l => toggle(l, id, now))
52}
53
54function asItems(input: Record<string, unknown>): NewItem[] {
55  const raw = Array.isArray(input.items) ? input.items : [input]
56  return raw
57    .filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null)
58    .map(x => ({
59      text: String(x.text ?? ''),
60      group: typeof x.group === 'string' ? x.group : typeof input.group === 'string' ? input.group : undefined,
61      session: typeof x.session === 'string' ? x.session : undefined,
62      link: typeof x.link === 'string' ? x.link : undefined,
63    }))
64}
65
66type Els = ReturnType<EngineInterface['ui']['resolve']>
67// One row: the box ticks, the text opens the item (its sub-session ↗, or this session ▸).
68function makeRow($: EngineInterface, { Box, Button, Text }: Els) {
69  return (key: string, i: ChecklistItem, width: number) => (
70    <Box key={key} flexDirection="row" alignItems="center" gap="sm" paddingLeft={1}>
71      <Button key={`${key}-box`} plain onPress={() => tick($, i.id)}>
72        <Text color={i.done ? 'success' : 'subtle'}>{i.done ? '☑' : '☐'}</Text>
73      </Button>
74      <Button key={`${key}-text`} plain hover={{ underline: true }} onPress={() => work($, i)}>
75        <Text dimColor={i.done} strikethrough={i.done} italic={i.hidden} wrap="truncate-end">{short(i.text, width - 6)}</Text>
76        <Text color="subtle">{i.session ? '  ↗' : '  ▸'}</Text>
77      </Button>
78    </Box>
79  )
80}
81
82// The quiet text-link keys on the right of a header.
83function makeKeys({ Box, Button, Text }: Els) {
84  return (keys: ({ key: string; label: string; onPress: () => unknown } | false)[]) => {
85    const shown = keys.filter((k): k is { key: string; label: string; onPress: () => unknown } => k !== false)
86    return (
87      <Box flexDirection="row" alignItems="center" gap="xs">
88        {shown.flatMap((k, n) => [
89          n > 0 ? <Text key={`${k.key}-dot`} color="subtle"> · </Text> : null,
90          <Box key={`${k.key}-at`}>
91            <Button key={k.key} plain dimColor hover={{ underline: true }} onPress={() => void k.onPress()}>
92              <Text size="footnote">{k.label}</Text>
93            </Button>
94          </Box>,
95        ])}
96      </Box>
97    )
98  }
99}
100
101export const register: Register = on => {
102  on('session.start', async ($, e, next) => {
103    const sid = await $.session.id()
104    const now = await read($, items)
105    if (now.length === 0) {
106      const kept = await $.store.get(`items:${sid}`)
107      if (Array.isArray(kept)) {
108        if (kept.length > 0) await update($, items, () => kept as ChecklistItem[])
109      } else if (!(await $.store.get('seeded')) && (await $.fs.exists(SEED).catch(() => false))) {
110        try {
111          const seed = JSON.parse(String(await $.fs.read(SEED))) as unknown
112          const obj = (Array.isArray(seed) ? { items: seed } : seed) as { sessions?: unknown; items?: unknown }
113          const forUs = !Array.isArray(obj.sessions) || obj.sessions.map(String).some(s => sid.includes(s) || s.includes(sid))
114          if (Array.isArray(obj.items) && forUs) {
115            const at = await $.clock.now()
116            await write($, l => addItems(l, asItems({ items: obj.items }), at).list)
117            await $.store.set('seeded', true)
118          }
119        } catch {
120          $.ui.toast('Checklist: the seed file could not be read.')
121        }
122      }
123    }
124    await $.command.register({
125      name: 'checklist',
126      description: 'Your checklist: opens the full list; "/checklist add <text>" adds an item; "/checklist clear" drops ticked items',
127      argumentHint: '[add <text> | clear]',
128    })
129    await $.tool.register({
130      name: 'add',
131      description:
132        "Adds items to the user's checklist (decisions, approvals, answers or steps that wait on them, never your own work). An open item with the same text is updated, not duplicated.",
133      isDeferred: false,
134      inputSchema: {
135        type: 'object',
136        properties: {
137          group: { type: 'string', description: 'Batch name shown as a heading, used for every item that names none' },
138          items: {
139            type: 'array',
140            items: {
141              type: 'object',
142              properties: {
143                text: { type: 'string', description: 'Short imperative line, e.g. "Approve the release plan (Q2–Q4)"' },
144                group: { type: 'string' },
145                session: { type: 'string', description: 'local_… id of the sub-session where they work on it' },
146                link: { type: 'string', description: 'https link to the page where he acts' },
147              },
148              required: ['text'],
149            },
150          },
151        },
152        required: ['items'],
153      },
154    })
155    await $.tool.register({
156      name: 'check',
157      description:
158        "Ticks (or with done=false unticks) items on the user's checklist when they have answered, approved or done them. Each ref is an item id or a few words of its text.",
159      isDeferred: false,
160      inputSchema: {
161        type: 'object',
162        properties: {
163          refs: { type: 'array', items: { type: 'string' } },
164          done: { type: 'boolean', description: 'default true' },
165        },
166        required: ['refs'],
167      },
168    })
169    return next(e)
170  })
171
172  on('prompt.compose', async ($, e, next) => {
173    const r = await next(e)
174    const list = await read($, items)
175    const open = list.filter(i => !i.done)
176    const state = open.length === 0 ? 'His checklist has no open item now.' : `Open on his checklist now:\n${summary(open)}`
177    return { ...r, sections: [...r.sections, { id: 'checklist:convention', text: `${CONVENTION}\n${state}`, scope: 'session' as const }] }
178  })
179
180  on('tool.call', { tool: TOOL_ADD }, async ($, e) => {
181    const adds = asItems(e as unknown as Record<string, unknown>)
182    if (adds.length === 0) return { deny: 'Give at least one item with a text.' }
183    const now = await $.clock.now()
184    let added: ChecklistItem[] = []
185    const list = await write($, l => { const r = addItems(l, adds, now); added = r.added; return r.list })
186    if ((await read($, collapsed)) && added.length > 0) await update($, collapsed, () => false)
187    const open = list.filter(i => !i.done).length
188    return { result: `Added ${added.length} (${adds.length - added.length} already there). ${open} open on the checklist.\n${summary(list)}` }
189  })
190
191  on('tool.call', { tool: TOOL_CHECK }, async ($, e) => {
192    const input = e as unknown as { refs?: unknown; done?: unknown }
193    const refs = Array.isArray(input.refs) ? input.refs.map(String) : []
194    if (refs.length === 0) return { deny: 'Give at least one ref (an item id or a few words of its text).' }
195    const done = input.done !== false
196    const now = await $.clock.now()
197    let changed: ChecklistItem[] = []
198    const list = await write($, l => { const r = setDone(l, refs, done, now); changed = r.changed; return r.list })
199    return { result: `${done ? 'Ticked' : 'Unticked'} ${changed.length}: ${changed.map(c => c.text).join('; ') || 'nothing matched'}.\n${summary(list)}` }
200  })
201
202  on('command.run', { command: 'checklist' }, async ($, e) => {
203    const args = e.args.trim()
204    if (/^add\s+/i.test(args)) {
205      const text = args.replace(/^add\s+/i, '')
206      const now = await $.clock.now()
207      await write($, l => addItems(l, [{ text }], now).list)
208      return { text: `Added to your checklist: ${text}` }
209    }
210    if (/^clear$/i.test(args)) {
211      const before = (await read($, items)).length
212      const list = await write($, l => l.filter(i => !i.done))
213      return { text: `Cleared ${before - list.length} ticked item(s).` }
214    }
215    await update($, collapsed, () => false)
216    const { isPlaced } = await $.ui.open({ id: PANE, title: 'Your checklist' })
217    return { text: isPlaced ? 'Checklist opened.' : summary(await read($, items)) }
218  })
219
220  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
221    if (e.props.hasSurvey) return next(e)
222    const list = await read($, items)
223    const open = list.filter(i => !i.done)
224    const withHidden = await read($, showHidden)
225    const hiddenCount = list.length - visible(list).length
226    if (visible(list).length === 0) return next(e)
227    const shownAll = withHidden ? list : visible(list)
228    const isCollapsed = await read($, collapsed)
229    const { Box, Button, Text } = $.ui.resolve(e)
230    const width = Math.max(30, e.props.bodyColumns - 14)
231    const done = list.length - open.length
232
233    if (isCollapsed) {
234      return (
235        <Box>
236          <Button key="expand" plain onPress={() => update($, collapsed, () => false)}>
237            ☐ <Text bold>Your checklist</Text> <Text dimColor>{open.length} open{open[0] ? ` · next: ${short(open[0].text, width - 30)}` : ''}</Text>
238          </Button>
239        </Box>
240      )
241    }
242
243    const row = makeRow($, $.ui.resolve(e))
244    const room = Math.max(1, Math.min(6, e.props.maxRows - 2))
245    const shown = shownAll.slice(0, room)
246    const doneShown = shownAll.filter(i => i.done && !i.hidden).length
247    let lastGroup = ''
248    const rows: RenderChildren[] = []
249    for (const i of shown) {
250      if (i.group !== lastGroup) {
251        lastGroup = i.group
252        rows.push(<Box key={`g-${i.id}`} marginTop={rows.length > 0 ? 'xs' : 0}><Text size="caption" weight="medium" color="subtle">{short(i.group, width)}</Text></Box>)
253      }
254      rows.push(row(`r-${i.id}`, i, width))
255    }
256
257    const keys = makeKeys($.ui.resolve(e))
258    return (
259      <Box flexDirection="column" gap="xs">
260        <Box flexDirection="row" justifyContent="space-between" alignItems="center">
261          <Box flexDirection="row" alignItems="center" gap="sm">
262            <Text weight="semibold" bold>Your checklist</Text>
263            <Text size="footnote" color="subtle" tabular>{done}/{list.length} done{shownAll.length > shown.length ? ` · ${shownAll.length - shown.length} more` : ''}</Text>
264          </Box>
265          {keys([
266            { key: 'all', label: 'All', onPress: () => $.ui.open({ id: PANE, title: 'Your checklist' }) },
267            doneShown > 0 && { key: 'hidedone', label: 'Hide done', onPress: () => write($, l => hideDone(l)) },
268            hiddenCount > 0 && { key: 'showhidden', label: withHidden ? 'Hide hidden' : `Show hidden (${hiddenCount})`, onPress: () => update($, showHidden, v => !v) },
269            { key: 'hide', label: 'Collapse', onPress: () => update($, collapsed, () => true) },
270          ])}
271        </Box>
272        {rows}
273      </Box>
274    )
275  })
276
277  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
278    const all = await read($, items)
279    const withHidden = await read($, showHidden)
280    const list = withHidden ? all : visible(all)
281    const hiddenCount = all.length - visible(all).length
282    const { Box, Button, Text } = $.ui.resolve(e)
283    const row = makeRow($, $.ui.resolve(e))
284    const width = Math.max(30, (e.props.bodyColumns ?? 80) - 10)
285    if (list.length === 0 && hiddenCount === 0) return <Text dimColor>Nothing waits on you. Claude adds items here when a reply leaves you a decision or a step.</Text>
286    const keys = makeKeys($.ui.resolve(e))
287    const doneShown = list.filter(i => i.done && !i.hidden).length
288    return (
289      <Box flexDirection="column" gap="md">
290        {grouped(list).map(g => (
291          <Box key={`pg-${g.group}`} flexDirection="column" gap="xs">
292            <Box flexDirection="row" alignItems="center" gap="sm">
293              <Text weight="semibold" bold>{g.group}</Text>
294              <Text size="footnote" color="subtle" tabular>{g.items.filter(i => i.done).length}/{g.items.length}</Text>
295            </Box>
296            {g.items.map(i => row(`pr-${i.id}`, i, width))}
297          </Box>
298        ))}
299        {keys([
300          doneShown > 0 && { key: 'phidedone', label: 'Hide done', onPress: () => write($, l => hideDone(l)) },
301          hiddenCount > 0 && { key: 'showhidden', label: withHidden ? 'Hide hidden' : `Show hidden (${hiddenCount})`, onPress: () => update($, showHidden, v => !v) },
302          { key: 'clear', label: 'Clear ticked', onPress: () => write($, l => l.filter(i => !i.done)) },
303        ])}
304      </Box>
305    )
306  })
307}
308
hooks/logic.ts 100 lines
1import type { ChecklistItem } from '../types'
2
3export type NewItem = { text: string; group?: string; session?: string; link?: string }
4
5export const DEFAULT_GROUP = 'To do'
6
7const norm = (s: string) => s.trim().replace(/\s+/g, ' ').toLowerCase()
8
9// Adds items; an open item with the same text is updated in place, never duplicated.
10export function addItems(list: ChecklistItem[], adds: NewItem[], now: number): { list: ChecklistItem[]; added: ChecklistItem[] } {
11  const out = [...list]
12  const added: ChecklistItem[] = []
13  let seq = 0
14  for (const a of adds) {
15    const text = a.text?.trim()
16    if (!text) continue
17    const group = a.group?.trim() || DEFAULT_GROUP
18    const at = out.findIndex(i => !i.done && norm(i.text) === norm(text))
19    if (at >= 0) {
20      out[at] = { ...out[at]!, group, session: a.session ?? out[at]!.session, link: a.link ?? out[at]!.link }
21      continue
22    }
23    const item: ChecklistItem = { id: `c${now.toString(36)}${(seq++).toString(36)}`, text, group, done: false, addedAt: now }
24    if (a.session) item.session = a.session
25    if (a.link) item.link = a.link
26    out.push(item)
27    added.push(item)
28  }
29  return { list: out, added }
30}
31
32// An id matches exactly; anything else matches an item whose text contains it (case-insensitive).
33export function matches(item: ChecklistItem, ref: string): boolean {
34  const r = norm(ref)
35  return item.id === ref.trim() || (r.length >= 3 && norm(item.text).includes(r))
36}
37
38export function setDone(list: ChecklistItem[], refs: string[], done: boolean, now: number): { list: ChecklistItem[]; changed: ChecklistItem[] } {
39  const changed: ChecklistItem[] = []
40  const out = list.map(i => {
41    if (i.done === done || !refs.some(r => matches(i, r))) return i
42    const next: ChecklistItem = done ? { ...i, done, doneAt: now } : { ...i, done, doneAt: undefined }
43    changed.push(next)
44    return next
45  })
46  return { list: out, changed }
47}
48
49// Unticking also unhides, so an item that is open again is never out of sight.
50export function toggle(list: ChecklistItem[], id: string, now: number): ChecklistItem[] {
51  return list.map(i => (i.id !== id ? i : i.done ? { ...i, done: false, doneAt: undefined, hidden: undefined } : { ...i, done: true, doneAt: now }))
52}
53
54// Hides ticked items: one by id, or every ticked one when no id is given. Open items never hide.
55export function hideDone(list: ChecklistItem[], id?: string): ChecklistItem[] {
56  return list.map(i => (i.done && !i.hidden && (id === undefined || i.id === id) ? { ...i, hidden: true } : i))
57}
58
59// What the band shows: open items, and ticked ones he has not hidden yet.
60export function visible(list: ChecklistItem[]): ChecklistItem[] {
61  return list.filter(i => !i.done || !i.hidden)
62}
63
64// The prompt a click on an item's text sends when the item has no sub-session of its own.
65export function workPrompt(item: ChecklistItem): string {
66  return [
67    `Let's work on this item from my checklist: "${item.text}" (group: ${item.group}, id ${item.id}).`,
68    item.link ? `The page where I act: ${item.link}` : '',
69    'Bring me what I need to decide or do it here; if it is a task of its own, offer a sub-session for it and add its session id to the item.',
70  ].filter(Boolean).join('\n')
71}
72
73// Groups in the order their first item was added.
74export function grouped(list: ChecklistItem[]): { group: string; items: ChecklistItem[] }[] {
75  const order: string[] = []
76  const by = new Map<string, ChecklistItem[]>()
77  for (const i of list) {
78    if (!by.has(i.group)) { by.set(i.group, []); order.push(i.group) }
79    by.get(i.group)!.push(i)
80  }
81  return order.map(group => ({ group, items: by.get(group)! }))
82}
83
84export function sessionUrl(session: string): string {
85  const id = session.startsWith('local_') ? session : `local_${session}`
86  return `claude://claude.ai/epitaxy/${id}`
87}
88
89export function short(text: string, max: number): string {
90  const t = text.replace(/\s+/g, ' ').trim()
91  return t.length <= max ? t : `${t.slice(0, Math.max(1, max - 1))}…`
92}
93
94export function summary(list: ChecklistItem[]): string {
95  if (list.length === 0) return 'Your checklist is empty.'
96  return grouped(list)
97    .map(g => [`${g.group}:`, ...g.items.map(i => `- [${i.done ? 'x' : ' '}] ${i.text} (id ${i.id}${i.session ? `, session ${i.session}` : ''}${i.hidden ? ', hidden' : ''})`)].join('\n'))
98    .join('\n')
99}
100
types/index.d.ts 18 lines
1export type ChecklistItem = {
2  id: string
3  text: string
4  group: string
5  done: boolean
6  session?: string
7  link?: string
8  addedAt: number
9  doneAt?: number
10  hidden?: boolean
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    checklist: { items: ChecklistItem[]; collapsed: boolean; showHidden: boolean }
16  }
17}
18