Persistent per-repo checklist that both you and Claude can work through: a model tool, a /checklist command, a progress band above the prompt, a status line…

A persistent, per-repo checklist that you and Claude share. Claude gets a checklist tool to plan and tick off work, you get /checklist, a progress band above the prompt, a status line, and a pane with the full list. It works on the terminal, the desktop Code tab and the Claude mobile app.
╭─ Checklist ─────────────────────────────────────────╮
│ ☑ 3/7 ▕████░░░░░░▏ 4 open │
│ ☑ #1 Sketch the data model ✕ │
│ ☑ #2 Add the migration ✕ │
│ ☑ #3 Wire up the API route ✕ │
│ ◐ #4 Write tests ✕ │
│ ☐ #5 Update the README ✕ │
│ ☐ #6 Handle the empty state ✕ │
│ ☐ #7 Ship it ✕ │
│ [ Clear done ] │
╰──────────────────────────────────────────────────────╯
☑ 3/7 ▕████░░░░░░▏ next: Write tests [-]
╭──────────────────────────────────────────────────────╮
│ > │
╰──────────────────────────────────────────────────────╯
/plugin install checklist --marketplace mrjk05/modemon
Answer y to add the marketplace, then pick a scope. Built against Claude Code 2.1.290; the mods API is early access and may change between releases.
| Command | What it does |
|---|---|
/checklist | Opens the pane, or closes it if it is already open. Where no pane can be placed (mobile, or a narrow terminal) it shows the list inline instead |
/checklist add <text> | Adds an item |
/checklist done <n> | Marks item n done (done 2,3 marks several) |
/checklist undo <n> | Puts item n back to todo |
/checklist rm <n> | Removes item n |
/checklist clear | Removes every done item |
/checklist start <n> | Marks item n as in progress |
/checklist list | Shows the list inline, with tappable ticks |
n is the item number shown as #n. Numbers stay the same when other items are removed. New items get the next number after the highest one in the list.
In the pane and in the inline list, click or tap (or focus and press Enter on) ☐ / ◐ / ☑ to tick or untick an item, ✕ to remove it, and Clear done to drop finished items.
| Terminal | Desktop (Code tab) | Mobile app | |
|---|---|---|---|
| Band above the prompt | yes | yes | not raised there |
Status line ☑ 3/7 · next: … | yes | yes | yes |
| Pane | yes (docked or inline) | yes | never placed |
/checklist inline list with tappable ticks | when the pane cannot be placed, and for /checklist list | same | always |
| Tool, slash command, nudge | yes | yes | yes |
next(e)) and stacks what they draw under it in a column. When they draw nothing, the checklist line is drawn alone.showStatus: false. The next item's text is cut to 40 characters there./checklist with no arguments draws the list as the command's output row whenever the pane cannot be placed ($.ui.open answers isPlaced: false). On mobile it always does, since the app never docks a pane. /checklist list always draws it. The buttons work like the pane's and the row redraws live. The text the model reads is still the plain list. The tree uses only Box, Text and Button, which every surface has, and item text is cut to the width the surface reports.mcp__checklist__checklist, which takes { action, items?, ids? }:listadd with items: ["...", "..."] (several at once)start, done and remove with ids: [n, ...]clear-doneEvery call returns the updated list as compact text:
Checklist 3/7 done ([ ] todo, [>] doing, [x] done):
[x] #1 Sketch the data model
[>] #4 Write tests
[ ] #5 Update the README
The tool only changes this plugin's own list, so the plugin allows it without a permission prompt. A call with a bad action or an unknown item number is refused, and the reason goes back to Claude.
start each item, mark it done once it is finished, and add any work it discovers. When nothing is open, the section is not added.The plugin's rows in /config (pluginConfigs.checklist.options in settings):
| Option | Default | |
|---|---|---|
showBand | true | Draw the progress band above the prompt |
showStatus | true | Show the ☑ 3/7 · next: … status line while the list has items |
nudge | true | Add the keep-going section to the system prompt |
Items are { id, text, status: 'todo' | 'doing' | 'done', createdAt, doneAt? }. They are kept in the plugin's $.store (a JSON file under your Claude Code config directory) under list:<repo root>, so every worktree and subfolder of a repo shares one list. Outside a git repo, the key is the session's working directory. The list is loaded at session start and mirrored into $.state for drawing.
/cd keeps showing the old repo's list until the plugin reloads or a new session starts.nudge: false./checklist list replace them. Items can be ticked, unticked and removed by tapping. Adding an item still goes through /checklist add <text> or Claude, because the app draws no text field./checklist add adds one item per call. To add several at once, ask Claude, since the tool takes a list.hooks/register.tsx 379 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { ChecklistItem } from '../types'
5import {
6 add,
7 bandHead,
8 clearDone,
9 clip,
10 done,
11 formatList,
12 nudgeText,
13 parseCommand,
14 parseToolInput,
15 progress,
16 remove,
17 sanitize,
18 start,
19 statusText,
20 undo,
21 type OpResult,
22} from './lib'
23
24const TOOL = 'mcp__checklist__checklist'
25const PANE = 'checklist'
26const STORE_PREFIX = 'list:'
27/** First line of `/checklist` when the pane cannot be placed; the CommandOutput hook draws the list for it. */
28const WAITING_HEAD = 'The checklist pane has no room here (it opens once the terminal is wide enough), so here is the list:'
29/** Args of `/checklist` that show the list (no args toggles the pane). */
30const LIST_ARGS = new Set(['', 'list', 'ls', 'show'])
31
32/** The elements every surface has, mobile included: the only ones the list trees use. */
33type ListElements = Pick<Elements['mobile'], 'Box' | 'Text' | 'Button'>
34
35const items = atom({ plugin: 'checklist', key: 'items' } as const, [])
36const storeKey = atom({ plugin: 'checklist', key: 'storeKey' } as const, '')
37
38const TOOL_DESCRIPTION = [
39 "A persistent checklist for the current repository, shared with the user (they see it above the prompt and edit it with /checklist).",
40 'Use it to track multi-step work: add the steps, "start" one when you begin it, "done" it as soon as it is finished, and add follow-up work you discover.',
41 'Actions: "list" (show items); "add" with "items": ["text", ...] (several at once); "start" / "done" / "remove" with "ids": [n, ...] (item numbers as shown, e.g. #3 -> 3); "clear-done" (drop finished items).',
42 'Every call returns the updated list as compact text: [ ] todo, [>] doing, [x] done, then #id and the text.',
43].join(' ')
44
45const INPUT_SCHEMA = {
46 type: 'object',
47 properties: {
48 action: {
49 type: 'string',
50 enum: ['list', 'add', 'start', 'done', 'remove', 'clear-done'],
51 description: 'What to do.',
52 },
53 items: {
54 type: 'array',
55 items: { type: 'string' },
56 description: 'For "add": the texts of the new items, one short line each.',
57 },
58 ids: {
59 type: 'array',
60 items: { type: 'integer' },
61 description: 'For "start", "done" and "remove": item numbers as the list shows them.',
62 },
63 },
64 required: ['action'],
65 additionalProperties: false,
66} as const
67
68type Dollar = EngineInterface
69
70/** The repo root (or the working directory outside a repo) names the list. */
71async function resolveStoreKey($: Dollar): Promise<string> {
72 let root: string | undefined
73 try {
74 root = (await $.session.repo())?.root
75 } catch {
76 root = undefined
77 }
78 if (root === undefined) root = await $.session.cwd()
79 return `${STORE_PREFIX}${root}`
80}
81
82/** Reads the repo's list from $.store into $.state. */
83async function load($: Dollar): Promise<string> {
84 const key = await resolveStoreKey($)
85 const stored = sanitize(await $.store.get(key))
86 await update($, storeKey, () => key)
87 await update($, items, () => stored)
88 refreshStatus($, stored)
89 return key
90}
91
92let statusEnabled = true // set from the `showStatus` option on every (re)load of register
93
94/** The status line (`☑ 3/7 · next: ...`): the one checklist view the mobile app shows unasked. */
95function refreshStatus($: Dollar, list: readonly ChecklistItem[]): void {
96 if (statusEnabled) $.ui.status(statusText(list))
97}
98
99async function ensureLoaded($: Dollar): Promise<string> {
100 const key = await read($, storeKey)
101 return key !== '' ? key : load($)
102}
103
104/** Applies a pure list operation to $.state and persists it to $.store. */
105async function mutate($: Dollar, op: (list: ChecklistItem[], now: number) => OpResult): Promise<OpResult> {
106 const key = await ensureLoaded($)
107 const now = await $.clock.now()
108 let outcome: OpResult = { items: [], changed: [] }
109 const next = await update($, items, list => {
110 outcome = op(list, now)
111 return outcome.error === undefined ? outcome.items : list
112 })
113 if (outcome.error === undefined) {
114 await $.store.set(key, next)
115 refreshStatus($, next)
116 }
117 return { ...outcome, items: next }
118}
119
120function summary(list: readonly ChecklistItem[]): string {
121 if (list.length === 0) return 'Checklist is empty.'
122 const p = progress(list)
123 return `${bandHead(list)} ${p.next === undefined ? 'all done' : `next: #${p.next.id} ${p.next.text}`}`
124}
125
126function names(list: readonly ChecklistItem[]): string {
127 return list.map(item => `#${item.id} ${item.text}`).join('; ')
128}
129
130async function togglePane($: Dollar): Promise<string> {
131 // A pane that is open but waits undrawn (narrow terminal, mobile-only session) is not "open" to the person.
132 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
133 if (isOpen) {
134 await $.ui.close({ id: PANE })
135 return 'Checklist pane closed.'
136 }
137 const list = await read($, items)
138 const opened = await $.ui.open({ id: PANE, title: 'Checklist', rows: Math.min(Math.max(list.length, 1) + 3, 20) })
139 if (opened.isPlaced) return `Checklist pane opened. ${summary(list)}`
140 return `${WAITING_HEAD}\n${formatList(list)}`
141}
142
143export const register: Register = (on, options) => {
144 const showBand = options.showBand !== false
145 const nudge = options.nudge !== false
146 statusEnabled = options.showStatus !== false
147
148 on('session.start', async ($, e, next) => {
149 try {
150 await load($)
151 } catch (error) {
152 $.ui.log(`checklist: could not load the list (${String(error)})`, { to: 'debug' })
153 }
154 await $.tool.register({ name: 'checklist', description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA })
155 await $.command.register({
156 name: 'checklist',
157 description: 'Show or edit this repo\'s checklist (no args toggles the pane)',
158 argumentHint: '[add <text> | done <n> | undo <n> | rm <n> | clear]',
159 })
160 return next(e)
161 })
162
163 // Only touches this plugin's own list, so it never needs a permission prompt.
164 on('tool.check', { tool: TOOL }, () => ({ decision: 'allow' as const }))
165
166 // Keep the schema in the prompt's tool list rather than behind ToolSearch.
167 on('tool.describe', { tool: TOOL }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
168
169 on('tool.call', { tool: TOOL }, async ($, e) => {
170 const request = parseToolInput(e as unknown as Record<string, unknown>)
171 let outcome: OpResult
172 switch (request.action) {
173 case 'error':
174 return { deny: request.message }
175 case 'list':
176 await ensureLoaded($)
177 return { result: formatList(await read($, items)) }
178 case 'add': {
179 const texts = request.texts
180 outcome = await mutate($, (list, now) => add(list, texts, now))
181 break
182 }
183 case 'start': {
184 const ids = request.ids
185 outcome = await mutate($, (list, now) => start(list, ids, now))
186 break
187 }
188 case 'done': {
189 const ids = request.ids
190 outcome = await mutate($, (list, now) => done(list, ids, now))
191 break
192 }
193 case 'remove': {
194 const ids = request.ids
195 outcome = await mutate($, list => remove(list, ids))
196 break
197 }
198 case 'clear-done':
199 outcome = await mutate($, list => clearDone(list))
200 break
201 }
202 if (outcome.error !== undefined) return { deny: `${outcome.error}\n${formatList(outcome.items)}` }
203 return { result: formatList(outcome.items) }
204 }).catch(() => ({ deny: 'checklist: the tool failed. Call it with action "list" to see the current state.' }))
205
206 on('command.run', { command: 'checklist' }, async ($, e) => {
207 const command = parseCommand(e.args)
208 let outcome: OpResult
209 let verb: string
210 switch (command.kind) {
211 case 'error':
212 return { text: command.message }
213 case 'toggle':
214 await ensureLoaded($)
215 return { text: await togglePane($) }
216 case 'list':
217 await ensureLoaded($)
218 return { text: formatList(await read($, items)) }
219 case 'add': {
220 const texts = command.texts
221 outcome = await mutate($, (list, now) => add(list, texts, now))
222 verb = 'Added'
223 break
224 }
225 case 'start': {
226 const ids = command.ids
227 outcome = await mutate($, (list, now) => start(list, ids, now))
228 verb = 'Started'
229 break
230 }
231 case 'done': {
232 const ids = command.ids
233 outcome = await mutate($, (list, now) => done(list, ids, now))
234 verb = 'Done'
235 break
236 }
237 case 'undo': {
238 const ids = command.ids
239 outcome = await mutate($, (list, now) => undo(list, ids, now))
240 verb = 'Reopened'
241 break
242 }
243 case 'rm': {
244 const ids = command.ids
245 outcome = await mutate($, list => remove(list, ids))
246 verb = 'Removed'
247 break
248 }
249 case 'clear':
250 outcome = await mutate($, list => clearDone(list))
251 if (outcome.changed.length === 0) return { text: `No done items to clear. ${summary(outcome.items)}` }
252 return { text: `Cleared ${outcome.changed.length} done item(s). ${summary(outcome.items)}` }
253 }
254 if (outcome.error !== undefined) return { text: outcome.error }
255 return { text: `${verb}: ${names(outcome.changed)}\n${summary(outcome.items)}` }
256 })
257
258 if (nudge) {
259 on('prompt.compose', async ($, e, next) => {
260 const composed = await next(e)
261 if (e.traits.includes('bare')) return composed
262 const text = nudgeText(await read($, items), TOOL)
263 if (text === undefined) return composed
264 return { sections: [...composed.sections, { id: 'checklist:open-items', text, scope: 'session' as const }] }
265 })
266 }
267
268 if (showBand) {
269 // One band for every plugin: draw this part, then stack whatever the hooks beneath draw under it.
270 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
271 const list = await read($, items)
272 if (e.props.hasSurvey || list.length === 0) return next(e)
273 const { Box, Text } = $.ui.resolve(e)
274 const p = progress(list)
275 const mine = (
276 <Box key="checklist-band" flexDirection="row">
277 <Text color={p.next === undefined ? 'success' : undefined}>
278 {bandHead(list)}{' '}
279 </Text>
280 <Text dimColor wrap="truncate-end">
281 {p.next === undefined ? 'all done' : `next: ${p.next.text}`}
282 </Text>
283 </Box>
284 )
285 const below = await next(e)
286 if (isEmptyTree(below)) return mine
287 return (
288 <Box key="checklist-stack" flexDirection="column">
289 {mine}
290 {below}
291 </Box>
292 )
293 })
294 }
295
296 // The pane, every surface that places one. Only Box, Text and Button: all of them exist on mobile too.
297 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
298 const { Box, Button, Text } = $.ui.resolve(e)
299 const list = await read($, items)
300 return listTree($, { Box, Button, Text }, list, e.props.bodyColumns)
301 })
302
303 // `/checklist` and `/checklist list` draw the list inline, with tappable ticks: always on mobile (which
304 // places no pane), and elsewhere for `list` or when the pane could not be placed.
305 on('ui.render', { component: 'CommandOutput', props: { command: 'checklist' } }, async ($, e, next) => {
306 const args = e.props.args.trim().toLowerCase()
307 if (e.props.isErrored || !LIST_ARGS.has(args)) return next(e)
308 const inline = e.surface === 'mobile' || args !== '' || e.props.text.startsWith(WAITING_HEAD)
309 if (!inline) return next(e)
310 const { Box, Button, Text } = $.ui.resolve(e)
311 const list = await read($, items)
312 return listTree($, { Box, Button, Text }, list, e.viewport?.columns)
313 })
314}
315
316/** True for a tree that draws nothing: no element, or Boxes/Texts holding only empty strings. */
317function isEmptyTree(tree: RenderElement | null | undefined): boolean {
318 if (tree === null || tree === undefined) return true
319 const el = tree as unknown as { type?: string; children?: unknown; props?: { children?: unknown } }
320 if (el.type !== 'Box' && el.type !== 'Text') return false
321 return isEmptyChildren(el.children ?? el.props?.children)
322}
323
324function isEmptyChildren(children: unknown): boolean {
325 if (children === undefined || children === null || children === false) return true
326 if (typeof children === 'string') return children === ''
327 if (typeof children === 'number') return false
328 if (Array.isArray(children)) return children.every(isEmptyChildren)
329 return isEmptyTree(children as RenderElement)
330}
331
332/** The full list with tick, remove and clear-done Buttons, for the pane and the inline command output. */
333function listTree($: Dollar, ui: ListElements, list: readonly ChecklistItem[], columns: number | undefined): RenderElement {
334 const { Box, Button, Text } = ui
335 const p = progress(list)
336 if (list.length === 0) {
337 return (
338 <Box key="empty" flexDirection="column">
339 <Text dimColor>
340 {'No items yet. Add one with /checklist add <text>, or ask Claude to plan its work with the checklist tool.'}
341 </Text>
342 </Box>
343 )
344 }
345 // Room for the tick, `#nn `, spaces and the ✕; a narrow phone gets a shorter line rather than a wrapped one.
346 const textMax = columns === undefined ? 200 : Math.max(columns - 12, 8)
347 return (
348 <Box key="list" flexDirection="column">
349 <Text bold>
350 {bandHead(list)} {p.total - p.done} open
351 </Text>
352 {list.map(item => (
353 <Box key={`row-${item.id}`} flexDirection="row">
354 <Button
355 key={`tick-${item.id}`}
356 plain
357 label={item.status === 'done' ? '☑' : item.status === 'doing' ? '◐' : '☐'}
358 onPress={() => mutate($, (l, now) => (item.status === 'done' ? undo(l, [item.id], now) : done(l, [item.id], now)))}
359 />
360 <Text
361 dimColor={item.status === 'done'}
362 strikethrough={item.status === 'done'}
363 bold={item.status === 'doing'}
364 wrap="truncate-end"
365 >
366 {' '}#{item.id} {clip(item.text, textMax)}{' '}
367 </Text>
368 <Button key={`rm-${item.id}`} plain dimColor label="✕" onPress={() => mutate($, l => remove(l, [item.id]))} />
369 </Box>
370 ))}
371 {p.done > 0 && (
372 <Box flexDirection="row">
373 <Button key="clear-done" label="Clear done" onPress={() => mutate($, l => clearDone(l))} />
374 </Box>
375 )}
376 </Box>
377 )
378}
379hooks/lib.ts 301 lines1import type { ChecklistItem, ChecklistStatus } from '../types'
2
3export type { ChecklistItem, ChecklistStatus }
4
5/** What every list operation answers: the new list, the items it touched, and an error when it did nothing. */
6export type OpResult = {
7 items: ChecklistItem[]
8 changed: ChecklistItem[]
9 error?: string
10}
11
12export type Progress = {
13 done: number
14 total: number
15 /** The first item being worked on, else the first todo; undefined when nothing is open. */
16 next: ChecklistItem | undefined
17}
18
19export const MAX_TEXT = 200
20
21/** One line, trimmed, capped. */
22export function cleanText(text: string): string {
23 const one = text.replace(/\s+/g, ' ').trim()
24 return one.length > MAX_TEXT ? `${one.slice(0, MAX_TEXT - 1)}…` : one
25}
26
27export function nextId(items: readonly ChecklistItem[]): number {
28 return items.reduce((max, item) => Math.max(max, item.id), 0) + 1
29}
30
31/** Accepts only well-formed items, so a damaged store entry cannot break drawing. */
32export function sanitize(value: unknown): ChecklistItem[] {
33 if (!Array.isArray(value)) return []
34 const out: ChecklistItem[] = []
35 const seen = new Set<number>()
36 for (const raw of value as unknown[]) {
37 if (typeof raw !== 'object' || raw === null) continue
38 const r = raw as Record<string, unknown>
39 const id = r.id
40 const text = r.text
41 const status = r.status
42 if (typeof id !== 'number' || !Number.isInteger(id) || id < 1 || seen.has(id)) continue
43 if (typeof text !== 'string' || text.trim() === '') continue
44 if (status !== 'todo' && status !== 'doing' && status !== 'done') continue
45 seen.add(id)
46 const item: ChecklistItem = {
47 id,
48 text: cleanText(text),
49 status,
50 createdAt: typeof r.createdAt === 'number' ? r.createdAt : 0,
51 }
52 if (status === 'done' && typeof r.doneAt === 'number') item.doneAt = r.doneAt
53 out.push(item)
54 }
55 return out
56}
57
58export function add(items: readonly ChecklistItem[], texts: readonly string[], now: number): OpResult {
59 const cleaned = texts.map(cleanText).filter(text => text !== '')
60 if (cleaned.length === 0) return { items: [...items], changed: [], error: 'Nothing to add: give the item text.' }
61 let id = nextId(items)
62 const added = cleaned.map(text => ({ id: id++, text, status: 'todo' as const, createdAt: now }))
63 return { items: [...items, ...added], changed: added }
64}
65
66function setStatus(
67 items: readonly ChecklistItem[],
68 ids: readonly number[],
69 status: ChecklistStatus,
70 now: number,
71): OpResult {
72 if (ids.length === 0) return { items: [...items], changed: [], error: 'Give at least one item number.' }
73 const missing = ids.filter(id => !items.some(item => item.id === id))
74 if (missing.length > 0) {
75 return { items: [...items], changed: [], error: `No item ${missing.map(n => `#${n}`).join(', ')}.` }
76 }
77 const wanted = new Set(ids)
78 const changed: ChecklistItem[] = []
79 const next = items.map(item => {
80 if (!wanted.has(item.id)) return item
81 const updated: ChecklistItem = { id: item.id, text: item.text, status, createdAt: item.createdAt }
82 if (status === 'done') updated.doneAt = item.status === 'done' && item.doneAt !== undefined ? item.doneAt : now
83 changed.push(updated)
84 return updated
85 })
86 return { items: next, changed }
87}
88
89/** Marks items as being worked on. */
90export function start(items: readonly ChecklistItem[], ids: readonly number[], now: number): OpResult {
91 return setStatus(items, ids, 'doing', now)
92}
93
94export function done(items: readonly ChecklistItem[], ids: readonly number[], now: number): OpResult {
95 return setStatus(items, ids, 'done', now)
96}
97
98/** Back to todo. */
99export function undo(items: readonly ChecklistItem[], ids: readonly number[], now: number): OpResult {
100 return setStatus(items, ids, 'todo', now)
101}
102
103export function remove(items: readonly ChecklistItem[], ids: readonly number[]): OpResult {
104 if (ids.length === 0) return { items: [...items], changed: [], error: 'Give at least one item number.' }
105 const missing = ids.filter(id => !items.some(item => item.id === id))
106 if (missing.length > 0) {
107 return { items: [...items], changed: [], error: `No item ${missing.map(n => `#${n}`).join(', ')}.` }
108 }
109 const wanted = new Set(ids)
110 return {
111 items: items.filter(item => !wanted.has(item.id)),
112 changed: items.filter(item => wanted.has(item.id)),
113 }
114}
115
116export function clearDone(items: readonly ChecklistItem[]): OpResult {
117 return {
118 items: items.filter(item => item.status !== 'done'),
119 changed: items.filter(item => item.status === 'done'),
120 }
121}
122
123export function progress(items: readonly ChecklistItem[]): Progress {
124 const doneCount = items.filter(item => item.status === 'done').length
125 const next = items.find(item => item.status === 'doing') ?? items.find(item => item.status === 'todo')
126 return { done: doneCount, total: items.length, next }
127}
128
129export function openItems(items: readonly ChecklistItem[]): ChecklistItem[] {
130 return items.filter(item => item.status !== 'done')
131}
132
133/** `▕██████░░░░▏` */
134export function bar(doneCount: number, total: number, width = 10): string {
135 const filled = total === 0 ? 0 : Math.round((doneCount / total) * width)
136 return `▕${'█'.repeat(filled)}${'░'.repeat(width - filled)}▏`
137}
138
139/** `☑ 3/7 ▕██████░░░░▏`; the caller adds `next: ...`. */
140export function bandHead(items: readonly ChecklistItem[]): string {
141 const p = progress(items)
142 return `☑ ${p.done}/${p.total} ${bar(p.done, p.total)}`
143}
144
145/** The whole band as one line, or undefined when the list is empty. */
146export function bandText(items: readonly ChecklistItem[]): string | undefined {
147 if (items.length === 0) return undefined
148 const p = progress(items)
149 return `${bandHead(items)} ${p.next === undefined ? 'all done' : `next: ${p.next.text}`}`
150}
151
152export function mark(status: ChecklistStatus): string {
153 return status === 'done' ? '[x]' : status === 'doing' ? '[>]' : '[ ]'
154}
155
156/** Compact text for the model and the command: a header line, then one line per item. */
157export function formatList(items: readonly ChecklistItem[]): string {
158 if (items.length === 0) return 'Checklist is empty.'
159 const p = progress(items)
160 const lines = items.map(item => `${mark(item.status)} #${item.id} ${item.text}`)
161 return [`Checklist ${p.done}/${p.total} done ([ ] todo, [>] doing, [x] done):`, ...lines].join('\n')
162}
163
164/** The keep-going section of the system prompt; undefined when nothing is open. */
165export function nudgeText(items: readonly ChecklistItem[], toolName: string, maxListed = 12): string | undefined {
166 const open = openItems(items)
167 if (open.length === 0) return undefined
168 const listed = open.slice(0, maxListed).map(item => `- #${item.id}${item.status === 'doing' ? ' (doing)' : ''} ${item.text}`)
169 if (open.length > maxListed) listed.push(`- ... and ${open.length - maxListed} more (action "list")`)
170 return [
171 '# Checklist',
172 `This repo has a persistent checklist the user shares with you (${open.length} open of ${items.length}):`,
173 ...listed,
174 `Unless the user asks for something else, keep working through these in order with the ${toolName} tool: "start" an item when you begin it, "done" as soon as it is finished (before moving on), and "add" any follow-up work you discover. Do not mark an item done that you did not finish.`,
175 ].join('\n')
176}
177
178/** Item numbers out of `3`, `#3`, `3,4 5`. */
179export function parseIds(text: string): number[] | undefined {
180 const parts = text.split(/[\s,]+/).filter(part => part !== '')
181 if (parts.length === 0) return undefined
182 const ids: number[] = []
183 for (const part of parts) {
184 const m = /^#?(\d+)$/.exec(part)
185 if (m === null || m[1] === undefined) return undefined
186 ids.push(Number(m[1]))
187 }
188 return ids
189}
190
191export type Command =
192 | { kind: 'toggle' }
193 | { kind: 'list' }
194 | { kind: 'add'; texts: string[] }
195 | { kind: 'start' | 'done' | 'undo' | 'rm'; ids: number[] }
196 | { kind: 'clear' }
197 | { kind: 'error'; message: string }
198
199export const USAGE = 'Usage: /checklist [add <text> | start <n> | done <n> | undo <n> | rm <n> | clear | list]'
200
201const VERBS: Record<string, 'start' | 'done' | 'undo' | 'rm'> = {
202 start: 'start',
203 doing: 'start',
204 done: 'done',
205 check: 'done',
206 tick: 'done',
207 undo: 'undo',
208 uncheck: 'undo',
209 rm: 'rm',
210 remove: 'rm',
211 del: 'rm',
212 delete: 'rm',
213}
214
215/** Parses what follows `/checklist`. */
216export function parseCommand(args: string): Command {
217 const trimmed = args.trim()
218 if (trimmed === '') return { kind: 'toggle' }
219 const m = /^(\S+)\s*([\s\S]*)$/.exec(trimmed)
220 const verb = (m?.[1] ?? '').toLowerCase()
221 const rest = (m?.[2] ?? '').trim()
222 if (verb === 'add') {
223 if (rest === '') return { kind: 'error', message: 'Usage: /checklist add <text>' }
224 return { kind: 'add', texts: [rest] }
225 }
226 if (verb === 'clear') return { kind: 'clear' }
227 if (verb === 'list' || verb === 'ls' || verb === 'show') return { kind: 'list' }
228 const kind = VERBS[verb]
229 if (kind !== undefined) {
230 const ids = parseIds(rest)
231 if (ids === undefined) return { kind: 'error', message: `Usage: /checklist ${verb} <n> (the item number from the list)` }
232 return { kind, ids }
233 }
234 return { kind: 'error', message: USAGE }
235}
236
237export const ACTIONS = ['list', 'add', 'start', 'done', 'remove', 'clear-done'] as const
238export type ToolAction = (typeof ACTIONS)[number]
239
240export type ToolRequest =
241 | { action: 'list' | 'clear-done' }
242 | { action: 'add'; texts: string[] }
243 | { action: 'start' | 'done' | 'remove'; ids: number[] }
244 | { action: 'error'; message: string }
245
246function toIds(value: unknown): number[] {
247 const list = Array.isArray(value) ? (value as unknown[]) : value === undefined ? [] : [value]
248 const ids: number[] = []
249 for (const one of list) {
250 if (typeof one === 'number' && Number.isInteger(one)) ids.push(one)
251 else if (typeof one === 'string') ids.push(...(parseIds(one) ?? []))
252 }
253 return ids
254}
255
256function toTexts(value: unknown): string[] {
257 const list = Array.isArray(value) ? (value as unknown[]) : value === undefined ? [] : [value]
258 return list.filter((one): one is string => typeof one === 'string')
259}
260
261/** Reads the model's tool input leniently: `items`/`ids` arrays, or a single `text`/`id`. */
262export function parseToolInput(input: Readonly<Record<string, unknown>>): ToolRequest {
263 const action = input.action
264 if (typeof action !== 'string' || !(ACTIONS as readonly string[]).includes(action)) {
265 return { action: 'error', message: `"action" must be one of ${ACTIONS.join(', ')}.` }
266 }
267 switch (action as ToolAction) {
268 case 'list':
269 return { action: 'list' }
270 case 'clear-done':
271 return { action: 'clear-done' }
272 case 'add': {
273 const texts = [...toTexts(input.items), ...toTexts(input.text)]
274 if (texts.length === 0) return { action: 'error', message: '"add" needs "items": an array of item texts.' }
275 return { action: 'add', texts }
276 }
277 default: {
278 const ids = [...toIds(input.ids), ...toIds(input.id)]
279 if (ids.length === 0) return { action: 'error', message: `"${action}" needs "ids": the item numbers from the list.` }
280 return { action: action as 'start' | 'done' | 'remove', ids }
281 }
282 }
283}
284
285export const STATUS_NEXT_MAX = 40
286
287/** The status line, `☑ 3/7 · next: Write tests`; undefined (clears it) when the list is empty. */
288export function statusText(items: readonly ChecklistItem[], maxNext = STATUS_NEXT_MAX): string | undefined {
289 if (items.length === 0) return undefined
290 const p = progress(items)
291 if (p.next === undefined) return `☑ ${p.done}/${p.total} · all done`
292 const text = p.next.text.length > maxNext ? `${p.next.text.slice(0, maxNext - 1)}…` : p.next.text
293 return `☑ ${p.done}/${p.total} · next: ${text}`
294}
295
296/** Cuts `text` to `max` characters with an ellipsis. */
297export function clip(text: string, max: number): string {
298 if (max < 2) return text.slice(0, Math.max(max, 0))
299 return text.length > max ? `${text.slice(0, max - 1)}…` : text
300}
301types/index.d.ts 24 lines1export type ChecklistStatus = 'todo' | 'doing' | 'done'
2
3export type ChecklistItem = {
4 /** Stable item number, shown in every list and used by the tool and /checklist. */
5 id: number
6 text: string
7 status: ChecklistStatus
8 /** Milliseconds since the epoch. */
9 createdAt: number
10 /** Milliseconds since the epoch, set while the item is done. */
11 doneAt?: number
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 checklist: {
17 /** The current repo's items, mirrored from $.store for drawing. */
18 items: ChecklistItem[]
19 /** The $.store key the items persist under (one per repo root). */
20 storeKey: string
21 }
22 }
23}
24