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

Claude Code mods (plugins), as one local marketplace: cc-mods.
| Mod | What it does |
|---|---|
discuss-first | Adds 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-coach | Teaches 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>. |
checklist | A 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. |
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.
claude plugin test discuss-first
claude plugin test harness-coach
claude plugin test checklisthooks/register.tsx 308 lines1import { 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}
308hooks/logic.ts 100 lines1import 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}
100types/index.d.ts 18 lines1export 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