Two-level task board in a side pane: goals with their own To do / Doing / Done subtasks, a Haiku-filed inbox for your asks, and unfinished goals carried over…

A two-level To do / Doing / Done board in a side pane: goals, subtasks, a Haiku-filed inbox, and unfinished work carried over per directory.
<img src="docs/board.png" alt="The Board pane: a progress bar, To do, Doing and Done columns of cards marked as Claude's or from a plan, and a New card button" width="380">
<sub>The screenshot shows made-up cards.</sub>
/board opens the pane.inbox for Haiku to file, and can list, add, move and remove cards. add accepts a top goal's id or exact title as parent to add a subtask./board again closes it directly.✦ marks Claude's cards, ◆ a plan's, ≡ one with a description, and ✧ a Haiku filing. /board opens the pane at any time. Done shows its latest five cards; a live board keeps at most 80, dropping the oldest done ones first. Removing a goal removes its subtasks too.
Claude's to-do list is the truth for its own cards: when Claude updates the list, its cards follow, even ones you moved. Cards you or the board tool added are yours to move.
Top cards are goals or phases; each has its own To do / Doing / Done subtask board. The main progress bar counts goals, and ▸ done/total on a goal counts its subtasks. Open a goal's details and choose Subtasks › to drill in, then ‹ Back to return. Claude's TodoWrite and TaskCreate items attach to the last goal in Doing, or to a reusable Session goal created on demand. + New card inside a goal adds a subtask; Make goal and Move under… correct placement while keeping at most two levels.
Messages queued while Claude works are filed automatically (except slash commands). For other real asks, Claude calls the board tool's inbox action with the ask text; Haiku decides whether to add a goal or a subtask. The Inbox row shows the ask while filing, and successful filings carry ✧. If Haiku fails, times out, or returns an invalid filing, the ask's first line becomes a new To do goal.
When a session ends, unfinished goals and all their subtasks are saved per working directory; Done goals are dropped. Carried over groups them by the session they came from and shows the days left before archive. Groups older than 14 days move to Archive at session start (or when /board reloads after /clear). Pull in moves a goal and its subtasks from either list to the live board: the goal starts in To do, subtask columns are kept, and empty groups disappear.
Session labels: when the set of top-goal titles changes, background Haiku (low effort, 10-second timeout) names the session in 3–6 words. The first change calls immediately; rapid changes are debounced to at most one call per three minutes, with one trailing call after three quiet minutes using the latest goals. The latest successful label stays in session state; failures keep the previous label. Session end makes no model call and saves that label immediately. If there is no label yet, it saves <Mon D> · <first goal title> and Haiku relabels the group at the next session start in that directory.
/plugin marketplace add MarcoCarnevali/claude-code-mods
/plugin install task-board@marco-mods
/reload-pluginshooks/register.tsx 842 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Next, PaneCloseInput, Register } from 'claude-code'
3
4import type { Card, Column, InboxItem, Stash, View } from '../types'
5import {
6 BOARD_TOOL_DESCRIPTION,
7 BOARD_TOOL_SCHEMA,
8 COLUMNS,
9 COLUMN_NAME,
10 DONE_SHOWN,
11 addCard,
12 currentGoal,
13 editCard,
14 goals,
15 makeGoal,
16 moveCard,
17 moveUnder,
18 neighbour,
19 planSteps,
20 progressOf,
21 removeCard,
22 runBoardTool,
23 sourceName,
24 subtasksOf,
25 syncTodos,
26 taskCreated,
27 taskUpdated,
28 workPrompt,
29} from './lib'
30import type { BoardToolInput } from './lib'
31import { columnsHeightFor } from './columns'
32import type { ColumnsMessage, ColumnsProps } from './columns'
33import { EMPTY_STASH, carryOver, daysLeft, expire, fallbackLabel, pullIn, readStash, stashKey, storedGoalKey } from './carry'
34import type { StoredGoal } from './carry'
35import { FILER_SYSTEM, LABEL_SYSTEM, applyFiling, fallbackFiling, filerPrompt, labelPrompt, parseFiling, parseLabel } from './filer'
36import { labelsChanged, runningLabels, startLabels, stopLabels } from './labels'
37
38const PANE = 'task-board'
39const TITLE = 'Board'
40const TOOL = 'board'
41// Theme colors follow the light or dark scheme by themselves; the green is one that reads on both.
42const ACCENT = 'claude'
43const GREEN = '#2da44e'
44/** Each column's dot. */
45const DOT: Record<Column, { color?: string }> = { todo: {}, doing: { color: ACCENT }, done: { color: GREEN } }
46/** The mark in a card's footer: who put it there (✦ Claude, ◆ a plan); the card's details say it in words. */
47const TAG: Record<Card['source'], string> = { you: '', todo: '✦', claude: '✦', plan: '◆' }
48/** Rows above the columns (the progress row and the blank under it) and below them (the pane's last blank). */
49const HEADER_ROWS = 3
50/** Cells in the progress bar. */
51const BAR = 12
52
53const cards = atom({ plugin: 'task-board', key: 'cards' } as const, [])
54const selected = atom({ plugin: 'task-board', key: 'selected' } as const, null)
55const composer = atom({ plugin: 'task-board', key: 'composer' } as const, null)
56const working = atom({ plugin: 'task-board', key: 'working' } as const, null)
57const inbox = atom({ plugin: 'task-board', key: 'inbox' } as const, [] as InboxItem[])
58const view = atom({ plugin: 'task-board', key: 'view' } as const, { kind: 'top' } as View)
59const stash = atom({ plugin: 'task-board', key: 'stash' } as const, EMPTY_STASH as Stash)
60const sessionLabel = atom({ plugin: 'task-board', key: 'sessionLabel' } as const, null)
61
62/** The board's full tool name, as Claude calls it: `mcp__task-board__board`, else what registering answered. */
63let toolName = `mcp__task-board__${TOOL}`
64
65/** Writes against the latest board and clears UI state invalidated by the mutation. */
66async function setCards($: EngineInterface, next: (current: Card[]) => Card[], isClaude: boolean): Promise<void> {
67 // Evaluate against the atom's current value so simultaneous filings do not overwrite each other.
68 await update($, cards, current => next(current))
69 const board = await read($, cards)
70 let where = await read($, view)
71 const goalId = where.kind === 'goal' ? where.id : null
72 if (goalId !== null && !goals(board).some(card => card.id === goalId)) {
73 await update($, view, (current): View => current.kind === 'goal' && !goals(board).some(card => card.id === current.id) ? { kind: 'top' } : current)
74 await update($, selected, () => null)
75 await update($, composer, () => null)
76 where = await read($, view)
77 }
78 const visible = where.kind === 'goal' ? subtasksOf(board, where.id) : where.kind === 'top' ? goals(board) : []
79 const openId = await read($, selected)
80 if (openId !== null && !visible.some(card => card.id === openId)) await update($, selected, current => current === openId ? null : current)
81 const draft = await read($, composer)
82 if (draft?.id != null && !visible.some(card => card.id === draft.id)) await update($, composer, current => current?.id === draft.id ? null : current)
83 // Opens only on /board (local change: the upstream mod opened the pane on Claude's first cards).
84}
85
86const HAIKU = { model: 'haiku', effort: 'low', timeoutMs: 10_000 } as const
87
88async function sessionId($: EngineInterface): Promise<string> {
89 return $.session.id().catch(() => '')
90}
91
92/** A queue per registration; failures do not poison subsequent mutations. */
93function serialQueue() {
94 let tail: Promise<unknown> = Promise.resolve()
95 return <T,>(run: () => Promise<T>): Promise<T> => {
96 const result = tail.then(run)
97 tail = result.catch(() => {})
98 return result
99 }
100}
101
102type Serial = ReturnType<typeof serialQueue>
103type InboxActions = { serial: Serial; running: Set<string> }
104
105async function resumeAsk($: EngineInterface, item: InboxItem, actions: InboxActions): Promise<void> {
106 if (actions.running.has(item.id)) return
107 actions.running.add(item.id)
108 try {
109 const at = item.createdAt ?? await $.clock.now()
110 const sid = item.sessionId ?? await sessionId($)
111 if (!(await read($, inbox)).some(pending => pending.id === item.id)) return
112 const board = await read($, cards)
113 const reply = await $.model.complete({ ...HAIKU, system: FILER_SYSTEM, prompt: filerPrompt(item.text, board) }).catch(() => null)
114 await actions.serial(async () => {
115 // session.end may already have consumed this item as a fallback goal.
116 if (!(await read($, inbox)).some(pending => pending.id === item.id)) return
117 await setCards($, current => {
118 let filing = reply?.isAnswered ? parseFiling(reply.text, current) : null
119 if (filing?.kind === 'subtask') {
120 const parentId = filing.parentId
121 const original = board.find(card => card.id === parentId)
122 const live = current.find(card => card.id === parentId)
123 // Validate both the reply's id and the lifetime that Haiku saw.
124 if (original === undefined || live === undefined || original.incarnation !== live.incarnation
125 || (original.incarnation === undefined && (original.createdAt !== live.createdAt || original.sessionId !== live.sessionId || original.title !== live.title))) filing = null
126 }
127 return applyFiling(current, filing ?? fallbackFiling(item.text), { at, sessionId: sid, byHaiku: filing !== null, inboxId: item.id })
128 }, true)
129 await update($, inbox, items => items.filter(pending => pending.id !== item.id))
130 })
131 } finally {
132 actions.running.delete(item.id)
133 }
134}
135
136async function fileAsk($: EngineInterface, text: string, actions: InboxActions): Promise<void> {
137 const at = await $.clock.now()
138 const item = { id: `i${at.toString(36)}${Math.random().toString(36).slice(2, 6)}`, text, createdAt: at }
139 await actions.serial(() => update($, inbox, items => [...items, item]))
140 void resumeAsk($, item, actions).catch(() => {})
141}
142
143async function recoverInbox($: EngineInterface, actions: InboxActions): Promise<void> {
144 for (const item of await read($, inbox)) void resumeAsk($, item, actions).catch(() => {})
145}
146
147async function endingBoard($: EngineInterface, sid: string, at: number, actions: InboxActions): Promise<Card[]> {
148 return actions.serial(async () => {
149 const pending = await read($, inbox)
150 // No model wait: carry every pending ask as a first-line, 60-character top goal.
151 await setCards($, current => pending.reduce((board, item) => applyFiling(board, fallbackFiling(item.text), {
152 at: item.createdAt ?? at, sessionId: item.sessionId ?? sid, byHaiku: false, inboxId: item.id,
153 }), current), true)
154 await update($, inbox, items => items.filter(item => !pending.some(taken => taken.id === item.id)))
155 return read($, cards)
156 })
157}
158
159async function mutateStash($: EngineInterface, next: (current: Stash) => Promise<Stash>, serial: Serial): Promise<Stash> {
160 return serial(async () => {
161 const key = stashKey(await $.session.root())
162 // Storage is authoritative; compute each operation only after its predecessor committed.
163 const latest = readStash(await $.store.get(key))
164 const fresh = await next(latest)
165 if (fresh !== latest) await $.store.set(key, fresh)
166 await update($, stash, () => fresh)
167 return fresh
168 })
169}
170
171async function relabel($: EngineInterface, current: Stash, serial: Serial): Promise<void> {
172 for (const group of [...current.carried, ...current.archive]) {
173 if (group.isLabelled) continue
174 const reply = await $.model.complete({ ...HAIKU, system: LABEL_SYSTEM, prompt: labelPrompt(group) }).catch(() => null)
175 const label = reply?.isAnswered ? parseLabel(reply.text) : null
176 if (label === null) continue
177 const name = (groups: Stash['carried']) => groups.map(g => g.sessionId === group.sessionId && g.endedAt === group.endedAt
178 ? { ...g, label, isLabelled: true, cards: g.cards.map(card => ({ ...card, sessionLabel: label })) } : g)
179 await mutateStash($, async latest => ({ carried: name(latest.carried), archive: name(latest.archive) }), serial)
180 }
181}
182
183async function loadStash($: EngineInterface, serial: Serial): Promise<void> {
184 let moved = 0
185 const fresh = await mutateStash($, async latest => {
186 const expired = expire(latest, await $.clock.now())
187 moved = expired.moved
188 return expired.stash
189 }, serial)
190 if (moved > 0) $.ui.status(`task-board: archived ${moved} old group${moved === 1 ? '' : 's'}`)
191 void relabel($, fresh, serial).catch(() => {})
192}
193
194/** Opens the composer: empty for a new card, or holding `card` to edit it. */
195async function compose($: EngineInterface, card: Card | null): Promise<void> {
196 await update($, composer, () =>
197 card === null
198 ? { id: null, title: '', note: '', hasNote: false }
199 : { id: card.id, title: card.title, note: card.note, hasNote: false },
200 )
201 await $.ui.focus({ requestId: PANE, key: 'title' }).catch(() => undefined)
202}
203
204/** Swaps the Title field for the Description field, and moves the cursor into it. */
205async function showNote($: EngineInterface): Promise<void> {
206 await update($, composer, current => (current === null ? current : { ...current, hasNote: true }))
207 await $.ui.focus({ requestId: PANE, key: 'note' }).catch(() => undefined)
208}
209
210/** Back to the Title field. */
211async function showTitle($: EngineInterface): Promise<void> {
212 await update($, composer, current => (current === null ? current : { ...current, hasNote: false }))
213 await $.ui.focus({ requestId: PANE, key: 'title' }).catch(() => undefined)
214}
215
216async function typeIn($: EngineInterface, field: 'title' | 'note', text: string): Promise<void> {
217 await update($, composer, current => (current === null ? current : { ...current, [field]: text }))
218}
219
220/** Saves what the composer holds: a new card in To do, or the edited card; then closes it. */
221async function saveComposer($: EngineInterface): Promise<void> {
222 const draft = await read($, composer)
223 if (draft === null) return
224 if (draft.title.trim() === '') {
225 $.ui.toast('Give the card a title.')
226 await $.ui.focus({ requestId: PANE, key: 'title' }).catch(() => undefined)
227 return
228 }
229 const at = await $.clock.now()
230 const id = draft.id
231 const where = await read($, view)
232 const sid = await sessionId($)
233 await setCards(
234 $,
235 current =>
236 id === null
237 ? addCard(current, { title: draft.title, note: draft.note, source: 'you', parentId: where.kind === 'goal' ? where.id : null, sessionId: sid }, at).cards
238 : editCard(current, id, { title: draft.title, note: draft.note }),
239 false,
240 )
241 await update($, composer, () => null)
242}
243
244async function select($: EngineInterface, id: string | null): Promise<void> {
245 await update($, selected, current => (current === id ? null : id))
246}
247
248async function move($: EngineInterface, id: string, column: Column): Promise<void> {
249 await setCards($, current => moveCard(current, id, column), false)
250}
251
252async function remove($: EngineInterface, id: string): Promise<void> {
253 await setCards($, current => removeCard(current, id), false)
254 if ((await read($, selected)) === id) await update($, selected, () => null)
255}
256
257/**
258 * Hands Claude a card: the card moves to Doing and Claude gets a prompt to
259 * work on it. Claude Code holds a prompt a mod sends until Claude is idle, so
260 * a toast says when it waits.
261 */
262async function workOn($: EngineInterface, card: Card): Promise<void> {
263 if ((await read($, working)) === card.id) {
264 $.ui.toast('Claude already has this card.')
265 return
266 }
267 await update($, working, () => card.id)
268 await move($, card.id, 'doing')
269 let isStarted = false
270 const submitted = $.prompt.submit({ text: workPrompt(card) }).then(
271 result => {
272 isStarted = result.drop === undefined
273 },
274 () => {
275 isStarted = false
276 },
277 )
278 let isSettled = false
279 void submitted.then(() => {
280 isSettled = true
281 })
282 await Promise.race([submitted, $.clock.sleep(500)])
283 if (!isSettled) $.ui.toast('Queued: Claude starts on this card once it finishes the current task.', { timeoutMs: 8_000 })
284 await submitted
285 if (!isStarted && (await read($, working)) === card.id) {
286 await update($, working, () => null)
287 $.ui.toast('The card was not handed to Claude.')
288 }
289}
290
291/** Person closes navigate up; plugin closes always continue to the engine. */
292export async function closePane($: EngineInterface, e: PaneCloseInput, next: Next<'ui.close'>) {
293 if (e.id !== PANE || e.origin.kind !== 'person') return next(e)
294 const where = await read($, view)
295 if (where.kind === 'carried' || where.kind === 'archive') {
296 await update($, selected, () => null)
297 await update($, composer, () => null)
298 await update($, view, (): View => ({ kind: 'top' }))
299 return { value: undefined }
300 }
301 if ((await read($, composer)) !== null) {
302 await update($, composer, () => null)
303 return { value: undefined }
304 }
305 if ((await read($, selected)) !== null) {
306 await update($, selected, () => null)
307 return { value: undefined }
308 }
309 if (where.kind !== 'top') {
310 await update($, view, (): View => ({ kind: 'top' }))
311 return { value: undefined }
312 }
313 return next(e)
314}
315
316export const register: Register = on => {
317 const stashSerial = serialQueue()
318 const filing: InboxActions = { serial: serialQueue(), running: new Set() }
319 const labels = runningLabels()
320 on('state.set', { plugin: 'task-board', key: 'cards' }, async ($, e, next) => {
321 const result = await next(e)
322 if (result.value?.isSet) await labelsChanged($, labels).catch(() => {})
323 return result
324 })
325 on('session.start', async ($, e, next) => {
326 startLabels(labels)
327 await $.command.register({ name: 'board', description: "Show the session's task board: To do, Doing and Done", immediate: true }).catch(() => {}) // the built-in /tasks refuses the old name
328 const registered = await $.tool.register({ name: TOOL, description: BOARD_TOOL_DESCRIPTION, inputSchema: BOARD_TOOL_SCHEMA })
329 toolName = registered.tool
330
331 const result = await next(e)
332 await loadStash($, stashSerial)
333 await recoverInbox($, filing)
334 await labelsChanged($, labels, true).catch(() => {})
335 return result
336 })
337
338 on('session.end', async ($, e, next) => {
339 // Neither draining the inbox nor a pending trailing label can start a model call at end.
340 stopLabels(labels, e.sessionId)
341 const endedAt = await $.clock.now()
342 await mutateStash($, async latest => {
343 const board = await endingBoard($, e.sessionId, endedAt, filing)
344 // A live-first pull can have an unacknowledged store write. Remove those
345 // sources in the same persisted mutation that carries the live board.
346 const transfers = new Set(board.flatMap(card => card.pulledFrom === undefined ? [] : [card.pulledFrom]))
347 const acknowledge = (groups: Stash['carried'], list: StoredGoal['list']) => groups.map(group => {
348 const taken = new Set(goals(group.cards).filter(card => transfers.has(storedGoalKey({ list, sessionId: group.sessionId, endedAt: group.endedAt, id: card.id }))).map(card => card.id))
349 return { ...group, cards: group.cards.filter(card => !taken.has(card.id) && !taken.has(card.parentId ?? '')) }
350 }).filter(group => group.cards.length > 0)
351 const acknowledged = { carried: acknowledge(latest.carried, 'carried'), archive: acknowledge(latest.archive, 'archive') }
352 const label = await read($, sessionLabel)
353 const group = carryOver(board, { sessionId: e.sessionId, label: label ?? fallbackLabel(board, endedAt), endedAt })
354 if (group !== null) group.isLabelled = label !== null
355 return group === null && transfers.size === 0 ? latest : { ...acknowledged, carried: [...acknowledged.carried, ...(group === null ? [] : [group])] }
356 }, stashSerial)
357 return next(e)
358 })
359
360 on('prompt.submit', async ($, e, next) => {
361 if (e.turnId !== undefined && !e.text.trimStart().startsWith('/') && e.text.trim() !== '') await fileAsk($, e.text, filing).catch(() => {})
362 return next(e)
363 })
364
365 on('command.run', { command: 'board' }, async $ => {
366 // Run again to close.
367 if ((await $.ui.panes()).some(p => p.id === PANE)) {
368 await $.ui.close({ id: PANE })
369 return { text: 'Board closed.' }
370 }
371 // /clear changes session id without emitting session.start; /board reloads the directory stash.
372 await loadStash($, stashSerial)
373 await $.ui.open({ id: PANE, title: TITLE, closeOnEscape: true })
374
375 return { text: 'Board opened.' }
376 })
377
378 on('ui.close', closePane)
379
380 // Claude's to-do list, in either of the tools it keeps it with.
381 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
382 const ran = await next(e)
383 if (ran.deny === undefined && ran.isError !== true) {
384 const at = await $.clock.now()
385 const sid = await sessionId($)
386 await setCards($, current => syncTodos(current, e.todos, at, sid), true)
387 }
388
389 return ran
390 })
391
392 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
393 const ran = await next(e)
394 const task = (ran.result as { task?: { id?: unknown } } | undefined)?.task
395 if (ran.deny === undefined && ran.isError !== true && typeof task?.id === 'string') {
396 const at = await $.clock.now()
397 const id = task.id
398 const sid = await sessionId($)
399 await setCards($, current => taskCreated(current, { id, subject: e.subject, description: e.description }, at, sid), true)
400 }
401
402 return ran
403 })
404
405 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
406 const ran = await next(e)
407 if (ran.deny === undefined && ran.isError !== true) {
408 const sid = await sessionId($)
409 await setCards(
410 $,
411 current =>
412 taskUpdated(current, {
413 taskId: e.taskId,
414 ...(e.status === undefined ? {} : { status: e.status }),
415 ...(e.subject === undefined ? {} : { subject: e.subject }),
416 ...(e.description === undefined ? {} : { description: e.description }),
417 }, sid),
418 true,
419 )
420 }
421
422 return ran
423 })
424
425 // Concrete approved-plan steps belong to the active goal, else Session.
426 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
427 const ran = await next(e)
428 const plan = (ran.result as { plan?: unknown } | undefined)?.plan
429 if (ran.deny === undefined && ran.isError !== true && typeof plan === 'string') {
430 const at = await $.clock.now()
431 const sid = await sessionId($)
432 await setCards(
433 $,
434 current => {
435 const steps = planSteps(plan)
436 if (steps.length === 0) return current
437 const goal = currentGoal(current, at, sid)
438 return steps.reduce((board, step) => addCard(board, { title: step, source: 'plan', parentId: goal.id, sessionId: sid }, at).cards, goal.cards)
439 },
440 true,
441 )
442 }
443
444 return ran
445 })
446
447 // The board tool Claude calls: answered here, never passed on.
448 on('tool.call', async ($, e, next) => {
449 if (e.tool !== toolName) return next(e)
450 const input = e as unknown as BoardToolInput
451 if (input.action === 'inbox') {
452 const text = input.text
453 if (typeof text !== 'string' || text.trim() === '') return { result: 'inbox needs text.', text: 'inbox needs text.', isError: true as const }
454 if (text.trimStart().startsWith('/')) return { result: 'Slash commands are not inbox asks.', text: 'Slash commands are not inbox asks.', isError: true as const }
455 await fileAsk($, text, filing)
456 return { result: 'Sent to the inbox.', text: 'Sent to the inbox.' }
457 }
458 const at = await $.clock.now()
459 const sid = await sessionId($)
460 let outcome = runBoardTool(await read($, cards), input, at, sid)
461 await setCards($, current => {
462 outcome = runBoardTool(current, input, at, sid)
463 return outcome.cards
464 }, true)
465
466 // A registered tool's result is text: what Claude reads back.
467 return outcome.isError === true
468 ? { result: outcome.text, text: outcome.text, isError: true as const }
469 : { result: outcome.text, text: outcome.text }
470 })
471
472 // The draggable columns: a card dropped on another column, or clicked to open it.
473 on('ui.message', { component: 'Pane', requestId: PANE }, async ($, e) => {
474 const data = e.data as Partial<ColumnsMessage> | null
475 const id = typeof data?.id === 'string' ? data.id : null
476 if (id === null || !(await read($, cards)).some(card => card.id === id)) return {}
477 if (data?.type === 'open') await select($, id)
478 if (data?.type === 'details') await update($, selected, () => id)
479 if (data?.type === 'enter') {
480 const card = (await read($, cards)).find(card => card.id === id)!
481 if ((card.parentId ?? null) === null) {
482 await update($, selected, () => null)
483 await update($, composer, () => null)
484 await update($, view, (): View => ({ kind: 'goal', id }))
485 } else await update($, selected, () => id)
486 }
487 if (data?.type === 'move' && typeof data.column === 'number') {
488 const column = COLUMNS[data.column]
489 if (column !== undefined) await move($, id, column)
490 }
491
492 return {}
493 })
494
495 on('turn.complete', async ($, e, next) => {
496 const done = await next(e)
497 // The turn Work on it started has ended: the card is Claude's no longer.
498 if (e.agentId === undefined && (await read($, working)) !== null) await update($, working, () => null)
499
500 return done
501 })
502
503 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
504 const table = $.ui.resolve(e)
505 const { Box, Button, Text } = table
506 const Input = 'Input' in table ? table.Input : undefined
507 const Select = 'Select' in table ? table.Select : undefined
508 // The desktop app and the terminal draw a Client; VS Code's table lists one but draws none.
509 const Client = (e.surface === 'desktop' || e.surface === 'terminal') && 'Client' in table ? table.Client : undefined
510 const board = await read($, cards)
511 let where = await read($, view)
512 const pending = await read($, inbox)
513 const kept = await read($, stash)
514 const goal = where.kind === 'goal' ? goals(board).find(card => card.id === (where as { id: string }).id) : undefined
515 if (where.kind === 'goal' && goal === undefined) {
516 where = { kind: 'top' }
517 }
518 const back = async () => {
519 await update($, selected, () => null)
520 await update($, composer, () => null)
521 await update($, view, () => ({ kind: 'top' }))
522 }
523 const inboxRow = pending.length === 0 ? null : (
524 <Text dimColor wrap="truncate-end">{`✉ Inbox: "${pending[0]!.text.slice(0, 40)}" …filing${pending.length > 1 ? ` (+${pending.length - 1})` : ''}`}</Text>
525 )
526 if (where.kind === 'carried' || where.kind === 'archive') {
527 const groups = where.kind === 'carried' ? kept.carried : kept.archive
528 const now = await $.clock.now()
529 const list = where.kind
530 const pull = async (target: StoredGoal) => {
531 await mutateStash($, async latest => {
532 let fresh = latest
533 await setCards($, current => {
534 const out = pullIn(latest, current, target)
535 fresh = out.stash
536 return out.cards
537 }, false)
538 return fresh
539 }, stashSerial)
540 }
541 return (
542 <Box flexDirection="column" paddingBottom={1} rowGap={1}>
543 <Box flexDirection="row" columnGap={1}>
544 <Button key="back" label="‹ Back" plain onPress={() => void back()} />
545 <Text bold>{where.kind === 'carried' ? 'Carried over' : 'Archive'}</Text>
546 </Box>
547 {inboxRow}
548 {groups.map(group => (
549 <Box key={`group:${group.sessionId}:${group.endedAt}`} flexDirection="column" rowGap={1}>
550 <Text dimColor>{`↺ ${fallbackLabel([], group.endedAt)} · ${group.label} (${goals(group.cards).length})${where.kind === 'carried' ? ` · ${daysLeft(group, now)} days left` : ''}`}</Text>
551 {goals(group.cards).map(card => (
552 <Box key={`stored:${storedGoalKey({ list, sessionId: group.sessionId, endedAt: group.endedAt, id: card.id })}`} flexDirection="row" justifyContent="space-between" columnGap={1}>
553 <Text wrap="wrap">{card.title}</Text>
554 <Button key={`pull:${storedGoalKey({ list, sessionId: group.sessionId, endedAt: group.endedAt, id: card.id })}`} label="Pull in" variant="secondary" onPress={() => void pull({ list, sessionId: group.sessionId, endedAt: group.endedAt, id: card.id }).catch(() => { $.ui.toast('Pull in persistence failed; the live transfer will be retried at session end.') })} />
555 </Box>
556 ))}
557 </Box>
558 ))}
559 {groups.length === 0 && <Text dimColor>Nothing here.</Text>}
560 </Box>
561 )
562 }
563 const shownCards = where.kind === 'goal' ? subtasksOf(board, where.id) : goals(board)
564 const goalProgress = goal === undefined ? null : progressOf(board, goal.id)
565 const navigation = where.kind === 'goal' && goal !== undefined ? (
566 <Box flexDirection="row" columnGap={1}>
567 <Button key="back" label="‹ Back" plain onPress={() => void back()} />
568 <Text bold wrap="truncate-end">{goal.title}</Text>
569 <Text dimColor>{`${goalProgress!.done}/${goalProgress!.total}`}</Text>
570 </Box>
571 ) : (
572 <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
573 {kept.carried.length > 0 && <Button key="carried" label={`↺ Carried over (${kept.carried.reduce((n, group) => n + goals(group.cards).length, 0)}) ›`} plain onPress={() => void update($, view, () => ({ kind: 'carried' }))} />}
574 {kept.archive.length > 0 && <Button key="archive" label="▤ Archive" plain dimColor onPress={() => void update($, view, () => ({ kind: 'archive' }))} />}
575 <Button key="reload" label="↻ Reload" plain dimColor onPress={() => void loadStash($, stashSerial)} />
576 </Box>
577 )
578 const openId = await read($, selected)
579 const writing = await read($, composer)
580 const saveLabel = writing?.id == null ? 'Add card' : 'Save'
581 const busy = await read($, working)
582 const open = shownCards.find(card => card.id === openId)
583 const cardTag = (card: Card, includeNote = true) => {
584 const p = progressOf(board, card.id)
585 return `${card.id === busy ? '◐' : TAG[card.source]}${includeNote && card.note !== '' ? ' ≡' : ''}${where.kind === 'top' ? ` ▸ ${p.done}/${p.total}` : ''}${card.filedBy === 'haiku' ? ' ✧' : ''}`.trim()
586 }
587
588 const cardView = (card: Card) => {
589 const isOpen = card.id === openId
590 const isBusy = card.id === busy
591 const isDone = card.column === 'done'
592 const next = neighbour(card.column, 1)
593 const tag = cardTag(card, false)
594 return (
595 <Box
596 key={`card:${card.id}`}
597 flexDirection="column"
598 borderStyle="round"
599 {...(isOpen || isBusy ? { borderColor: ACCENT } : { borderDimColor: true })}
600 hover={{ borderColor: ACCENT }}
601 paddingX={1}
602 >
603 <Text wrap="wrap" dimColor={isDone}>
604 {isDone ? `✓ ${card.title}` : card.title}
605 </Text>
606 <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
607 <Box flexDirection="row" columnGap={1}>
608 <Text {...(isBusy ? { color: ACCENT } : { dimColor: true })} wrap="truncate-end">{tag}</Text>
609 {card.note !== '' && <Text dimColor>≡</Text>}
610 </Box>
611 <Box flexDirection="row" columnGap={1}>
612 <Button key={`open:${card.id}`} label={isOpen ? 'Close' : 'Open'} plain dimColor onPress={() => void select($, card.id)} />
613 {next !== null && (
614 <Button key={`next:${card.id}`} label="›" plain dimColor onPress={() => void move($, card.id, next)} />
615 )}
616 </Box>
617 </Box>
618 </Box>
619 )
620 }
621
622 // The cards as the draggable columns draw them, column by column, Done's latest first.
623 const shownIn = (column: Column) => {
624 const inColumn = shownCards.filter(card => card.column === column)
625 return column === 'done' ? inColumn.slice(-DONE_SHOWN).reverse() : inColumn
626 }
627 const columnsProps: ColumnsProps = {
628 view: where.kind === 'goal' ? `goal:${where.id}` : where.kind,
629 cards: COLUMNS.flatMap((column, index) =>
630 shownIn(column).map(card => ({
631 id: card.id,
632 title: card.title,
633 column: index,
634 tag: cardTag(card),
635 isDone: column === 'done',
636 isOpen: card.id === openId,
637 isBusy: card.id === busy,
638 })),
639 ),
640 names: COLUMNS.map(column => COLUMN_NAME[column]),
641 counts: COLUMNS.map(column => shownCards.filter(card => card.column === column).length),
642 dots: COLUMNS.map(column => DOT[column].color ?? null),
643 }
644
645 // The columns fill the pane below the header, so a card can be dragged anywhere under them; with a
646 // card's details or the composer open, they keep their own height and those sit right under them.
647 const contentRows = columnsHeightFor(columnsProps.cards, e.props.bodyColumns)
648 const fillRows = e.props.scroll.bodyRows - HEADER_ROWS - (navigation === null ? 0 : 1) - (inboxRow === null ? 0 : 1)
649 const columnsHeight = open !== undefined || writing !== null ? contentRows : Math.max(contentRows, fillRows)
650
651 const columnView = (column: Column) => {
652 const inColumn = shownCards.filter(card => card.column === column)
653 // Done keeps the latest few: the column that only grows.
654 const shown = column === 'done' ? inColumn.slice(-DONE_SHOWN).reverse() : inColumn
655 return (
656 <Box key={`column:${column}`} flexDirection="column" width="33%" flexShrink={1} rowGap={1}>
657 <Box flexDirection="row" columnGap={1}>
658 <Text {...DOT[column]} dimColor={DOT[column].color === undefined}>
659 ●
660 </Text>
661 <Text bold>{COLUMN_NAME[column]}</Text>
662 <Text dimColor>{String(inColumn.length)}</Text>
663 </Box>
664 {shown.map(cardView)}
665 {shown.length === 0 && (
666 <Box borderStyle="round" borderDimColor paddingX={1}>
667 <Text dimColor>No cards</Text>
668 </Box>
669 )}
670 {inColumn.length > shown.length && <Text dimColor>{`+${inColumn.length - shown.length} earlier`}</Text>}
671 </Box>
672 )
673 }
674
675 // How far along the board is: the done cards of all.
676 const doneCount = shownCards.filter(card => card.column === 'done').length
677 const filled = shownCards.length === 0 ? 0 : Math.round((doneCount / shownCards.length) * BAR)
678 const newCard =
679 Input === undefined || writing !== null ? null : (
680 <Button key="new" label="+ New card" variant="secondary" onPress={() => void compose($, null)} />
681 )
682 const progress = (
683 <Box flexDirection="row" columnGap={1} alignItems="center">
684 <Box flexDirection="row">
685 {filled > 0 && <Text color={GREEN}>{'■'.repeat(filled)}</Text>}
686 {filled < BAR && <Text dimColor>{'□'.repeat(BAR - filled)}</Text>}
687 </Box>
688 <Text dimColor>{`${doneCount} of ${shownCards.length} done`}</Text>
689 </Box>
690 )
691 const header = (
692 <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={1} marginBottom={1}>
693 {shownCards.length === 0 ? <Text bold>{where.kind === 'goal' ? 'No subtasks yet' : 'Nothing on the board yet'}</Text> : progress}
694 {newCard}
695 </Box>
696 )
697
698 const details = (card: Card) => {
699 const left = neighbour(card.column, -1)
700 const right = neighbour(card.column, 1)
701 const parents = subtasksOf(board, card.id).length === 0 ? goals(board).filter(g => g.id !== card.id) : []
702 return (
703 <Box key={`details:${card.id}`} flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={1} marginTop={1}>
704 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
705 <Box flexDirection="row" columnGap={1} flexShrink={1}>
706 <Text {...DOT[card.column]} dimColor={DOT[card.column].color === undefined}>
707 ●
708 </Text>
709 <Text bold wrap="wrap">
710 {card.title}
711 </Text>
712 </Box>
713 <Button key="close" label="✕" plain dimColor onPress={() => void select($, null)} />
714 </Box>
715 <Text dimColor>{`${COLUMN_NAME[card.column]} · ${sourceName(card.source)}`}</Text>
716 {card.note !== '' && (
717 <Box marginTop={1}>
718 <Text wrap="wrap">{card.note}</Text>
719 </Box>
720 )}
721 <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1} marginBottom={1}>
722 {(card.parentId ?? null) === null ? (
723 <Button key="subtasks" label="Subtasks ›" variant="secondary" onPress={() => void update($, view, (): View => ({ kind: 'goal', id: card.id })).then(() => select($, null))} />
724 ) : (
725 <Button key="make-goal" label="Make goal" plain dimColor onPress={() => void setCards($, c => makeGoal(c, card.id), false)} />
726 )}
727 {Select !== undefined && parents.length > 0 && (
728 <Select key="move-under" label="Move under…" options={parents.map(g => ({ value: g.id, label: g.title }))} {...(card.parentId == null ? {} : { value: card.parentId })} onSelect={value => void setCards($, c => moveUnder(c, card.id, value), false)} />
729 )}
730 {card.column !== 'done' && (
731 <Button
732 key="work"
733 label={card.id === busy ? 'Claude is on it' : '▶ Work on it'}
734 variant="secondary"
735 onPress={() => void workOn($, card)}
736 />
737 )}
738 {left !== null && (
739 <Button key="left" label={`‹ ${COLUMN_NAME[left]}`} variant="secondary" onPress={() => void move($, card.id, left)} />
740 )}
741 {right !== null && (
742 <Button key="right" label={`${COLUMN_NAME[right]} ›`} variant="secondary" onPress={() => void move($, card.id, right)} />
743 )}
744 <Button key="edit" label="Edit" plain dimColor onPress={() => void compose($, card)} />
745 <Button key="delete" label="Delete" plain dimColor onPress={() => void remove($, card.id)} />
746 </Box>
747 </Box>
748 )
749 }
750
751 return (
752 // A focus ring at the content's top or bottom edge is cut off: keep a line below the last element.
753 <Box flexDirection="column" paddingBottom={1}>
754 {navigation}
755 {inboxRow}
756 {shownCards.length === 0 ? (
757 <Box flexDirection="column">
758 {header}
759 <Text dimColor wrap="wrap">
760 Add a card with New card. Claude's to-do list and the plans you approve show up here by themselves, and Claude can add
761 cards when you ask it to put something on the board.
762 </Text>
763 </Box>
764 ) : (
765 <Box flexDirection="column">
766 {header}
767 {Client !== undefined ? (
768 // Dragging needs the pointer, which only a Client hears: it draws the columns itself.
769 <Client key="columns" module="./columns.tsx" props={columnsProps} width="100%" height={columnsHeight} />
770 ) : (
771 <Box flexDirection="row" columnGap={1}>
772 {COLUMNS.map(columnView)}
773 </Box>
774 )}
775 </Box>
776 )}
777 {writing !== null && Input !== undefined && (
778 <Box
779 key="composer"
780 flexDirection="column"
781 borderStyle="round"
782 borderColor={ACCENT}
783 paddingX={1}
784 paddingY={1}
785 rowGap={1}
786 marginTop={1}
787 >
788 <Box flexDirection="row" justifyContent="space-between">
789 <Text bold>{writing.id === null ? 'New card' : 'Edit card'}</Text>
790 <Button key="cancel-x" label="✕" plain dimColor onPress={() => void update($, composer, () => null)} />
791 </Box>
792 {/* One field at a time: each field's Enter label is drawn as a button, always. */}
793 {!writing.hasNote ? (
794 <Box flexDirection="column" rowGap={1}>
795 <Input
796 key="title"
797 label="Title"
798 placeholder="What needs doing"
799 value={writing.title}
800 submitLabel={saveLabel}
801 autoFocus
802 onInput={text => void typeIn($, 'title', text)}
803 onSubmit={text => void typeIn($, 'title', text).then(() => saveComposer($))}
804 />
805 <Box>
806 <Button
807 key="add-note"
808 label={writing.note === '' ? '+ Description' : 'Edit description'}
809 plain
810 dimColor
811 onPress={() => void showNote($)}
812 />
813 </Box>
814 </Box>
815 ) : (
816 <Box flexDirection="column" rowGap={1}>
817 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
818 <Text bold wrap="wrap">
819 {writing.title === '' ? 'Untitled' : writing.title}
820 </Text>
821 <Button key="back-title" label="‹ Title" plain dimColor onPress={() => void showTitle($)} />
822 </Box>
823 <Input
824 key="note"
825 label="Description"
826 placeholder="Why, where, what done means"
827 value={writing.note}
828 submitLabel={saveLabel}
829 autoFocus
830 onInput={text => void typeIn($, 'note', text)}
831 onSubmit={text => void typeIn($, 'note', text).then(() => saveComposer($))}
832 />
833 </Box>
834 )}
835 </Box>
836 )}
837 {open !== undefined && details(open)}
838 </Box>
839 )
840 })
841}
842hooks/lib.ts 363 lines1import type { Card, CardSource, Column } from '../types'
2
3export const COLUMNS: readonly Column[] = ['todo', 'doing', 'done']
4
5export const COLUMN_NAME: Record<Column, string> = { todo: 'To do', doing: 'Doing', done: 'Done' }
6
7/** How many Done cards the column shows; the rest are counted. */
8export const DONE_SHOWN = 5
9
10/** The most cards a board keeps: the oldest Done ones go first. */
11export const CARD_LIMIT = 80
12
13const SOURCE_NAME: Record<CardSource, string> = {
14 you: 'Added by you',
15 todo: "From Claude's to-do list",
16 plan: 'From the approved plan',
17 claude: 'Added by Claude',
18}
19
20export function sourceName(source: CardSource): string {
21 return SOURCE_NAME[source]
22}
23
24const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
25
26/** A card's title as it is kept: one line, trimmed, at most 160 characters. */
27export function cleanTitle(text: string): string {
28 return clip(text.replace(/\s+/g, ' ').trim(), 160)
29}
30
31/** A new card's id, unique within the board. */
32export function newId(cards: readonly Card[], at: number): string {
33 let n = cards.length
34 let id = `c${at.toString(36)}${n.toString(36)}`
35 while (cards.some(card => card.id === id)) {
36 n += 1
37 id = `c${at.toString(36)}${n.toString(36)}`
38 }
39
40 return id
41}
42
43/** The board with a card added at the end of `column`, and the card; a card already there by that title is kept as is. */
44export function addCard(
45 cards: readonly Card[],
46 fields: { title: string; note?: string; column?: Column; source: CardSource; ref?: string | null; parentId?: string | null; sessionId?: string; filedBy?: 'haiku' | null; inboxId?: string; distinct?: boolean },
47 at: number,
48): { cards: Card[]; card: Card } {
49 const title = cleanTitle(fields.title)
50 // Invalid parents become top goals rather than introducing orphans or a third level.
51 const parentId = fields.parentId != null && goals(cards).some(card => card.id === fields.parentId) ? fields.parentId : null
52 const same = cards.find(card => fields.inboxId !== undefined
53 ? card.inboxId === fields.inboxId
54 : fields.source === 'todo' && fields.ref != null
55 ? card.source === 'todo' && card.ref === fields.ref && (card.sessionId ?? '') === (fields.sessionId ?? '')
56 : !fields.distinct && (card.parentId ?? null) === parentId && card.title.toLowerCase() === title.toLowerCase() && card.column !== 'done')
57 if (same !== undefined) return { cards: [...cards], card: same }
58 const card: Card = {
59 id: newId(cards, at),
60 incarnation: Math.random().toString(36).slice(2),
61 title,
62 note: clip((fields.note ?? '').trim(), 1_000),
63 column: fields.column ?? 'todo',
64 source: fields.source,
65 ref: fields.ref ?? null,
66 createdAt: at,
67 parentId,
68 sessionId: fields.sessionId ?? '',
69 sessionLabel: null,
70 carriedAt: null,
71 filedBy: fields.filedBy ?? null,
72 ...(fields.inboxId === undefined ? {} : { inboxId: fields.inboxId }),
73 }
74
75 return { cards: trim([...cards, card], card.id), card }
76}
77
78/** At most `CARD_LIMIT` cards: the oldest Done ones go first. */
79function trim(cards: Card[], addedId: string): Card[] {
80 const out = [...cards]
81 // Keep the incoming card, its parent, and every active goal. Evicting
82 // any of those goals would also silently delete protected work below it.
83 const protectedIds = new Set(out.filter(card => card.id === addedId || ((card.parentId ?? null) === null && card.column === 'doing')).map(card => card.id))
84 const parent = out.find(card => card.id === addedId)?.parentId
85 if (parent != null) protectedIds.add(parent)
86 while (out.length > CARD_LIMIT) {
87 let at = out.findIndex(card => !protectedIds.has(card.id) && card.column === 'done')
88 if (at === -1) at = out.findIndex(card => !protectedIds.has(card.id))
89 // All remaining cards are protected; preservation takes priority over the cap.
90 if (at === -1) break
91 const removed = out[at]!
92 out.splice(at, 1)
93 // Evict a goal's children too: limiting the board must not leave orphans.
94 for (let n = out.length - 1; n >= 0; n -= 1) if (out[n]?.parentId === removed.id) out.splice(n, 1)
95 }
96
97 return out
98}
99
100/** The board with card `id` in `column`, moved to the end of it. */
101export function moveCard(cards: readonly Card[], id: string, column: Column): Card[] {
102 const card = cards.find(one => one.id === id)
103 if (card === undefined || card.column === column) return [...cards]
104
105 return [...cards.filter(one => one.id !== id), { ...card, column }]
106}
107
108/** The board with card `id` retitled and its description replaced. */
109export function editCard(cards: readonly Card[], id: string, fields: { title: string; note: string }): Card[] {
110 return cards.map(card => (card.id === id ? { ...card, title: cleanTitle(fields.title), note: clip(fields.note.trim(), 1_000) } : card))
111}
112
113export function removeCard(cards: readonly Card[], id: string): Card[] {
114 return cards.filter(card => card.id !== id && card.parentId !== id)
115}
116
117export const SESSION_GOAL = 'Session'
118
119/** The top goals, including cards made before nesting was added. */
120export function goals(cards: readonly Card[]): Card[] {
121 return cards.filter(card => (card.parentId ?? null) === null)
122}
123
124export function subtasksOf(cards: readonly Card[], id: string): Card[] {
125 return cards.filter(card => card.parentId === id)
126}
127
128export function progressOf(cards: readonly Card[], id: string): { done: number; total: number } {
129 const subs = subtasksOf(cards, id)
130 return { done: subs.filter(card => card.column === 'done').length, total: subs.length }
131}
132
133/** Last goal in Doing, otherwise a reusable Session goal. */
134export function currentGoal(cards: readonly Card[], at: number, sessionId: string): { cards: Card[]; id: string } {
135 const doing = goals(cards).filter(card => card.column === 'doing')
136 const last = doing[doing.length - 1]
137 if (last !== undefined) return { cards: [...cards], id: last.id }
138 const existing = goals(cards).find(card => card.title === SESSION_GOAL)
139 if (existing !== undefined) return { cards: moveCard(cards, existing.id, 'doing'), id: existing.id }
140 const added = addCard(cards, { title: SESSION_GOAL, column: 'doing', source: 'claude', sessionId }, at)
141 return { cards: added.cards, id: added.card.id }
142}
143
144export function makeGoal(cards: readonly Card[], id: string): Card[] {
145 return cards.map(card => (card.id === id ? { ...card, parentId: null } : card))
146}
147
148/** Refuse parents or children that would introduce a third level. */
149export function moveUnder(cards: readonly Card[], id: string, parentId: string): Card[] {
150 const parent = cards.find(card => card.id === parentId)
151 if (parent === undefined || (parent.parentId ?? null) !== null || id === parentId || subtasksOf(cards, id).length > 0) return [...cards]
152 return cards.map(card => (card.id === id ? { ...card, parentId } : card))
153}
154
155/** The column left or right of `column`, if any. */
156export function neighbour(column: Column, step: -1 | 1): Column | null {
157 return COLUMNS[COLUMNS.indexOf(column) + step] ?? null
158}
159
160const STATUS_COLUMN: Record<string, Column> = { pending: 'todo', in_progress: 'doing', completed: 'done' }
161
162/**
163 * The board after Claude wrote its to-do list (TodoWrite): each item a card
164 * in the column its status says, matched to the card it made before by its
165 * text; the cards of items it dropped go. Claude's list is the truth for its
166 * own cards.
167 */
168export function syncTodos(
169 cards: readonly Card[],
170 todos: ReadonlyArray<{ content: string; status: string }>,
171 at: number,
172 sessionId = '',
173): Card[] {
174 const kept = new Set(todos.map(todo => todo.content))
175 let out = [...cards]
176 for (const card of cards) {
177 if (card.source === 'todo' && (card.sessionId ?? '') === sessionId && card.ref !== null && !kept.has(card.ref) && !card.ref.startsWith('task:')) {
178 out = removeCard(out, card.id)
179 }
180 }
181 for (const todo of todos) {
182 const title = cleanTitle(todo.content)
183 const column = STATUS_COLUMN[todo.status] ?? 'todo'
184 const mine = out.find(card => card.source === 'todo' && (card.sessionId ?? '') === sessionId && card.ref === todo.content)
185 if (mine === undefined) {
186 const goal = currentGoal(out, at, sessionId)
187 out = addCard(goal.cards, { title, column, source: 'todo', ref: todo.content, parentId: goal.id, sessionId }, at).cards
188 } else if (mine.column !== column) {
189 out = moveCard(out, mine.id, column)
190 }
191 }
192
193 return out
194}
195
196/** The board after Claude created a task in its to-do list (TaskCreate). */
197export function taskCreated(cards: readonly Card[], task: { id: string; subject: string; description?: string }, at: number, sessionId = ''): Card[] {
198 const goal = currentGoal(cards, at, sessionId)
199 return addCard(goal.cards, { title: task.subject, note: task.description ?? '', source: 'todo', ref: `task:${task.id}`, parentId: goal.id, sessionId }, at).cards
200}
201
202/** The board after Claude updated a task in its to-do list (TaskUpdate): its status, title or note; deleted, it goes. */
203export function taskUpdated(
204 cards: readonly Card[],
205 update: { taskId: string; status?: string; subject?: string; description?: string },
206 sessionId = '',
207): Card[] {
208 const card = cards.find(one => one.source === 'todo' && (one.sessionId ?? '') === sessionId && one.ref === `task:${update.taskId}`)
209 if (card === undefined) return [...cards]
210 if (update.status === 'deleted') return removeCard(cards, card.id)
211 const changed: Card = {
212 ...card,
213 ...(update.subject === undefined ? {} : { title: cleanTitle(update.subject) }),
214 ...(update.description === undefined ? {} : { note: clip(update.description.trim(), 1_000) }),
215 }
216 const column = update.status === undefined ? card.column : (STATUS_COLUMN[update.status] ?? card.column)
217 const replaced = cards.map(one => (one.id === card.id ? changed : one))
218
219 return column === card.column ? replaced : moveCard(replaced, card.id, column)
220}
221
222/** Markdown as plain text: emphasis, code marks and links reduced to their words. */
223function plainText(text: string): string {
224 return text
225 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
226 .replace(/(\*\*|__)(.+?)\1/g, '$2')
227 .replace(/`([^`]+)`/g, '$1')
228 .replace(/[::]\s*$/, '')
229 .trim()
230}
231
232// Headings that frame a plan rather than name a step in it.
233const FRAMING = /^(context|overview|summary|background|goal|goals|plan|approach|verification|testing|test plan|risks|notes|open questions|files|critical files)\b/i
234
235/**
236 * The steps of an approved plan, as cards: its numbered items, else its
237 * top-level bullets, else its section headings (not the ones that frame it,
238 * such as Context or Verification). At most 20.
239 */
240export function planSteps(plan: string): string[] {
241 const lines = plan.split('\n')
242 const pick = (pattern: RegExp) =>
243 lines
244 .map(line => line.match(pattern)?.[1])
245 .filter((step): step is string => step !== undefined)
246 .map(plainText)
247 .filter(step => step !== '')
248 const numbered = pick(/^ {0,3}\d+[.)]\s+(.+)$/)
249 const bullets = pick(/^[-*+]\s+(?:\[[ xX]\]\s+)?(.+)$/)
250 const headings = pick(/^#{2,4}\s+(?:(?:step|phase)\s*\d+\s*[:.\-–]\s*)?(.+)$/i).filter(step => !FRAMING.test(step))
251 const steps = numbered.length >= 2 ? numbered : bullets.length >= 2 ? bullets : headings
252
253 return steps.slice(0, 20).map(cleanTitle)
254}
255
256/** What Work on it sends Claude. */
257export function workPrompt(card: Card): string {
258 return [`Work on this card from the task board: "${card.title}"`, ...(card.note === '' ? [] : ['', card.note])].join('\n')
259}
260
261/** The board as text, for the board tool's `list`: each column, each card with its id. */
262export function boardText(cards: readonly Card[]): string {
263 if (cards.length === 0) return 'The board is empty.'
264
265 return COLUMNS.map(column => {
266 const inColumn = goals(cards).filter(card => card.column === column)
267 return [`${COLUMN_NAME[column]} (${inColumn.length})`, ...inColumn.flatMap(card => [
268 `- [${card.id}] ${card.title}`,
269 ...subtasksOf(cards, card.id).map(sub => ` - [${sub.id}] ${sub.title} (${COLUMN_NAME[sub.column]})`),
270 ])].join('\n')
271 }).join('\n\n')
272}
273
274/** What the board tool takes, as the model sends it: checked here, never trusted. */
275export type BoardToolInput = { action?: unknown; title?: unknown; note?: unknown; card?: unknown; column?: unknown; parent?: unknown; text?: unknown }
276
277export const BOARD_TOOL_SCHEMA = {
278 type: 'object',
279 properties: {
280 action: { type: 'string', enum: ['list', 'add', 'move', 'remove', 'inbox'], description: 'What to do.' },
281 title: { type: 'string', description: 'add: the card, a short imperative ("Add an offline banner").' },
282 note: { type: 'string', description: 'add: optional detail: why, where, what done means.' },
283 card: { type: 'string', description: 'move, remove: the card id from list, or its exact title.' },
284 column: { type: 'string', enum: ['todo', 'doing', 'done'], description: 'add (default todo), move: the column.' },
285 parent: { type: 'string', description: 'add: optional top goal (id or exact title) to put the card under as a subtask.' },
286 text: { type: 'string', description: "inbox: the person's ask, as they said it." },
287 },
288 required: ['action'],
289} as const
290
291export const BOARD_TOOL_DESCRIPTION = [
292 "The task board in the person's side pane: top goals (To do, Doing, Done), each with its own subtasks.",
293 "When the person asks for real work (not a yes/no, a question or a one-word reply), call inbox with their ask: a small model files it as a goal or a subtask. Do not decide where it goes yourself.",
294 'Messages the person queues while you work are filed by themselves: do not send them again.',
295 'Your own to-do list goes under the goal in Doing by itself: do not add its items again.',
296 'Actions: list (cards and ids, subtasks indented), add (title, optional note, column, parent), move (card, column), remove (card, with its subtasks), inbox (text).',
297].join(' ')
298
299const isColumn = (value: unknown): value is Column => value === 'todo' || value === 'doing' || value === 'done'
300
301/** Finds a card by its id, else by its title (exact, ignoring case). */
302function findCard(cards: readonly Card[], key: string): Card | undefined {
303 const wanted = key.trim().toLowerCase()
304
305 return cards.find(card => card.id === key.trim()) ?? cards.find(card => card.title.toLowerCase() === wanted)
306}
307
308/** Runs one board tool call: the board after it, and what the model reads back. */
309export function runBoardTool(
310 cards: readonly Card[],
311 input: BoardToolInput,
312 at: number,
313 sessionId = '',
314): { cards: Card[]; text: string; isError?: true } {
315 const fail = (text: string) => ({ cards: [...cards], text, isError: true as const })
316 switch (input.action) {
317 case 'list':
318 return { cards: [...cards], text: boardText(cards) }
319 case 'add': {
320 if (typeof input.title !== 'string' || input.title.trim() === '') return fail('add needs a title.')
321 if (input.column !== undefined && !isColumn(input.column)) return fail('column is todo, doing or done.')
322 let parentId: string | null = null
323 if (input.parent !== undefined) {
324 if (typeof input.parent !== 'string') return fail('parent is a top goal id or title.')
325 const parent = findCard(goals(cards), input.parent)
326 if (parent === undefined) return fail(`No top goal "${input.parent}". Use list to see the goals.`)
327 parentId = parent.id
328 }
329 const before = cards.length
330 const added = addCard(
331 cards,
332 { title: input.title, note: typeof input.note === 'string' ? input.note : '', column: input.column ?? 'todo', source: 'claude', parentId, sessionId },
333 at,
334 )
335 return {
336 cards: added.cards,
337 text:
338 added.cards.length === before
339 ? `"${added.card.title}" is already on the board (${COLUMN_NAME[added.card.column]}, ${added.card.id}).`
340 : parentId !== null ? `Added "${added.card.title}" under "${cards.find(c => c.id === parentId)!.title}" (${added.card.id}).`
341 : `Added "${added.card.title}" to ${COLUMN_NAME[added.card.column]} (${added.card.id}).`,
342 }
343 }
344 case 'move': {
345 if (typeof input.card !== 'string') return fail('move needs a card: its id or title.')
346 if (!isColumn(input.column)) return fail('move needs a column: todo, doing or done.')
347 const card = findCard(cards, input.card)
348 if (card === undefined) return fail(`No card "${input.card}". Use list to see the cards and their ids.`)
349 return { cards: moveCard(cards, card.id, input.column), text: `Moved "${card.title}" to ${COLUMN_NAME[input.column]}.` }
350 }
351 case 'remove': {
352 if (typeof input.card !== 'string') return fail('remove needs a card: its id or title.')
353 const card = findCard(cards, input.card)
354 if (card === undefined) return fail(`No card "${input.card}". Use list to see the cards and their ids.`)
355 return { cards: removeCard(cards, card.id), text: `Removed "${card.title}".` }
356 }
357 case 'inbox':
358 return fail('inbox is answered by the hook.')
359 default:
360 return fail('action is list, add, move, remove or inbox.')
361 }
362}
363hooks/columns.tsx 283 lines1import type { ClientKeyEvent, ClientModule, ClientPointerEvent } from 'claude-code'
2
3/** A card as the columns draw it: plain data, handed over by the board's render hook. */
4export type ColumnCard = { id: string; title: string; column: number; tag: string; isDone: boolean; isOpen: boolean; isBusy: boolean }
5
6export type ColumnsProps = { cards: ColumnCard[]; names: string[]; counts: number[]; dots: Array<string | null>; view: string }
7
8/** What the module sends the board: a card dropped on another column, or a card clicked. */
9export type ColumnsMessage = { type: 'move'; id: string; column: number } | { type: 'open' | 'details' | 'enter'; id: string }
10
11/**
12 * A card being dragged: where the press was, where the pointer is now, and
13 * where on the card it was grabbed, so the ghost stays under the pointer as it was picked up.
14 */
15type Drag = { id: string; from: number; x: number; y: number; moved: boolean; over: number; atX: number; atY: number; grabX: number; grabY: number }
16type State = { drag: Drag | null; highlight: string | null; view: string }
17
18const ACCENT = 'claude'
19/** The dragged card's fill and text. */
20const GHOST_FILL = '#ffffff'
21const GHOST_INK = '#1f1f1f'
22/** Lines a card's title may take before it is cut. */
23const TITLE_LINES = 4
24
25/** `text` broken into lines of at most `width` characters, at spaces where it can; cut with an ellipsis past `max`. */
26export function wrap(text: string, width: number, max = TITLE_LINES): string[] {
27 const lines: string[] = []
28 let line = ''
29 for (const word of text.split(/\s+/).filter(Boolean)) {
30 let rest = word
31 while (rest.length > width) {
32 if (line !== '') {
33 lines.push(line)
34 line = ''
35 }
36 lines.push(rest.slice(0, width))
37 rest = rest.slice(width)
38 }
39 if (line === '') line = rest
40 else if (line.length + 1 + rest.length <= width) line = `${line} ${rest}`
41 else {
42 lines.push(line)
43 line = rest
44 }
45 }
46 if (line !== '') lines.push(line)
47 if (lines.length <= max) return lines
48 const kept = lines.slice(0, max)
49 const last = kept[max - 1] ?? ''
50 kept[max - 1] = `${last.slice(0, Math.max(0, width - 1))}…`
51
52 return kept
53}
54
55/** Where each card sits, in the region's cells: what the pointer is checked against. */
56export type Layout = {
57 columnWidth: number
58 cards: Array<{ id: string; column: number; top: number; bottom: number; lines: string[] }>
59}
60
61/**
62 * The columns' layout: three columns a cell apart, a header row, then each
63 * column's cards one under another, each its border, its title's lines and
64 * a footer row.
65 */
66export function layout(cards: readonly ColumnCard[], regionColumns: number): Layout {
67 const columnWidth = Math.max(12, Math.floor((Math.max(regionColumns, 38) - 2) / 3))
68 const inner = columnWidth - 4
69 const tops = [1, 1, 1]
70 const placed: Layout['cards'] = []
71 for (const card of cards) {
72 const lines = wrap(card.isDone ? `✓ ${card.title}` : card.title, inner)
73 const top = tops[card.column] ?? 1
74 const bottom = top + lines.length + 2 // the border's two rows and the footer, the title between
75 placed.push({ id: card.id, column: card.column, top, bottom, lines })
76 tops[card.column] = bottom + 2 // the blank row between cards
77 }
78
79 return { columnWidth, cards: placed }
80}
81
82/**
83 * How many rows the columns take as laid out: the header row, then the
84 * tallest column's cards, a blank row after each, or its one placeholder.
85 */
86export function columnsHeightFor(cards: readonly ColumnCard[], regionColumns: number): number {
87 const at = layout(cards, regionColumns)
88 const bottoms = [0, 1, 2].map(column => {
89 const last = at.cards.filter(card => card.column === column).at(-1)
90 return last === undefined ? 4 : last.bottom + 2
91 })
92
93 return Math.max(...bottoms)
94}
95
96/** The column under the pointer's column `x`. */
97export function columnAt(x: number, columnWidth: number): number {
98 return Math.max(0, Math.min(2, Math.floor(x / (columnWidth + 1))))
99}
100
101/** The card under the pointer, if any. */
102export function cardAt(at: Layout, x: number, y: number): Layout['cards'][number] | undefined {
103 const column = columnAt(x, at.columnWidth)
104
105 return at.cards.find(card => card.column === column && y >= card.top && y <= card.bottom)
106}
107
108/**
109 * The board's three columns as a `Client`: drawn here so the pointer can be
110 * read against where each card sits. Press a card and drag it onto another
111 * column to move it there; press and release without moving to open it.
112 */
113const Columns: ClientModule<ColumnsProps, State> = (props, surface) => {
114 const { Box, Text } = surface.elements
115 const pressed = surface.state?.drag ?? null
116 const drag = surface.state?.view === props.view && props.cards.some(card => card.id === pressed?.id) ? pressed : null
117 const at = layout(props.cards, surface.columns)
118 // A view change or deletion resets the cursor to the first visible card.
119 const highlight = surface.state?.view === props.view && props.cards.some(card => card.id === surface.state?.highlight)
120 ? surface.state.highlight : props.cards[0]?.id ?? null
121 const setState = (next: Partial<State>) => surface.setState({ drag, highlight, view: props.view, ...next })
122
123 surface.onKey((event: ClientKeyEvent) => {
124 if (event.ctrl || event.meta || event.shift || drag !== null) return
125 const card = props.cards.find(card => card.id === highlight)
126 if (card === undefined) return
127 if (event.key === ' ' || event.key === 'return') {
128 surface.post({ type: event.key === ' ' ? 'details' : 'enter', id: card.id })
129 return
130 }
131 const column = props.cards.filter(one => one.column === card.column)
132 const row = column.findIndex(one => one.id === card.id)
133 let next: ColumnCard | undefined
134 if (event.key === 'up' || event.key === 'down') next = column[row + (event.key === 'up' ? -1 : 1)]
135 if (event.key === 'left' || event.key === 'right') {
136 const direction = event.key === 'left' ? -1 : 1
137 for (let target = card.column + direction; target >= 0 && target < props.names.length; target += direction) {
138 const neighbours = props.cards.filter(one => one.column === target)
139 if (neighbours.length > 0) {
140 next = neighbours[Math.min(row, neighbours.length - 1)]
141 break
142 }
143 }
144 }
145 if (next !== undefined) setState({ highlight: next.id })
146 })
147
148 surface.onPointer((event: ClientPointerEvent) => {
149 const current = drag
150 if (event.type === 'down' && event.button === 'left') {
151 const card = cardAt(at, event.x, event.y)
152 if (card !== undefined) {
153 const left = card.column * (at.columnWidth + 1)
154 setState({
155 highlight: card.id,
156 drag: {
157 id: card.id,
158 from: card.column,
159 x: event.x,
160 y: event.y,
161 moved: false,
162 over: card.column,
163 atX: event.x,
164 atY: event.y,
165 grabX: event.x - left,
166 grabY: event.y - card.top,
167 },
168 })
169 }
170 return
171 }
172 if (current === null) return
173 if (event.type === 'move') {
174 const moved = current.moved || Math.abs(event.x - current.x) + Math.abs(event.y - current.y) >= 2
175 const over = columnAt(event.x, at.columnWidth)
176 // Every move redraws while dragging: the ghost follows the pointer.
177 if (moved || moved !== current.moved) setState({ drag: { ...current, moved, over, atX: event.x, atY: event.y } })
178 return
179 }
180 if (event.type === 'up') {
181 if (!current.moved) surface.post({ type: 'open', id: current.id })
182 else if (current.over !== current.from) surface.post({ type: 'move', id: current.id, column: current.over })
183 setState({ drag: null })
184 return
185 }
186 if (event.type === 'leave') {
187 // Released outside the region: the card stays where it was.
188 setState({ drag: null })
189 }
190 })
191
192 if (pressed !== null && drag === null) setState({ drag: null })
193
194 const isDragging = drag !== null && drag.moved
195 const carried = isDragging ? at.cards.find(card => card.id === drag.id) : undefined
196 const carriedCard = carried === undefined ? undefined : props.cards.find(card => card.id === carried.id)
197 return (
198 <Box flexDirection="row" columnGap={1}>
199 {isDragging && carried !== undefined && carriedCard !== undefined && (
200 // The ghost: the card under the pointer, held where it was grabbed. A white card with dark
201 // text in either theme: a mod's colors are fixed, so the text's is set as well as the fill.
202 <Box
203 key="ghost"
204 position="absolute"
205 // Kept inside the region, which clips what it draws: at its bottom edge, not cut.
206 top={Math.max(0, Math.min(drag.atY - drag.grabY, surface.rows - carried.lines.length - 3))}
207 left={Math.max(0, drag.atX - drag.grabX)}
208 width={at.columnWidth}
209 flexDirection="column"
210 borderStyle="bold"
211 borderColor={ACCENT}
212 backgroundColor={GHOST_FILL}
213 paddingX={1}
214 >
215 {carried.lines.map(line => (
216 <Text bold color={GHOST_INK} wrap="truncate-end">
217 {line}
218 </Text>
219 ))}
220 <Text color={ACCENT} wrap="truncate-end">
221 {carriedCard.tag === '' ? ' ' : carriedCard.tag}
222 </Text>
223 </Box>
224 )}
225 {[0, 1, 2].map(column => {
226 const isTarget = isDragging && drag.over === column && drag.from !== column
227 const dot = props.dots[column] ?? null
228 return (
229 <Box key={`column:${column}`} flexDirection="column" width={at.columnWidth}>
230 <Box flexDirection="row" columnGap={1}>
231 <Text {...(dot === null ? { dimColor: true } : { color: dot })}>●</Text>
232 <Text bold {...(isTarget ? { color: ACCENT } : {})}>
233 {props.names[column] ?? ''}
234 </Text>
235 <Text dimColor>{String(props.counts[column] ?? 0)}</Text>
236 </Box>
237 {at.cards
238 .filter(placed => placed.column === column)
239 .map(placed => {
240 const card = props.cards.find(one => one.id === placed.id)
241 if (card === undefined) return null
242 // The card picked up stays in place, dimmed; its ghost is what moves.
243 const isCarried = isDragging && drag.id === card.id
244 const isLit = !isCarried && (card.id === highlight || card.isOpen || card.isBusy)
245 return (
246 <Box
247 key={`card:${card.id}`}
248 flexDirection="column"
249 borderStyle={card.id === highlight ? 'bold' : 'round'}
250 {...(isLit ? { borderColor: ACCENT } : { borderDimColor: true })}
251 paddingX={1}
252 marginBottom={1}
253 >
254 {placed.lines.map(line => (
255 <Text wrap="truncate-end" dimColor={card.isDone || isCarried}>
256 {line}
257 </Text>
258 ))}
259 <Text dimColor wrap="truncate-end">
260 {card.tag === '' ? ' ' : card.tag}
261 </Text>
262 </Box>
263 )
264 })}
265 {isTarget && (
266 <Box borderStyle="round" borderColor={ACCENT} paddingX={1}>
267 <Text color={ACCENT}>Drop here</Text>
268 </Box>
269 )}
270 {(props.counts[column] ?? 0) === 0 && !isTarget && (
271 <Box borderStyle="round" borderDimColor paddingX={1}>
272 <Text dimColor>No cards</Text>
273 </Box>
274 )}
275 </Box>
276 )
277 })}
278 </Box>
279 )
280}
281
282export default Columns
283hooks/carry.ts 84 lines1import type { Card, Group, Stash } from '../types'
2import { goals, newId, subtasksOf } from './lib'
3
4export const ARCHIVE_AFTER_MS = 14 * 24 * 60 * 60_000
5const DAY_MS = 24 * 60 * 60_000
6const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
7export const EMPTY_STASH: Stash = { carried: [], archive: [] }
8export const stashKey = (cwd: string) => `board:${cwd}`
9
10export function readStash(value: unknown): Stash {
11 const v = value as Partial<Stash> | null | undefined
12 if (v === null || typeof v !== 'object' || !Array.isArray(v.carried) || !Array.isArray(v.archive)) return { carried: [], archive: [] }
13 return { carried: v.carried, archive: v.archive }
14}
15
16export function fallbackLabel(cards: readonly Card[], endedAt: number): string {
17 const d = new Date(endedAt)
18 const first = goals(cards).find(card => card.column !== 'done')
19 return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}${first === undefined ? '' : ` · ${first.title}`}`
20}
21
22export function carryOver(cards: readonly Card[], at: { sessionId: string; label: string; endedAt: number }): Group | null {
23 const open = goals(cards).filter(card => card.column !== 'done')
24 if (open.length === 0) return null
25 const mark = (card: Card): Card => ({ ...card, parentId: card.parentId ?? null, sessionId: card.sessionId ?? '', carriedAt: at.endedAt, sessionLabel: at.label })
26 return {
27 sessionId: at.sessionId,
28 label: at.label,
29 isLabelled: false,
30 endedAt: at.endedAt,
31 cards: open.flatMap(goal => [mark(goal), ...subtasksOf(cards, goal.id).map(mark)]),
32 }
33}
34
35/** Expire strictly after 14 days, dropping empty groups from both lists. */
36export function expire(stash: Stash, now: number): { stash: Stash; moved: number } {
37 const live = stash.carried.filter(group => group.cards.length > 0)
38 const old = live.filter(group => now - group.endedAt > ARCHIVE_AFTER_MS)
39 return { stash: { carried: live.filter(group => !old.includes(group)), archive: [...stash.archive.filter(group => group.cards.length > 0), ...old] }, moved: old.length }
40}
41
42export function daysLeft(group: Group, now: number): number {
43 return Math.max(0, Math.ceil((group.endedAt + ARCHIVE_AFTER_MS - now) / DAY_MS))
44}
45
46export type StoredGoal = { list: 'carried' | 'archive'; sessionId: string; endedAt: number; id: string }
47export const storedGoalKey = (goal: StoredGoal): string => JSON.stringify([goal.list, goal.sessionId, goal.endedAt, goal.id])
48
49/** Move one goal and its children; UI callers identify the list and group too. */
50export function pullIn(stash: Stash, board: readonly Card[], target: string | StoredGoal): { stash: Stash; cards: Card[] } {
51 const goalId = typeof target === 'string' ? target : target.id
52 let taken: Card[] = []
53 let transferKey = ''
54 const take = (groups: Group[], list: StoredGoal['list']) => groups.map(group => {
55 if (typeof target !== 'string' && (target.list !== list || target.sessionId !== group.sessionId || target.endedAt !== group.endedAt)) return group
56 if (taken.length > 0 || !goals(group.cards).some(card => card.id === goalId)) return group
57 transferKey = storedGoalKey({ list, sessionId: group.sessionId, endedAt: group.endedAt, id: goalId })
58 taken = group.cards.filter(card => card.id === goalId || card.parentId === goalId)
59 return { ...group, cards: group.cards.filter(card => !taken.includes(card)) }
60 }).filter(group => group.cards.length > 0)
61 const carried = take(stash.carried, 'carried')
62 const archive = take(stash.archive, 'archive')
63 // A failed persistence can leave the source stored after the live write succeeded.
64 // Retrying acknowledges that transfer instead of adding a second copy.
65 if (board.some(card => card.pulledFrom === transferKey)) return { stash: { carried, archive }, cards: [...board] }
66 // Retain stored ids normally; remap incoming collisions and their parent links.
67 const ids = new Map<string, string>()
68 const reserved = [...board, ...taken]
69 for (const card of taken) {
70 if (!board.some(live => live.id === card.id)) continue
71 const id = newId(reserved, card.createdAt)
72 ids.set(card.id, id)
73 reserved.push({ ...card, id })
74 }
75 const pulled = taken.map(card => ({
76 ...card,
77 id: ids.get(card.id) ?? card.id,
78 parentId: card.id === goalId ? null : ids.get(card.parentId ?? '') ?? card.parentId ?? null,
79 carriedAt: null,
80 ...(card.id === goalId ? { column: 'todo' as const, pulledFrom: transferKey } : {}),
81 }))
82 return { stash: { carried, archive }, cards: [...board, ...pulled] }
83}
84hooks/filer.ts 76 lines1import type { Card, Group } from '../types'
2import { addCard, cleanTitle, goals } from './lib'
3
4const ASK_MAX = 1_000
5const TITLE_MAX = 60
6export type Filing = { kind: 'subtask'; parentId: string; title: string } | { kind: 'goal'; title: string }
7
8export const FILER_SYSTEM = [
9 "You file a user's request onto a two-level task board: top goals, each with subtasks.",
10 'If the request is a step of an existing goal (usually the one in doing), file it under that goal; otherwise make it a new goal.',
11 'Reply with JSON only, one of:',
12 '{"subtask_of":"<goal id>","title":"<short imperative>"}',
13 '{"new_goal":"<short imperative>"}',
14 `Titles: imperative, at most ${TITLE_MAX} characters.`,
15].join('\n')
16
17const short = (text: string) => cleanTitle(text).slice(0, TITLE_MAX)
18
19export function filerPrompt(ask: string, cards: readonly Card[]): string {
20 const open = goals(cards).filter(card => card.column !== 'done')
21 const lines = open.map(card => `[${card.id}] ${card.title} (${card.column})`)
22 return [`Request:\n${ask.slice(0, ASK_MAX)}`, '', 'Open goals:', ...(lines.length === 0 ? ['(none)'] : lines)].join('\n')
23}
24
25/** Accept a JSON object even when wrapped in prose or a code fence. */
26export function parseFiling(text: string, cards: readonly Card[]): Filing | null {
27 // A reply that is an array (bare or fenced) is not a filing object.
28 if (/^\s*(```\w*\s*)?\[/.test(text)) return null
29 const json = text.match(/\{[\s\S]*\}/)?.[0]
30 if (json === undefined) return null
31 let value: Record<string, unknown>
32 try {
33 value = JSON.parse(json)
34 } catch {
35 return null
36 }
37 if (value === null || typeof value !== 'object' || Array.isArray(value)) return null
38 const keys = Object.keys(value)
39 if (keys.length === 1 && typeof value.new_goal === 'string' && value.new_goal.trim() !== '') return { kind: 'goal', title: short(value.new_goal) }
40 if (keys.length === 2 && typeof value.subtask_of === 'string' && typeof value.title === 'string' && value.title.trim() !== '') {
41 const parent = goals(cards).find(card => card.id === value.subtask_of && card.column !== 'done')
42 if (parent === undefined) return null
43 return { kind: 'subtask', parentId: parent.id, title: short(value.title) }
44 }
45 return null
46}
47
48export function fallbackFiling(ask: string): Filing {
49 return { kind: 'goal', title: short(ask.split('\n').find(line => line.trim() !== '') ?? ask) }
50}
51
52export function applyFiling(cards: readonly Card[], filing: Filing, at: { at: number; sessionId: string; byHaiku: boolean; inboxId?: string }): Card[] {
53 return addCard(cards, {
54 title: filing.title,
55 source: 'claude',
56 column: 'todo',
57 parentId: filing.kind === 'subtask' ? filing.parentId : null,
58 sessionId: at.sessionId,
59 filedBy: at.byHaiku ? 'haiku' : null,
60 ...(at.inboxId === undefined ? {} : { inboxId: at.inboxId }),
61 distinct: true,
62 }, at.at).cards
63}
64
65export const LABEL_SYSTEM = 'Name a work session from its goals in 3 to 6 words. Reply with the name only.'
66
67export function labelPrompt(group: Pick<Group, 'cards'>): string {
68 return goals(group.cards).map(card => `- ${card.title}`).join('\n')
69}
70
71export function parseLabel(text: string): string | null {
72 const label = text.trim().replace(/^["'`]+|["'`.]+$/g, '').trim().replace(/\s+/g, ' ')
73 const words = label.split(/\s+/).filter(Boolean).length
74 return words >= 3 && words <= 6 ? label : null
75}
76hooks/labels.ts 107 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3import type { Card } from '../types'
4import { goals } from './lib'
5import { LABEL_SYSTEM, labelPrompt, parseLabel } from './filer'
6
7const sessionLabel = atom({ plugin: 'task-board', key: 'sessionLabel' } as const, null)
8const cards = atom({ plugin: 'task-board', key: 'cards' } as const, [] as Card[])
9const WINDOW = 3 * 60_000
10
11type Labels = {
12 sid: string | null; titles: string; lastCall: number | null; due: number | null
13 epoch: number; request: number; stopped: boolean; waiting: boolean; tail: Promise<unknown>
14}
15
16export function runningLabels(): Labels {
17 return { sid: null, titles: '[]', lastCall: null, due: null, epoch: 0, request: 0, stopped: false, waiting: false, tail: Promise.resolve() }
18}
19
20function reset(labels: Labels): void {
21 labels.epoch++
22 labels.titles = '[]'
23 labels.lastCall = null
24 labels.due = null
25 labels.waiting = false
26 labels.stopped = false
27}
28
29export function startLabels(labels: Labels): void {
30 if (labels.stopped) { reset(labels); labels.sid = null }
31}
32
33export function stopLabels(labels: Labels, sessionId: string): void {
34 labels.sid ??= sessionId
35 labels.stopped = true
36 labels.due = null
37 labels.epoch++
38}
39
40async function call($: EngineInterface, labels: Labels, board: Card[], at: number): Promise<void> {
41 if (labels.stopped) return
42 labels.lastCall = at
43 labels.due = null
44 const generation = labels.epoch
45 const sequence = ++labels.request
46 const session = labels.sid
47 const reply = await $.model.complete({ model: 'haiku', effort: 'low', timeoutMs: 10_000,
48 system: LABEL_SYSTEM, prompt: labelPrompt({ cards: board }) }).catch(() => null)
49 const label = reply?.isAnswered ? parseLabel(reply.text) : null
50 if (label === null || labels.stopped || generation !== labels.epoch || sequence !== labels.request || await $.session.id() !== session) return
51 await update($, sessionLabel, current => labels.stopped || generation !== labels.epoch || sequence !== labels.request ? current : label)
52}
53
54/** One sleeper tracks a movable deadline; engine-clock waits are deterministic in tests. */
55async function wait($: EngineInterface, labels: Labels): Promise<void> {
56 if (labels.waiting) return
57 labels.waiting = true
58 const generation = labels.epoch
59 try {
60 while (!labels.stopped && generation === labels.epoch && labels.due !== null) {
61 await $.clock.sleep(Math.max(0, labels.due - await $.clock.now()))
62 if (labels.stopped || generation !== labels.epoch || labels.due === null) return
63 const now = await $.clock.now()
64 if (now < labels.due) continue
65 const board = await read($, cards)
66 if (labels.stopped || generation !== labels.epoch) return
67 if (goals(board).length > 0) void call($, labels, board, now).catch(() => {})
68 else labels.due = null
69 }
70 } finally {
71 if (generation === labels.epoch) labels.waiting = false
72 }
73}
74
75/** Leading call plus one trailing call after three quiet minutes. */
76export function labelsChanged($: EngineInterface, labels: Labels, restored = false): Promise<void> {
77 // Observe committed state in order, including simultaneous filings and Pull ins.
78 const changed = labels.tail.then(async () => {
79 const session = await $.session.id()
80 // End may create the very first goal while draining the inbox. A missing
81 // sid is not a new session and must never re-enable calls during that drain.
82 if (labels.stopped && (labels.sid === null || labels.sid === session)) return
83 if (labels.sid !== session) { reset(labels); labels.sid = session }
84 if (labels.stopped) return
85 const board = await read($, cards)
86 const next = JSON.stringify([...new Set(goals(board).map(card => card.title))].sort())
87 if (next === labels.titles) return
88 labels.titles = next
89 const now = await $.clock.now()
90 if (next === '[]') { labels.due = null; return }
91 if (restored && labels.lastCall === null) {
92 // A reload lost the in-memory timer. Resume conservatively after a
93 // quiet window, including when an older successful label was restored.
94 labels.lastCall = now
95 labels.due = now + WINDOW
96 void wait($, labels).catch(() => {})
97 } else if (labels.lastCall === null || now - labels.lastCall >= WINDOW) {
98 void call($, labels, board, now).catch(() => {})
99 } else {
100 labels.due = now + WINDOW
101 void wait($, labels).catch(() => {})
102 }
103 })
104 labels.tail = changed.catch(() => {})
105 return changed
106}
107types/index.d.ts 55 lines1export type Column = 'todo' | 'doing' | 'done'
2
3/** Who put a card on the board: you, Claude's to-do list, an approved plan, or Claude through the board tool. */
4export type CardSource = 'you' | 'todo' | 'plan' | 'claude'
5
6export type Card = {
7 id: string
8 /** Lifetime identity: an id can be reused after deletion, but this cannot. */
9 incarnation?: string
10 title: string
11 /** More about it, when whoever added it said more; else empty. */
12 note: string
13 column: Column
14 source: CardSource
15 /** What ties a card to Claude's to-do list: the item's text (TodoWrite) or its task id (TaskCreate). */
16 ref: string | null
17 createdAt: number
18 /** null for a top goal; otherwise its top goal's id. */
19 parentId: string | null
20 sessionId: string
21 sessionLabel: string | null
22 carriedAt: number | null
23 filedBy: 'haiku' | null
24 /** Stable request identity, independent of its shortened display title. */
25 inboxId?: string
26 /** Stored-goal transfer identity, retained until persistence acknowledges the pull. */
27 pulledFrom?: string
28}
29
30/** One ended session's unfinished goals and subtasks. */
31export type Group = { sessionId: string; label: string; isLabelled: boolean; endedAt: number; cards: Card[] }
32export type Stash = { carried: Group[]; archive: Group[] }
33export type View = { kind: 'top' } | { kind: 'goal'; id: string } | { kind: 'carried' } | { kind: 'archive' }
34export type InboxItem = { id: string; text: string; sessionId?: string; createdAt?: number }
35
36declare module 'claude-code' {
37 interface PluginState {
38 'task-board': {
39 /** The session's cards, in the order they were added. */
40 cards: Card[]
41 /** The card whose details are open under the board. */
42 selected: string | null
43 /** The card being written: a new one (id null) or one being edited; null when the composer is closed. */
44 composer: { id: string | null; title: string; note: string; hasNote: boolean } | null
45 /** The card handed to Claude with Work on it, until Claude's turn on it ends. */
46 working: string | null
47 inbox: InboxItem[]
48 view: View
49 stash: Stash
50 /** Latest successful background Haiku name for this live session. */
51 sessionLabel: string | null
52 }
53 }
54}
55