SLOPSHOPPER

task-board

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…

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-board
│ ┃ Board ✕ › fix the failing auth test and add an audit log call │ ┃ ↻ Reload │ ┃ Nothing on the board yet [ + New card ] ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Add a card with New card. Claude's to-do ⏺ Update(src/auth.ts) │ ┃ list and the plans you approve show up here ⎿ Added 2 lines, removed 1 line │ ┃ by themselves, and Claude can add cards when ⏺ Bash(bun test) │ ┃ you ask it to put something on the board. ⎿ 3 pass, 1 fail │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /board │ ⎿ task-board: Board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Board
↻ Reload Nothing on the board yet [ + New card ] 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 cards when you ask it to put something on the board.
README

task-board

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>

  • Claude's to-do list: the list Claude keeps while it works becomes subtasks, which move to Doing and Done as Claude goes. /board opens the pane.
  • Approved plans: when you approve a plan, its steps (its numbered items, else its bullets or step headings) go to To do.
  • A board tool for Claude: Claude sends real asks to 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.
  • Your own cards: + New card opens a composer on the title; Enter or Add card adds the card to To do. + Description swaps the title field for a description field (the title stays above it, ‹ Title goes back), whose Enter or Add card adds it too. ✕ cancels.
  • A card's details: Open shows its full text, where it came from, and buttons to move it a column left or right, Edit (its title and description), ▶ Work on it (hands it to Claude and moves it to Doing; Claude Code holds the prompt until Claude finishes what it is doing) and Delete.
  • Drag and drop: in the desktop app and the terminal, press a card and drag it onto another column: a white copy of it follows the pointer, the card stays dimmed where it was, and the column under the pointer shows Drop here. A click (press and release without moving) opens the card. The columns fill the pane, so there is room to drag below the cards. In VS Code and the mobile app, which cannot draw this, each card has Open and › (move it a column on) instead.
  • Keys (desktop and terminal): click into the columns to give them the keyboard. Arrow keys move the highlighted card: left/right across columns, up/down within a column. Space opens details; Enter opens a goal's subtask board, or a subtask's details. Esc (or the pane's close mark) goes up one level: details → goal board → top board; Carried over and Archive return to the top board. At the top with no details or composer open, it closes the pane. Running /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.

Goals, inbox, carry-over

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.

Install

/plugin marketplace add MarcoCarnevali/claude-code-mods
/plugin install task-board@marco-mods
/reload-plugins
Source 7 files
hooks/register.tsx 842 lines
1import { 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}
842
hooks/lib.ts 363 lines
1import 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}
363
hooks/columns.tsx 283 lines
1import 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
283
hooks/carry.ts 84 lines
1import 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}
84
hooks/filer.ts 76 lines
1import 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}
76
hooks/labels.ts 107 lines
1import { 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}
107
types/index.d.ts 55 lines
1export 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