A minimal task board inside Claude Code: a band above the prompt, a board pane, and a tool the model keeps it current with. Survives crashes.

A minimal task board that lives inside Claude Code.
You see what Claude is working on — above the prompt, all the time.<br> Claude keeps it current. It survives crashes, restarts and new sessions.
<img src="docs/demo.gif" alt="docket in Claude Code: the band above the prompt ticks items off live, /docket opens the whole board, /dk folds the band to one line" width="760">
Long agent sessions lose the plot. The plan lives in a message forty screens up, the terminal crashes, and the next session starts from nothing. docket puts the plan where you look anyway — right above the prompt — and keeps it in a plain JSON file in your project, so it outlives any one session.
| Band above the prompt | Progress bar, the item in progress (◐), anything blocked (⊘), what is next (○). Up to six rows, then +N more. |
| Fold it | /dk folds the band to a single line and back. No model call, instant. |
| Board pane | /docket opens the whole board: lists you can fold, an all / open filter, Enter cycles ○ → ◐ → ●, add a task inline, clear done. |
| A tool Claude uses | Claude gets a docket tool (view, plan, add, update, remove, clear_done) and is told to plan multi-step work on the board and tick it off as it goes. |
| Crash-proof | The board is .claude/docket.json in your project. Close the terminal, come back tomorrow — it is still there, and Claude reads it before resuming. |
| Plays well with other mods | Draws on top of whatever other bands draw, never instead of them. While the board is open the band shrinks to one line. |
| Hand-editable | Edit the JSON yourself; the band picks it up within 1.5 s. A broken edit never wipes the board — the last good one stays on screen with the parse error under it. |
| Native todos, mirrored | Where Claude Code's own todo list runs, its items are mirrored into a Session list. |
<table> <tr> <td width="50%"><img src="docs/band.png" alt="The band: progress, the item in progress, a blocked item, what is next"><br><sub><b>The band</b> — always above the prompt</sub></td> <td width="50%"><img src="docs/folded.png" alt="The band folded to one line with /dk"><br><sub><b>Folded</b> — <code>/dk</code>, one line</sub></td> </tr> <tr> <td colspan="2"><img src="docs/board.png" alt="The /docket board pane with three lists"><br><sub><b>The board</b> — <code>/docket</code></sub></td> </tr> </table>
At the prompt of a Claude Code terminal session:
/plugin install docket --marketplace michalstrnadel/claude-docket
or from a shell:
claude plugin install docket --marketplace michalstrnadel/claude-docket
Pick the user scope and it runs in every project. Requires a Claude Code build with mods (function hooks), 2.1.300 or newer.
Just work. When a task has more than two steps, Claude plans it on the board, marks the current item ◐ and ticks items ● as they are verified. You can also ask directly:
"Put the release checklist on the docket." "What's left on the docket?" "Mark #4 blocked — waiting on review."
| Command | Does |
|---|---|
/docket | Open the board pane (Esc closes it). |
/dk | Fold or unfold the band. |
ctrl+x tab | Focus the band; Enter on the summary row folds it too. |
ctrl+x ctrl+a | Claude Code's own: hide or show everything above the prompt. |
// .claude/docket.json
{
"version": 1,
"title": "Tasks",
"nextId": 4,
"lists": [
{
"name": "Release",
"items": [
{ "id": 1, "title": "Write changelog", "status": "done", "updated": "…" },
{ "id": 2, "title": "Tag v1.2", "status": "doing", "note": "waiting on CI", "updated": "…" },
{ "id": 3, "title": "Announce", "status": "todo", "updated": "…" }
]
}
]
}
Statuses: todo ○ · doing ◐ · done ● · blocked ⊘. Ids are never reused.
Commit it or ignore it. Commit .claude/docket.json to share the board with your team; add it to .gitignore to keep it personal.
docket is a Claude Code mod: one TypeScript hooks module, no server, no dependencies.
ui.render on AbovePrompt draws the band; on Pane the board.$.tool.register gives Claude the docket tool, served by a tool.call hook.prompt.compose adds a short section to the system prompt so a fresh session knows the board exists. It is frozen per session, so it does not break prompt caching. (Organizations that manage Claude Code run prompt.compose only for managed plugins — there the tool description carries the same instruction.)hooks/board.ts) is pure functions over an immutable board — every rule is unit-tested, and a failed write never leaves state half-changed.git clone https://github.com/michalstrnadel/claude-docket
claude --plugin-dir ./claude-docket # run it, hot-reloading on save
claude plugin validate ./claude-docket # what the engine would refuse
claude plugin test ./claude-docket # unit tests
vhs scripts/demo.tape # re-record docs/demo.gif and the screenshots
Issues and pull requests welcome.
MIT © Michal Strnadel
hooks/register.tsx 406 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, Filter, Status } from '../types'
5import {
6 GLYPH,
7 addItem,
8 allItems,
9 bar,
10 clearDone,
11 cycleItem,
12 emptyBoard,
13 mirrorTodos,
14 parseBoard,
15 planBoard,
16 progress,
17 removeItem,
18 renderText,
19 serialize,
20 toggleList,
21 updateItem,
22} from './board'
23
24export const FILE = '.claude/docket.json'
25const PANE = 'docket'
26const TOOL = 'mcp__docket__docket'
27const POLL_MS = 1500
28
29const board = atom({ plugin: 'docket', key: 'board' } as const, null)
30const error = atom({ plugin: 'docket', key: 'error' } as const, null)
31const filter = atom({ plugin: 'docket', key: 'filter' } as const, 'all')
32const addTo = atom({ plugin: 'docket', key: 'addTo' } as const, '')
33const compact = atom({ plugin: 'docket', key: 'compact' } as const, false)
34const paneOpen = atom({ plugin: 'docket', key: 'paneOpen' } as const, false)
35const BAND_ROWS = 6
36
37const TONE: Record<Status, string> = { todo: 'subtle', doing: 'warning', done: 'success', blocked: 'error' }
38
39// The file is the source of truth; the atom is a cache of its last good parse.
40let lastSeen = ''
41// Frozen per load so the system prompt stays byte-identical across turns (prompt cache).
42let promptSnapshot: string | null = null
43
44function now(): string {
45 return new Date().toISOString()
46}
47
48async function load($: EngineInterface): Promise<void> {
49 const stat = await $.fs.stat(FILE).catch(() => null)
50 const stamp = stat ? `${stat.mtimeMs}:${stat.size}` : 'missing'
51 if (stamp === lastSeen) return
52 lastSeen = stamp
53 if (!stat) {
54 await update($, board, () => null)
55 await update($, error, () => null)
56 return
57 }
58 const text = await $.fs.read(FILE).catch(() => null)
59 if (typeof text !== 'string') return
60 try {
61 const parsed = parseBoard(text)
62 await update($, board, () => parsed)
63 await update($, error, () => null)
64 } catch (err) {
65 // Keep showing the last good board; say why the new one did not load.
66 await update($, error, () => `${FILE}: ${(err as Error).message}`)
67 }
68}
69
70async function save($: EngineInterface, next: Board): Promise<void> {
71 await $.fs.write(FILE, serialize(next))
72 const stat = await $.fs.stat(FILE).catch(() => null)
73 lastSeen = stat ? `${stat.mtimeMs}:${stat.size}` : ''
74 await update($, board, () => next)
75 await update($, error, () => null)
76}
77
78async function mutate($: EngineInterface, fn: (b: Board) => Board): Promise<Board> {
79 await load($)
80 const current = (await read($, board)) ?? emptyBoard()
81 const next = fn(current)
82 await save($, next)
83 return next
84}
85
86async function openPane($: EngineInterface): Promise<void> {
87 // Ask for room for the whole board (header, lists, items, footer); the person's own size still wins.
88 const b = await read($, board)
89 const rows = b ? 5 + b.lists.reduce((n, l) => n + 2 + (l.collapsed ? 0 : l.items.length), 0) : 6
90 await $.ui.open({ id: PANE, title: 'Docket', focus: true, closeOnEscape: true, rows })
91 await update($, paneOpen, () => true)
92}
93
94const TOOL_DESCRIPTION = `The project's task board (docket), shown to the user live above the prompt and in a board pane, stored in ${FILE} so it survives crashes and new sessions.
95Use it for any work of more than two steps: plan it on the board first, set an item to "doing" when you start it and "done" when it is verified, and keep at most one or two items "doing". The user reads this board instead of your messages to know where things stand, so keep it true.
96Actions:
97- view: returns the board as text with item ids (#n).
98- plan: {title, lists:[{name, items:[title | {title, status}]}]} builds a whole board. Refused when the board has open items unless replace:true — prefer add/update to extend an existing plan.
99- add: {list, title, status?} adds an item (the list is created if missing).
100- update: {id, status?, title?, note?, list?} changes an item; note is a one-line status note ("waiting on CI"), empty string clears it.
101- remove: {id}. clear_done: drops done items and empty lists.
102Statuses: todo, doing, done, blocked.`
103
104const TOOL_SCHEMA = {
105 type: 'object',
106 properties: {
107 action: { type: 'string', enum: ['view', 'plan', 'add', 'update', 'remove', 'clear_done'] },
108 id: { type: 'integer' },
109 list: { type: 'string' },
110 title: { type: 'string' },
111 status: { type: 'string', enum: ['todo', 'doing', 'done', 'blocked'] },
112 note: { type: 'string' },
113 replace: { type: 'boolean' },
114 lists: {
115 type: 'array',
116 items: {
117 type: 'object',
118 properties: {
119 name: { type: 'string' },
120 items: {
121 type: 'array',
122 items: {
123 anyOf: [
124 { type: 'string' },
125 {
126 type: 'object',
127 properties: { title: { type: 'string' }, status: { type: 'string', enum: ['todo', 'doing', 'done', 'blocked'] } },
128 required: ['title'],
129 },
130 ],
131 },
132 },
133 },
134 required: ['name', 'items'],
135 },
136 },
137 },
138 required: ['action'],
139}
140
141export async function runAction($: EngineInterface, input: Record<string, any>): Promise<string> {
142 const action = String(input.action ?? 'view')
143 const stamp = now()
144 if (action === 'view') {
145 await load($)
146 const b = await read($, board)
147 return b ? renderText(b) : `The board is empty (${FILE} does not exist yet). Use action "plan" or "add".`
148 }
149 if (action === 'plan') {
150 await load($)
151 const current = await read($, board)
152 const open = current ? allItems(current).filter(t => t.status !== 'done').length : 0
153 if (open > 0 && !input.replace) {
154 return `Refused: the board has ${open} open items. Extend it with add/update, or pass replace:true to start over.\n\n${renderText(current as Board)}`
155 }
156 const next = planBoard({ title: input.title, lists: input.lists ?? [] }, stamp)
157 await save($, next)
158 return renderText(next)
159 }
160 if (action === 'add') {
161 if (!input.title) return 'Refused: add needs a title.'
162 let added = 0
163 const next = await mutate($, b => {
164 const r = addItem(b, String(input.list ?? 'Inbox'), String(input.title), (input.status as Status) ?? 'todo', stamp)
165 added = r.item.id
166 return r.board
167 })
168 return `Added #${added}.\n\n${renderText(next)}`
169 }
170 if (action === 'update') {
171 const id = Number(input.id)
172 const next = await mutate($, b =>
173 updateItem(b, id, { title: input.title, status: input.status, note: input.note, list: input.list }, stamp),
174 )
175 return `Updated #${id}.\n\n${renderText(next)}`
176 }
177 if (action === 'remove') {
178 const id = Number(input.id)
179 const next = await mutate($, b => removeItem(b, id))
180 return `Removed #${id}.\n\n${renderText(next)}`
181 }
182 if (action === 'clear_done') {
183 const next = await mutate($, clearDone)
184 return renderText(next)
185 }
186 return `Unknown action "${action}".`
187}
188
189export const register: Register = on => {
190 lastSeen = ''
191 promptSnapshot = null
192
193 on('session.start', async ($, e, next) => {
194 await $.command.register({ name: 'docket', description: 'Open the task board' })
195 await $.command.register({ name: 'dk', description: 'Fold or unfold the task list above the prompt' })
196 await $.tool.register({ name: 'docket', description: TOOL_DESCRIPTION, inputSchema: TOOL_SCHEMA, isDeferred: false })
197 await load($)
198 $.clock.every(POLL_MS, () => void load($).catch(() => {}))
199
200 return next(e)
201 })
202
203 on('command.run', { command: 'docket' }, async $ => {
204 await openPane($)
205
206 return { text: 'board opened · esc closes' }
207 })
208
209 on('ui.close', async ($, e, next) => {
210 if (e.id === PANE) await update($, paneOpen, () => false)
211
212 return next(e)
213 }).catch(($, e, next) => next(e))
214
215 on('command.run', { command: 'dk' }, async $ => {
216 await update($, compact, v => !v)
217
218 return { text: (await read($, compact)) ? 'task list folded' : 'task list unfolded' }
219 })
220
221 on('tool.call', { tool: TOOL }, async ($, e) => {
222 try {
223 return { result: await runAction($, e as Record<string, any>) }
224 } catch (err) {
225 return { result: `Error: ${(err as Error).message}`, isError: true }
226 }
227 }).catch(() => ({ result: 'Error: the docket tool failed; the board file is unchanged.', isError: true }))
228
229 // Where the engine's own todo list runs, mirror it so it lands on the board too.
230 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
231 const ran = await next(e)
232 const todos = (e as { todos?: { content: string; status: string }[] }).todos
233 if (Array.isArray(todos)) await mutate($, b => mirrorTodos(b, 'Session', todos, now())).catch(() => {})
234
235 return ran
236 }).catch(($, e, next) => next(e))
237
238 on('prompt.compose', async ($, e, next) => {
239 const composed = await next(e)
240 if (promptSnapshot === null) {
241 await load($)
242 const b = await read($, board)
243 const open = b ? allItems(b).filter(t => t.status !== 'done').length : 0
244 promptSnapshot = [
245 `A task board ("docket") is shown to the user above the prompt. Keep it current with the mcp__docket__docket tool for any multi-step work: plan first, mark items doing/done as you go.`,
246 b && open > 0
247 ? `The board had ${open} open items when this session started — the plan from earlier work. Call docket view before resuming:\n${renderText(b)}`
248 : '',
249 ]
250 .filter(Boolean)
251 .join('\n\n')
252 }
253
254 return { ...composed, sections: [...composed.sections, { id: 'docket:board', text: promptSnapshot, scope: 'session' as const }] }
255 })
256
257 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
258 const b = await read($, board)
259 if (e.props.hasSurvey || b === null || allItems(b).length === 0) return next(e)
260
261 const { Box, Button, Text } = $.ui.resolve(e)
262 const { done, total } = progress(b)
263 // While the board pane is open the band shrinks to its summary row: one list on screen, not two.
264 const isPaneOpen = await read($, paneOpen)
265 const isCompact = isPaneOpen || (await read($, compact))
266 // Open work in the order it matters: what runs now, what is stuck, then what is next.
267 const rank: Record<Status, number> = { doing: 0, blocked: 1, todo: 2, done: 3 }
268 const open = allItems(b)
269 .filter(t => t.status !== 'done')
270 .map((t, i) => ({ t, i }))
271 .sort((x, y) => rank[x.t.status] - rank[y.t.status] || x.i - y.i)
272 .map(x => x.t)
273 const shown = isPaneOpen ? [] : isCompact ? open.filter(t => t.status === 'doing').slice(0, 1) : open.slice(0, BAND_ROWS)
274 // Folded, the summary already says how many are open.
275 const more = isCompact ? 0 : open.length - shown.length
276
277 // Other mods draw in this band too: draw ours on top of whatever they draw, never instead of it.
278 const below = await next(e)
279
280 return (
281 <Box flexDirection="column">
282 <Box flexDirection="column">
283 <Box flexDirection="row" gap={1}>
284 {/* The whole summary is one control: a click, or Enter with the band focused, folds the list. */}
285 <Button
286 key="compact"
287 plain
288 onPress={() => void update($, compact, v => !v)}
289 >
290 <Text color="claude">◆</Text> <Text color="success">{bar(done, total, 8)}</Text>{' '}
291 <Text dimColor>{done}/{total}</Text>{' '}
292 {open.length === 0 ? <Text color="success">all done </Text> : null}
293 <Text dimColor>{isCompact ? `▸ ${open.length} open` : '▾'}</Text>
294 </Button>
295 <Button key="open" label="board" dimColor onPress={() => void openPane($)} />
296 </Box>
297 {shown.map(t => (
298 <Box key={`band:${t.id}`} flexDirection="row" gap={1} paddingLeft={2}>
299 <Text color={TONE[t.status]}>{GLYPH[t.status]}</Text>
300 <Text dimColor tabular>#{t.id}</Text>
301 <Text color={t.status === 'doing' ? 'warning' : undefined} wrap="truncate-end">{t.title}</Text>
302 {t.note ? <Text dimColor italic wrap="truncate-end">{t.note}</Text> : null}
303 </Box>
304 ))}
305 {more > 0 ? <Text dimColor>{' '}+{more} more · /docket</Text> : null}
306 </Box>
307 {below}
308 </Box>
309 )
310 })
311
312 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
313 const ui = $.ui.resolve(e)
314 const { Box, Button, Text } = ui
315 // The mobile surface has no fields; the board stays readable and pressable there.
316 const Input = 'Input' in ui ? ui.Input : null
317 const Select = 'Select' in ui ? ui.Select : null
318 const b = await read($, board)
319 const err = await read($, error)
320 const mode: Filter = await read($, filter)
321 const target = (await read($, addTo)) || b?.lists[0]?.name || 'Inbox'
322
323 const counts = { todo: 0, doing: 0, done: 0, blocked: 0 }
324 for (const t of b ? allItems(b) : []) counts[t.status] += 1
325 const { done, total } = b ? progress(b) : { done: 0, total: 0 }
326
327 return (
328 <Box flexDirection="column" paddingX={1}>
329 <Box flexDirection="row" gap={1}>
330 <Text color="claude">◆</Text>
331 <Text bold>{b?.title ?? 'Docket'}</Text>
332 <Text color="success">{bar(done, total, 16)}</Text>
333 <Text dimColor tabular>{done}/{total}</Text>
334 </Box>
335 <Box flexDirection="row" gap={2} marginBottom={1}>
336 <Text dimColor>
337 <Text color="warning">◐ {counts.doing}</Text> doing · <Text color="error">⊘ {counts.blocked}</Text> blocked · ○ {counts.todo} todo · <Text color="success">● {counts.done}</Text> done
338 </Text>
339 {Select ? (<Select
340 key="filter"
341 value={mode}
342 options={[
343 { value: 'all', label: 'all' },
344 { value: 'open', label: 'open' },
345 ]}
346 onSelect={(v: string) => void update($, filter, () => v as Filter)}
347 />) : null}
348 </Box>
349
350 {err ? <Text color="error">{err}</Text> : null}
351 {!b || b.lists.length === 0 ? (
352 <Text dimColor>No tasks yet. Ask Claude to plan the work, or add one below.</Text>
353 ) : null}
354
355 {(b?.lists ?? []).map(list => {
356 const shown = mode === 'open' ? list.items.filter(t => t.status !== 'done') : list.items
357 const listDone = list.items.filter(t => t.status === 'done').length
358 if (mode === 'open' && shown.length === 0) return null
359
360 return (
361 <Box key={`list:${list.name}`} flexDirection="column" marginBottom={1}>
362 <Button key={`toggle:${list.name}`} plain onPress={() => void mutate($, x => toggleList(x, list.name))}>
363 <Text dimColor>{list.collapsed ? '▸' : '▾'}</Text> <Text bold>{list.name}</Text>{' '}
364 <Text dimColor tabular>{listDone}/{list.items.length}</Text>
365 </Button>
366 {list.collapsed
367 ? null
368 : shown.map(t => (
369 <Box key={`row:${t.id}`} flexDirection="row" paddingLeft={2}>
370 <Button key={`item:${t.id}`} plain onPress={() => void mutate($, x => cycleItem(x, t.id, now()))}>
371 <Text color={TONE[t.status]}>{GLYPH[t.status]}</Text>{' '}
372 <Text dimColor tabular>#{t.id}</Text>{' '}
373 <Text dimColor={t.status === 'done'} strikethrough={t.status === 'done'}>{t.title}</Text>
374 {t.note ? <Text dimColor italic> {t.note}</Text> : null}
375 </Button>
376 </Box>
377 ))}
378 </Box>
379 )
380 })}
381
382 <Box flexDirection="row" gap={1}>
383 {Select && b && b.lists.length > 1 ? (
384 <Select
385 key="addTo"
386 value={target}
387 options={b.lists.map(l => ({ value: l.name, label: l.name }))}
388 onSelect={(v: string) => void update($, addTo, () => v)}
389 />
390 ) : null}
391 {Input ? (<Input
392 key="add"
393 placeholder="add a task…"
394 submitLabel="add"
395 onSubmit={(v: string) => {
396 if (v.trim()) void mutate($, x => addItem(x, target, v, 'todo', now()).board)
397 }}
398 />) : null}
399 {counts.done > 0 ? <Button key="clear" label="clear done" dimColor onPress={() => void mutate($, clearDone)} /> : null}
400 </Box>
401 <Text dimColor>enter cycles ○ → ◐ → ● · esc closes · {FILE}</Text>
402 </Box>
403 )
404 })
405}
406hooks/board.ts 205 lines1// Pure board model: parse, mutate, render. No `$` in here, so every rule is testable.
2import type { Board, Item, List, Status } from '../types'
3
4export const STATUSES: readonly Status[] = ['todo', 'doing', 'done', 'blocked']
5export const GLYPH: Record<Status, string> = { todo: '○', doing: '◐', done: '●', blocked: '⊘' }
6const CYCLE: Record<Status, Status> = { todo: 'doing', doing: 'done', done: 'todo', blocked: 'todo' }
7
8export function emptyBoard(title = 'Tasks'): Board {
9 return { version: 1, title, nextId: 1, lists: [] }
10}
11
12function asStatus(v: unknown): Status {
13 return STATUSES.includes(v as Status) ? (v as Status) : 'todo'
14}
15
16/** Lenient parse: a hand-edited file with missing fields still loads; ids are repaired. */
17export function parseBoard(text: string): Board {
18 const raw = JSON.parse(text)
19 if (!raw || typeof raw !== 'object' || !Array.isArray(raw.lists)) {
20 throw new Error('expected an object with a "lists" array')
21 }
22 const seen = new Set<number>()
23 let max = 0
24 const lists: List[] = raw.lists.map((l: any) => ({
25 name: String(l?.name ?? 'Untitled'),
26 ...(l?.collapsed ? { collapsed: true } : {}),
27 items: (Array.isArray(l?.items) ? l.items : []).map((t: any) => {
28 const id = Number.isInteger(t?.id) && t.id > 0 && !seen.has(t.id) ? t.id : 0
29 if (id) {
30 seen.add(id)
31 max = Math.max(max, id)
32 }
33 const item: Item = {
34 id,
35 title: String(t?.title ?? t?.text ?? ''),
36 status: asStatus(t?.status),
37 updated: typeof t?.updated === 'string' ? t.updated : '',
38 }
39 if (typeof t?.note === 'string' && t.note) item.note = t.note
40 return item
41 }),
42 }))
43 let next = Math.max(max + 1, Number.isInteger(raw.nextId) ? raw.nextId : 1)
44 for (const l of lists) for (const t of l.items) if (!t.id) t.id = next++
45 return { version: 1, title: String(raw.title ?? 'Tasks'), nextId: next, lists }
46}
47
48export function serialize(board: Board): string {
49 return JSON.stringify(board, null, 2) + '\n'
50}
51
52export function allItems(board: Board): Item[] {
53 return board.lists.flatMap(l => l.items)
54}
55
56export function progress(board: Board): { done: number; total: number } {
57 const items = allItems(board)
58 return { done: items.filter(t => t.status === 'done').length, total: items.length }
59}
60
61/** A bar of `width` cells; empty boards draw an empty bar, never NaN. */
62export function bar(done: number, total: number, width = 10): string {
63 const filled = total === 0 ? 0 : Math.round((done / total) * width)
64 return '━'.repeat(filled) + '─'.repeat(width - filled)
65}
66
67export function findItem(board: Board, id: number): { list: List; item: Item } | null {
68 for (const list of board.lists) {
69 const item = list.items.find(t => t.id === id)
70 if (item) return { list, item }
71 }
72 return null
73}
74
75function findList(board: Board, name: string): List | undefined {
76 const key = name.trim().toLowerCase()
77 return board.lists.find(l => l.name.toLowerCase() === key)
78}
79
80// Every mutation returns a NEW board, so a failed write never leaves state half-changed.
81function clone(board: Board): Board {
82 return JSON.parse(JSON.stringify(board))
83}
84
85export function addItem(board: Board, listName: string, title: string, status: Status = 'todo', now = ''): { board: Board; item: Item } {
86 const b = clone(board)
87 const name = listName.trim() || 'Inbox'
88 let list = findList(b, name)
89 if (!list) {
90 list = { name, items: [] }
91 b.lists.push(list)
92 }
93 const item: Item = { id: b.nextId++, title: title.trim(), status, updated: now }
94 list.items.push(item)
95 return { board: b, item }
96}
97
98export type Patch = { title?: string; status?: Status; note?: string; list?: string }
99
100export function updateItem(board: Board, id: number, patch: Patch, now = ''): Board {
101 const b = clone(board)
102 const hit = findItem(b, id)
103 if (!hit) throw new Error(`no item #${id}`)
104 const { item } = hit
105 if (patch.title !== undefined) item.title = patch.title.trim()
106 if (patch.status !== undefined) item.status = asStatus(patch.status)
107 if (patch.note !== undefined) {
108 if (patch.note) item.note = patch.note
109 else delete item.note
110 }
111 if (patch.list !== undefined && patch.list.trim().toLowerCase() !== hit.list.name.toLowerCase()) {
112 hit.list.items = hit.list.items.filter(t => t.id !== id)
113 let target = findList(b, patch.list)
114 if (!target) {
115 target = { name: patch.list.trim(), items: [] }
116 b.lists.push(target)
117 }
118 target.items.push(item)
119 }
120 item.updated = now
121 return b
122}
123
124export function cycleItem(board: Board, id: number, now = ''): Board {
125 const hit = findItem(board, id)
126 if (!hit) return board
127 return updateItem(board, id, { status: CYCLE[hit.item.status] }, now)
128}
129
130export function removeItem(board: Board, id: number): Board {
131 const b = clone(board)
132 if (!findItem(b, id)) throw new Error(`no item #${id}`)
133 for (const l of b.lists) l.items = l.items.filter(t => t.id !== id)
134 return b
135}
136
137export function toggleList(board: Board, name: string): Board {
138 const b = clone(board)
139 const list = findList(b, name)
140 if (list) list.collapsed = !list.collapsed
141 if (list && !list.collapsed) delete list.collapsed
142 return b
143}
144
145/** Drops done items and then lists left empty. */
146export function clearDone(board: Board): Board {
147 const b = clone(board)
148 for (const l of b.lists) l.items = l.items.filter(t => t.status !== 'done')
149 b.lists = b.lists.filter(l => l.items.length > 0)
150 return b
151}
152
153export type PlanInput = { title?: string; lists: { name: string; items: (string | { title: string; status?: Status })[] }[] }
154
155/** Builds a whole board in one call. Replacing a board that has open work is the caller's decision. */
156export function planBoard(input: PlanInput, now = ''): Board {
157 let b = emptyBoard(input.title?.trim() || 'Tasks')
158 for (const l of input.lists ?? []) {
159 if (!l.items?.length) b.lists.push({ name: l.name, items: [] })
160 for (const raw of l.items ?? []) {
161 const t = typeof raw === 'string' ? { title: raw } : raw
162 b = addItem(b, l.name, t.title, asStatus(t.status), now).board
163 }
164 }
165 return b
166}
167
168/** Compact text the model reads: one line per item, ids first so it can address them. */
169export function renderText(board: Board): string {
170 const { done, total } = progress(board)
171 const lines = [`${board.title} — ${done}/${total} done`]
172 for (const l of board.lists) {
173 const d = l.items.filter(t => t.status === 'done').length
174 lines.push('', `## ${l.name} (${d}/${l.items.length})`)
175 for (const t of l.items) {
176 lines.push(`#${t.id} ${GLYPH[t.status]} ${t.status.padEnd(7)} ${t.title}${t.note ? ` — ${t.note}` : ''}`)
177 }
178 }
179 return lines.join('\n')
180}
181
182/** Mirror of the engine's TodoWrite list into one list, matched by title so ids stay stable. */
183export function mirrorTodos(board: Board, listName: string, todos: { content: string; status: string }[], now = ''): Board {
184 const map: Record<string, Status> = { pending: 'todo', in_progress: 'doing', completed: 'done' }
185 let b = clone(board)
186 const existing = findList(b, listName)
187 const byTitle = new Map((existing?.items ?? []).map(t => [t.title, t]))
188 const keep = new Set<number>()
189 for (const todo of todos) {
190 const status = map[todo.status] ?? 'todo'
191 const hit = byTitle.get(todo.content.trim())
192 if (hit) {
193 keep.add(hit.id)
194 if (hit.status !== status) b = updateItem(b, hit.id, { status }, now)
195 } else {
196 const r = addItem(b, listName, todo.content, status, now)
197 b = r.board
198 keep.add(r.item.id)
199 }
200 }
201 const list = findList(b, listName)
202 if (list) list.items = list.items.filter(t => keep.has(t.id))
203 return b
204}
205types/index.d.ts 12 lines1export type Status = 'todo' | 'doing' | 'done' | 'blocked'
2export type Item = { id: number; title: string; status: Status; note?: string; updated: string }
3export type List = { name: string; collapsed?: boolean; items: Item[] }
4export type Board = { version: 1; title: string; nextId: number; lists: List[] }
5export type Filter = 'all' | 'open'
6
7declare module 'claude-code' {
8 interface PluginState {
9 docket: { board: Board | null; error: string | null; filter: Filter; addTo: string; compact: boolean; paneOpen: boolean }
10 }
11}
12