SLOPSHOPPER

docket

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.

newpanebandguardcommandprompt
★ 1v0.1.0MITupdated 2026-10-15michalstrnadel/claude-docket
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · docket
│ ┃ Docket ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Docket ──────────────── 0/0 │ ┃ ◐ 0 doing · ⊘ 0 blocked · ○ 0 todo · ● 0 ⏺ Read(src/auth.ts) │ ┃ done ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No tasks yet. Ask Claude to plan the work, ⎿ Added 2 lines, removed 1 line │ ┃ or add one below. ⏺ Bash(bun test) │ ┃ add a task… ⏎ add ⎿ 3 pass, 1 fail │ ┃ enter cycles ○ → ◐ → ● · esc closes · │ ┃ .claude/docket.json ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /docket │ ⎿ docket: board opened · esc closes │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Docket
◆ Docket ──────────────── 0/0 ◐ 0 doing · ⊘ 0 blocked · ○ 0 todo · ● 0 done all ▾ No tasks yet. Ask Claude to plan the work, or add one below. add a task… ⏎ add enter cycles ○ → ◐ → ● · esc closes · .claude/docket.json
README

◆ docket

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.

License: MIT Claude Code mod Version

<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">


Why

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.

What it does

Band above the promptProgress 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 usesClaude 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-proofThe 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 modsDraws on top of whatever other bands draw, never instead of them. While the board is open the band shrinks to one line.
Hand-editableEdit 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, mirroredWhere 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>

Install

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.

Use it

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."

CommandDoes
/docketOpen the board pane (Esc closes it).
/dkFold or unfold the band.
ctrl+x tabFocus the band; Enter on the summary row folds it too.
ctrl+x ctrl+aClaude Code's own: hide or show everything above the prompt.

The file

// .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.

How it works

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.)
  • The board model (hooks/board.ts) is pure functions over an immutable board — every rule is unit-tested, and a failed write never leaves state half-changed.

Develop

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.

License

MIT © Michal Strnadel

Source 3 files
hooks/register.tsx 406 lines
1import { 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}
406
hooks/board.ts 205 lines
1// 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}
205
types/index.d.ts 12 lines
1export 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