SLOPSHOPPER

kanbot

A global kanban board in Claude Code that hands queued cards to background Claude agents across your projects

newpaneguardcommandtoaststatus
v0.4.3MITupdated 2026-10-16roboza/kanbot
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kanbot
│ ┃ Kanbot ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Kanbot ╭───────────────────────────────── │ ┃ │ b: tab-board c: tab-card p: ta ⏺ Read(src/auth.ts) │ ┃ ╰───────────────────────────────── ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ ┃ │ GET STARTED │ ⏺ Bash(bun test) │ ┃ │ No projects yet. A project points Kanbot │ ⎿ 3 pass, 1 fail │ ┃ │ at a repo, a workspace of repos, or a │ │ ┃ │ plain working folder. │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ │ ┃ │ [ Set up a project ] │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰──────────────────────────────────────────╯ │ › /kanbot │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Kanbot
◆ Kanbot ╭───────────────────────────────────────────────── │ b: tab-board c: tab-card p: tab-projects a: t ╰───────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ GET STARTED │ │ No projects yet. A project points Kanbot at a repo, a │ │ workspace of repos, or a plain working folder. │ │ │ │ [ Set up a project ] │ ╰──────────────────────────────────────────────────────────╯
README

Kanbot

A global kanban board inside Claude Code that hands queued cards to background Claude agents across your projects. It runs in the Code tab of the Claude desktop app and in the terminal.

  • Projects come in three types:
  • Repo: one git repo. Cards get their own branch and worktree, and are merged when you mark them Done.
  • Workspace: a parent folder holding several repos (for example C:\development\ideajudge-2026 with ideajudge-ui, ideajudge-services, ... inside). Kanbot finds the repos and each one's base branch; a card can change any of them.
  • Chat: a plain folder. Cards run one at a time, no git.
  • Cards move Backlog → Queued → Running → Review → Done. You move cards into Queued and Done; Kanbot moves the rest.
  • One dispatcher session starts queued cards as background agents in each card's folder, tracks their progress, and runs the merge when you mark a card Done.

Screenshots

The Board, Card, Projects and Activity tabs, with sample data.

Code tab of the desktop app

Kanbot in the Code tab of the Claude desktop app

Terminal

Kanbot in the Claude Code terminal

Requirements

  • Claude Code 2.1.287 or later (mods are on by default from that version). Check with claude --version.
  • Mods allowed on the machine. On a managed work machine, run /plugin in a session first: if your organization only allows managed mods, user-installed mods like this one will not load.

Install

From GitHub:

claude plugin marketplace add roboza/kanbot
claude plugin install kanbot@kanbot

Or from a local copy: unzip or clone this folder somewhere you keep it, for example ~/claude-mods/kanbot. Then, in a terminal:

claude plugin marketplace add ~/claude-mods/kanbot
claude plugin install kanbot@kanbot

Kanbot is then active in new sessions, including the Code tab of the desktop app. Because the marketplace is a local folder, edits to the folder take effect after /reload-plugins.

To try it without installing, start the terminal with claude --plugin-dir ~/claude-mods/kanbot.

Getting started

  1. Open the board. In any Code-tab session, run /kanbot. The board opens in a side pane.
  2. Add your projects. Open the Projects tab (p). For each project, enter a name, pick Repo, Workspace or Chat, give the absolute folder, and for repos the base branch, how many cards may run at once, and an optional test command to run after merging. Kanbot checks the folder and branch before saving. For a workspace it lists the repos it found and their base branches; save again later to pick up new repos.
  3. Start a dispatcher. Open a Code-tab session in a folder that contains all your projects and the .kanbot-worktrees folder beside them (for example C:\development or ~/work), set its permission mode to one that allows edits (accept edits, or auto), and run /kanbot work. Keep this session open: it runs the agents. Agents inherit its permission mode; a tool they are refused flags the card as waiting on you.
  4. Add cards. Type in the New card field (<title> | <brief>, or <project>: <title> | <brief> when showing all projects), or run /kanbot add. Click a card to open it: edit the brief, add acceptance checks, and choose sequential or parallel.
  5. Queue work. Drag cards from Backlog to Queued, ordered top to bottom. Kanbot starts them as room allows.
  6. Review. When a card reaches Review, open it to read Claude's summary. Then drag it to Done (or press Done) to merge it, send it back with a note, or discard the work.

From a plan to cards

Plan the work in any Code-tab or terminal session. When you have agreed a plan, run /kanbot queue. Claude splits the plan into cards and adds them to Backlog through Kanbot's queue_cards tool. Each card gets a self-contained brief (the agent that runs it can't see the conversation), acceptance checks, sequential or parallel, and, for a workspace, the repos it changes.

  • The project is the one whose folder holds the session's folder. Name another one first: /kanbot queue IdeaJudge.
  • Anything after the project is passed on as guidance: /kanbot queue IdeaJudge keep it to three cards.
  • Cards wait in Backlog. Review and edit them on the board, then move them to Queued to run them.
  • You can also just ask Claude to "put this plan on Kanbot", which uses the same tool.

How cards run

  • Each project's queue drains top to bottom. A sequential card waits until nothing else of its project runs and holds the cards under it. A parallel card starts while the project's limit allows. Chat projects always run one card at a time.
  • All projects run side by side, up to a global limit (4 by default; change it in the Projects tab).
  • A repo card runs in its own git worktree on branch kanbot/<card id>, at <parent of repo>/.kanbot-worktrees/<repo>/<card id>. Your main checkout is not touched while it works.
  • A workspace card gets a card folder that mirrors the workspace, at <parent of workspace>/.kanbot-worktrees/<workspace>/<card id>/<repo>, with one worktree per repo on branch kanbot/<card id>. By default a card may change every repo; open the card to tick only the repos it should touch.
  • Done on a workspace card merges only the repos where the card has commits, each into its own base branch. The merge agent checks every repo is clean and on its base before merging anything, and stops at the first conflict it cannot resolve. Repos that merged are cleaned up; the card stays in Review listing the repos still to merge, and marking it Done again retries those.
  • Done on a repo card starts a merge agent in the repo's main folder. It stops if the checkout has uncommitted changes or is not on the base branch, merges with --no-ff, runs the test command if you set one, and undoes the merge if the tests fail. Kanbot then checks the branch really landed before moving the card to Done and removing the worktree and branch. Nothing is pushed.
  • If an agent disappears (for example, the dispatcher session was closed), the card returns to the top of Queued and starts again.

Commands

CommandDoes
/kanbotOpen the board
/kanbot add <title>Add a card; also `<project>: <title> \<brief>`
/kanbot queue [project] [notes]Have Claude turn the plan agreed in this conversation into cards in Backlog
/kanbot workMake this session the dispatcher (work force takes over from another)
/kanbot pause / resumeStop or restart starting new cards
/kanbot stopStop dispatching from this session
/kanbot projectsOpen the Projects tab

Pane keys: b Board, c Card, p Projects, a Activity.

Where data lives

Everything is in Kanbot's own store under ~/.claude/plugins/store/, shared by every session on the machine. The status line in each session shows running, queued, waiting and review counts.

Development

claude plugin validate .
claude plugin test .
Source 7 files
hooks/register.tsx 1022 lines
1// Kanbot: a global kanban board in Claude Code that hands queued cards to
2// background Claude agents across your projects.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, RenderChildren } from 'claude-code'
5
6import type { Column, Project, ProjectDraft, Tab } from '../types'
7import type { BoardProps } from './board-client'
8import type { Board } from './data'
9import {
10  addCard,
11  addCards,
12  applyUserMove,
13  checkProject,
14  deleteCard,
15  discoverRepos,
16  loadBoard,
17  nextColour,
18  patchCard,
19  readActivity,
20  removeProject,
21  saveProject,
22  touch,
23} from './data'
24import {
25  cardForAgent,
26  dispatch,
27  dispatcherState,
28  finished,
29  me,
30  sawDenied,
31  sawTaskMade,
32  sawToolCall,
33} from './dispatch'
34import {
35  COLUMNS,
36  COLUMN_NAMES,
37  DEFAULT_CAP,
38  EMPTY_DRAFT,
39  ago,
40  cardFlag,
41  cardMeta,
42  checkPlanned,
43  counts,
44  findProject,
45  inColumn,
46  isWithin,
47  leadingProject,
48  newId,
49  parseAdd,
50  queuePrompt,
51  rankAt,
52  statusLine,
53} from './model'
54import { COLUMN_TONE, flagTone, svgBar, textBar } from './style'
55
56const TAB = atom({ plugin: 'kanbot', key: 'tab' } as const, 'board')
57const SELECTED = atom({ plugin: 'kanbot', key: 'selected' } as const, '')
58const FILTER = atom({ plugin: 'kanbot', key: 'filter' } as const, 'all')
59const REV = atom({ plugin: 'kanbot', key: 'rev' } as const, 0)
60const DRAFT = atom({ plugin: 'kanbot', key: 'draft' } as const, EMPTY_DRAFT)
61const EDITING = atom({ plugin: 'kanbot', key: 'editing' } as const, '')
62
63type E = EngineInterface
64
65const PANE = 'kanbot'
66const QUEUE_TOOL = 'mcp__kanbot__queue_cards'
67const QUEUE_SPEC = {
68  name: 'queue_cards',
69  description: [
70    "Adds cards to the Kanbot board's Backlog, in order, for the person to review and then queue.",
71    'Each card is later run by a background Claude agent that cannot see this conversation, so each brief must stand alone.',
72    'Use it when the person asks to put a plan or tasks on Kanbot (for example after /kanbot queue).',
73  ].join(' '),
74  inputSchema: {
75    type: 'object',
76    properties: {
77      project: { type: 'string', description: "The Kanbot project's name. Leave it out to use the project this session's folder belongs to." },
78      cards: {
79        type: 'array',
80        minItems: 1,
81        maxItems: 20,
82        description: 'The cards in the order they should run.',
83        items: {
84          type: 'object',
85          properties: {
86            title: { type: 'string', description: 'Short imperative title, under 120 characters.' },
87            brief: { type: 'string', description: 'Self-contained instructions: goal, files or areas, decisions, constraints, what done looks like.' },
88            acceptance: { type: 'array', items: { type: 'string' }, description: 'One to five checks a reviewer can verify.' },
89            mode: { type: 'string', enum: ['sequential', 'parallel'], description: 'sequential waits for the cards above it; parallel can run alongside them. Default sequential.' },
90            repos: { type: 'array', items: { type: 'string' }, description: 'Workspace projects only: the repos this card changes. Leave out for all.' },
91          },
92          required: ['title', 'brief'],
93        },
94      },
95    },
96    required: ['cards'],
97  },
98  isDeferred: false,
99} as const
100const TICK_MS = 3000
101const HELP = [
102  'Kanbot commands:',
103  '  /kanbot                      open the board',
104  '  /kanbot add <title>          add a card (also "<project>: <title> | <brief>")',
105  '  /kanbot queue [project] [notes]  have Claude turn this conversation\'s plan into Backlog cards',
106  '  /kanbot work                 make this session the dispatcher that runs cards',
107  '  /kanbot pause | resume       stop or restart starting new cards',
108  '  /kanbot stop                 stop dispatching from this session',
109  '  /kanbot projects             open the Projects tab',
110  '  /kanbot help                 this list',
111].join('\n')
112
113/** What a person has typed into a field, kept so a redraw does not wipe it. */
114const typing = new Map<string, string>()
115let lastRev = 0
116
117function isColumn(v: unknown): v is Column {
118  return typeof v === 'string' && (COLUMNS as readonly string[]).includes(v)
119}
120
121async function openPane($: E, tab?: Tab): Promise<void> {
122  if (tab !== undefined) await update($, TAB, () => tab)
123  await $.ui.open({ id: PANE, title: 'Kanbot', columns: 110 })
124}
125
126function projectForCwd(projects: readonly Project[], cwd: string): Project | undefined {
127  const sorted = [...projects].sort((a, b) => b.path.length - a.path.length)
128  return sorted.find(p => isWithin(cwd, p.path))
129}
130
131async function defaultProject($: E, board: Board): Promise<Project | undefined> {
132  const filter = await read($, FILTER)
133  const filtered = board.projects.find(p => p.id === filter)
134  if (filtered !== undefined) return filtered
135  const here = projectForCwd(board.projects, await $.session.cwd())
136  if (here !== undefined) return here
137  return board.projects.length === 1 ? board.projects[0] : undefined
138}
139
140/** Every session: notice other sessions' changes, keep the status line, dispatch if it is ours. */
141async function tick($: E): Promise<void> {
142  const rev = Number((await $.store.get('rev')) ?? 0)
143  if (rev !== lastRev) {
144    lastRev = rev
145    await update($, REV, () => rev)
146  }
147  const board = await loadBoard($)
148  const state = dispatcherState(board, Date.now())
149  if (me.isDispatcher && state === 'mine') await dispatch($, board)
150  const after = me.isDispatcher ? await loadBoard($) : board
151  const mine = dispatcherState(after, Date.now())
152  $.ui.status(
153    after.projects.length === 0
154      ? 'kanbot: add a project with /kanbot projects'
155      : statusLine(counts(after.cards), mine === 'mine' ? true : mine === 'other' ? false : undefined),
156  )
157}
158
159async function becomeDispatcher($: E, force: boolean): Promise<string> {
160  const board = await loadBoard($)
161  const state = dispatcherState(board, Date.now())
162  if (state === 'other' && !force) {
163    const d = board.dispatcher
164    const seen = d === undefined ? '' : ` (last heartbeat ${ago(Date.now() - d.heartbeatAt)} ago)`
165    return `Another session is already dispatching${seen}. Run /kanbot work force to take over from this one.`
166  }
167  await $.store.set('dispatcher', { sessionId: me.sessionId, heartbeatAt: Date.now(), isPaused: false })
168  me.isDispatcher = true
169  await touch($)
170  return [
171    'This session is now the Kanbot dispatcher: it starts queued cards as background agents and merges cards you mark Done.',
172    'Keep it open while cards run. Agents inherit this session\'s permission mode, so use one that allows edits (for example accept edits) or they will stop on refused tools.',
173  ].join('\n')
174}
175
176export const register: Register = on => {
177  on('session.start', async ($, e, next) => {
178    me.sessionId = await $.session.id()
179    const d = await $.store.get('dispatcher')
180    if (typeof d === 'object' && d !== null && (d as { sessionId?: unknown }).sessionId === me.sessionId) me.isDispatcher = true
181    if ((await $.store.get('settings')) === undefined) await $.store.set('settings', { globalCap: DEFAULT_CAP })
182    $.clock.every(TICK_MS, () => tick($))
183    try {
184      await $.command.register({
185        name: 'kanbot',
186        description: 'Kanbot board: add, work, pause, resume, stop, projects, help',
187        argumentHint: '[add <title> | queue [project] | work | pause | resume | stop | projects | help]',
188        immediate: true,
189      })
190    } catch (err) {
191      $.ui.log(`kanbot: could not register /kanbot: ${String(err)}`)
192    }
193    try {
194      await $.tool.register({ ...QUEUE_SPEC })
195    } catch (err) {
196      $.ui.log(`kanbot: could not register the queue_cards tool: ${String(err)}`)
197    }
198    return next(e)
199  })
200
201  on('command.run', { command: 'kanbot' }, async ($, e) => {
202    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
203    const tail = e.args.trim().slice(verb.length).trim()
204    switch (verb.toLowerCase()) {
205      case '':
206        await openPane($)
207        return {}
208      case 'help':
209        return { text: HELP }
210      case 'projects':
211      case 'project':
212        await openPane($, 'projects')
213        return {}
214      case 'work':
215        return { text: await becomeDispatcher($, rest[0] === 'force') }
216      case 'pause':
217      case 'resume': {
218        const d = await $.store.get('dispatcher')
219        if (typeof d !== 'object' || d === null) return { text: 'No session is dispatching. Run /kanbot work first.' }
220        await $.store.set('dispatcher', { ...(d as object), isPaused: verb === 'pause' })
221        await touch($)
222        return { text: verb === 'pause' ? 'Kanbot paused: running cards finish, no new ones start.' : 'Kanbot resumed.' }
223      }
224      case 'stop': {
225        if (!me.isDispatcher) return { text: 'This session is not the dispatcher.' }
226        await $.store.delete('dispatcher')
227        me.isDispatcher = false
228        await touch($)
229        return { text: 'This session no longer dispatches. Running agents keep going until they finish.' }
230      }
231      case 'queue':
232      case 'plan': {
233        const board = await loadBoard($)
234        if (board.projects.length === 0) return { text: 'Add a project first: /kanbot projects' }
235        const lead = leadingProject(tail, board.projects)
236        const project = lead.project ?? (await defaultProject($, board))
237        if (project === undefined) {
238          return { text: `Which project? Use /kanbot queue <project>. Projects: ${board.projects.map(p => p.name).join(', ')}` }
239        }
240        // A command hook may not submit (it would wait on its own turn): submit once it has returned.
241        const text = queuePrompt(project, lead.rest)
242        $.clock.after(0, () => {
243          $.prompt.submit({ text }).catch(err => $.ui.log(`kanbot: could not hand the plan to Claude: ${String(err)}`))
244        })
245        return { text: `Asking Claude to turn this conversation's plan into cards for ${project.name}. They will land in Backlog for you to review.` }
246      }
247      case 'add': {
248        const board = await loadBoard($)
249        if (board.projects.length === 0) return { text: 'Add a project first: /kanbot projects' }
250        const parsed = parseAdd(tail, board.projects)
251        if (parsed.title === '') return { text: 'Usage: /kanbot add <title>, or /kanbot add <project>: <title> | <brief>' }
252        const project = board.projects.find(p => p.id === parsed.projectId) ?? (await defaultProject($, board))
253        if (project === undefined) {
254          return { text: `Which project? Use /kanbot add <project>: ${parsed.title}. Projects: ${board.projects.map(p => p.name).join(', ')}` }
255        }
256        const card = await addCard($, board, { projectId: project.id, title: parsed.title, brief: parsed.brief })
257        return { text: `Added card ${card.id} to ${project.name}'s Backlog: ${card.title}` }
258      }
259      default:
260        return { text: `Unknown: /kanbot ${verb}\n\n${HELP}` }
261    }
262  })
263
264  // The model adds a plan's cards to Backlog.
265  on('tool.call', { tool: QUEUE_TOOL }, async ($, e) => {
266    const input = e as unknown as { project?: unknown; cards?: unknown }
267    const board = await loadBoard($)
268    if (board.projects.length === 0) return { deny: 'Kanbot has no projects yet. Ask the person to add one with /kanbot projects.' }
269    const named = typeof input.project === 'string' && input.project.trim() !== '' ? input.project : undefined
270    const project = named !== undefined ? findProject(named, board.projects) : await defaultProject($, board)
271    if (project === undefined) {
272      const list = board.projects.map(p => p.name).join(', ')
273      return { deny: named !== undefined ? `No Kanbot project is called "${named}". Projects: ${list}.` : `Say which project with \`project\`. Projects: ${list}.` }
274    }
275    const checked = checkPlanned(input.cards, project)
276    if ('error' in checked) return { deny: checked.error }
277    const added = await addCards($, board, project.id, checked.cards, 'Created from a plan')
278    $.ui.toast(`Kanbot: ${added.length} card${added.length === 1 ? '' : 's'} added to ${project.name}'s Backlog`)
279    return {
280      result: [
281        `Added ${added.length} card${added.length === 1 ? '' : 's'} to ${project.name}'s Backlog:`,
282        ...added.map(c => `- ${c.id} ${c.title} (${c.mode}${c.repos !== undefined ? `; ${c.repos.join(', ')}` : ''})`),
283        'They stay in Backlog until the person reviews them and moves them to Queued on the board.',
284      ].join('\n'),
285    }
286  })
287
288  // Follow every tool call a card's agent makes.
289  on('tool.call', async ($, e, next) => {
290    const cardId = cardForAgent(e.agentId)
291    if (cardId === undefined) return next(e)
292    const input = e as unknown as Record<string, unknown>
293    const tool = String(e.tool)
294    sawToolCall(cardId, tool, input)
295    const result = await next(e)
296    const r = result as unknown as { deny?: unknown; isError?: unknown }
297    if (typeof r.deny === 'string') sawDenied(cardId, tool, r.deny)
298    else if (tool === 'TaskCreate' && r.isError !== true) sawTaskMade(cardId)
299    return result
300  })
301
302  // A card's agent finished: move the card on.
303  on('turn.complete', async ($, e, next) => {
304    const result = await next(e)
305    if (e.agentId !== undefined && cardForAgent(e.agentId) !== undefined) {
306      await finished($, e.agentId, { answer: e.answer, reason: e.reason, usage: e.usage })
307    }
308    return result
309  })
310
311  // The drag-and-drop board posts moves and opens.
312  on('ui.message', { component: 'Pane', element: 'board' }, async ($, e) => {
313    const data = e.data as { type?: unknown; id?: unknown; col?: unknown; index?: unknown }
314    if (typeof data !== 'object' || data === null || typeof data.id !== 'string') return {}
315    if (data.type === 'open') {
316      await update($, SELECTED, () => data.id as string)
317      await update($, TAB, () => 'card')
318      return {}
319    }
320    if (data.type === 'move' && isColumn(data.col) && typeof data.index === 'number') {
321      const board = await loadBoard($)
322      const filter = await read($, FILTER)
323      const others = inColumn(board.cards, data.col, filter === 'all' ? undefined : filter).filter(c => c.id !== data.id)
324      const said = await applyUserMove($, board, data.id, data.col, rankAt(others, data.index))
325      if (said !== undefined) $.ui.toast(said)
326    }
327    return {}
328  })
329
330  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
331    const el = $.ui.resolve(e)
332    const { Box, Text, Button } = el
333    if (e.surface !== 'terminal' && e.surface !== 'desktop') {
334      return <Text>Kanbot's board needs the terminal or the Code tab of the desktop app.</Text>
335    }
336    const { Input, Select, Markdown, Client } = $.ui.resolve(e)
337    // Svg is drawn by the desktop only; the terminal gets text bars instead.
338    const Svg = e.surface === 'desktop' ? $.ui.resolve(e).Svg : undefined
339    await read($, REV)
340    const tab = await read($, TAB)
341    const board = await loadBoard($)
342    const now = Date.now()
343    const projectName = (id: string) => board.projects.find(p => p.id === id)?.name ?? '?'
344    const go = (t: Tab) => () => void update($, TAB, () => t)
345    const toast = (said: string | undefined) => {
346      if (said !== undefined) $.ui.toast(said)
347    }
348    const width = e.props.bodyColumns ?? e.viewport?.columns ?? 100
349
350    const isDesktop = e.surface === 'desktop'
351    /** A small filled label: a status, a project, a column. */
352    const chip = (key: string, text: string, tone: string) => (
353      <Text key={key} color="inverseText" backgroundColor={tone} wrap="truncate-end">{` ${text} `}</Text>
354    )
355    /** A bordered section with a heading. */
356    const panel = (key: string, title: string, children: RenderChildren, tone = 'subtle') => (
357      <Box key={key} flexDirection="column" borderStyle="round" borderColor={tone} borderDimColor={tone === 'subtle'} paddingX={1} marginTop={1}>
358        <Text size="footnote" weight="semibold" color={tone === 'subtle' ? 'subtle' : tone}>{title.toUpperCase()}</Text>
359        {children}
360      </Box>
361    )
362
363    const ds = dispatcherState(board, now)
364    const paused = board.dispatcher?.isPaused === true
365    const [who, whoTone] =
366      ds === 'mine'
367        ? paused ? ['● paused here', 'warning'] : ['● dispatching here', 'success']
368        : ds === 'other'
369          ? ['● dispatched elsewhere', 'suggestion']
370          : ds === 'stale'
371            ? ['○ dispatcher gone', 'error']
372            : ['○ no dispatcher', 'error']
373    const tally = counts(board.cards)
374
375    const tabButton = (t: Tab, label: string, hotkey: string) => {
376      const active = tab === t
377      return isDesktop ? (
378        <Button key={`tab-${t}`} label={label} hotkey={hotkey} variant={active ? 'primary' : 'secondary'} onPress={go(t)} />
379      ) : (
380        <Button key={`tab-${t}`} hotkey={hotkey} plain dimColor={!active} onPress={go(t)}>
381          <Text bold={active} color={active ? 'claude' : undefined} underline={active}>
382            {label}
383          </Text>
384        </Button>
385      )
386    }
387
388    const titleBar = (
389      <Box flexDirection="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" columnGap={2}>
390        <Box flexDirection="row" alignItems="center" columnGap={2}>
391          <Text size="title" weight="bold" color="claude">
392            ◆ Kanbot
393          </Text>
394          <Box flexDirection="row" columnGap={2} borderStyle="round" borderColor="subtle" borderDimColor paddingX={1}>
395            {tabButton('board', 'Board', 'b')}
396            {tabButton('card', 'Card', 'c')}
397            {tabButton('projects', 'Projects', 'p')}
398            {tabButton('activity', 'Activity', 'a')}
399          </Box>
400        </Box>
401        <Box flexDirection="row" alignItems="center" columnGap={1}>
402          {tally.waiting > 0 && chip('chip-waiting', `${tally.waiting} waiting on you`, 'warning')}
403          <Text size="footnote" dimColor tabular wrap="truncate-end">
404            {`${tally.running} running · ${tally.queued} queued · ${tally.review} review`}
405          </Text>
406          {chip('chip-dispatcher', who, whoTone)}
407        </Box>
408      </Box>
409    )
410
411    let body
412    if (tab === 'board') body = await boardTab()
413    else if (tab === 'card') body = await cardTab()
414    else if (tab === 'projects') body = await projectsTab()
415    else body = await activityTab()
416
417    return (
418      <Box flexDirection="column">
419        {titleBar}
420        {body}
421      </Box>
422    )
423
424    async function boardTab() {
425      if (board.projects.length === 0) {
426        return panel(
427          'welcome',
428          'Get started',
429          <Box flexDirection="column" rowGap={1}>
430            <Text wrap="wrap">No projects yet. A project points Kanbot at a repo, a workspace of repos, or a plain working folder.</Text>
431            <Box flexDirection="row">
432              <Button key="go-projects" label="Set up a project" variant="primary" onPress={go('projects')} />
433            </Box>
434          </Box>,
435          'claude',
436        )
437      }
438      const filter = await read($, FILTER)
439      const selected = await read($, SELECTED)
440      const shown = filter === 'all' ? board.cards : board.cards.filter(c => c.projectId === filter)
441      const cols: BoardProps['cols'] = COLUMNS.map(col => {
442        const list = inColumn(shown, col)
443        return {
444          key: col,
445          name: COLUMN_NAMES[col],
446          tone: COLUMN_TONE[col],
447          count: list.length,
448          cards: list.slice(0, 40).map(c => ({
449            id: c.id,
450            title: c.title,
451            meta: cardMeta(c, projectName(c.projectId), now),
452            flag: cardFlag(c, now),
453            flagTone: flagTone(cardFlag(c, now)),
454            colour: board.projects.find(p => p.id === c.projectId)?.colour ?? 'white',
455            selected: c.id === selected,
456          })),
457        }
458      })
459      const rows = Math.max(12, (e.viewport?.rows ?? 30) - 11)
460      const options = [{ value: 'all', label: 'All projects' }, ...board.projects.map(p => ({ value: p.id, label: p.name }))]
461      return (
462        <Box flexDirection="column">
463          <Box flexDirection="row" columnGap={2} marginTop={1} alignItems="center">
464            <Select key="filter" label="Show" options={options} value={filter} onSelect={v => void update($, FILTER, () => v)} />
465            <Input
466              key="new-card"
467              label="New card"
468              placeholder={filter === 'all' ? '<project>: <title> | <brief>' : '<title> | <brief>'}
469              value={typing.get('new-card') ?? ''}
470              submitLabel="add"
471              onInput={v => typing.set('new-card', v)}
472              onSubmit={async v => {
473                const fresh = await loadBoard($)
474                const parsed = parseAdd(v, fresh.projects)
475                if (parsed.title === '') return
476                const project = fresh.projects.find(p => p.id === parsed.projectId) ?? (await defaultProject($, fresh))
477                if (project === undefined) {
478                  $.ui.toast('Pick a project in Show, or start with "<project>: "')
479                  return
480                }
481                typing.delete('new-card')
482                await addCard($, fresh, { projectId: project.id, title: parsed.title, brief: parsed.brief })
483              }}
484            />
485          </Box>
486          <Box marginTop={1}>
487            <Client key="board" module="./board-client.tsx" props={{ cols, rows, columns: width }} width="100%" height={rows} />
488          </Box>
489          <Text size="footnote" dimColor wrap="truncate-end">
490            Drag cards between columns · click to open · Backlog → Queued starts a card · Review → Done merges it
491          </Text>
492        </Box>
493      )
494    }
495
496    async function cardTab() {
497      const id = await read($, SELECTED)
498      const card = board.cards.find(c => c.id === id)
499      if (card === undefined) {
500        return panel('no-card', 'Card', <Text dimColor>Click a card on the Board to open it here.</Text>)
501      }
502      const project = board.projects.find(p => p.id === card.projectId)
503      const move = (to: Column) => async () => toast(await applyUserMove($, await loadBoard($), card.id, to, undefined, typing.get(`note-${card.id}`)))
504      const nudge = (delta: -1 | 1) => async () => {
505        const fresh = await loadBoard($)
506        const list = inColumn(fresh.cards, card.column).filter(c => c.id !== card.id)
507        const here = inColumn(fresh.cards, card.column).findIndex(c => c.id === card.id)
508        await patchCard($, card.id, c => ({ ...c, rank: rankAt(list, Math.max(0, here + delta)) }))
509      }
510      const actions = []
511      if (card.column === 'backlog') {
512        actions.push(<Button key="act-queue" label="Queue" hotkey="q" variant="primary" onPress={move('queued')} />)
513        actions.push(
514          <Button
515            key="act-delete" variant="secondary"
516            label="Delete"
517            onPress={async () => {
518              await deleteCard($, card)
519              await update($, SELECTED, () => '')
520            }}
521          />,
522        )
523      }
524      if (card.column === 'queued') actions.push(<Button key="act-unqueue" variant="secondary" label="Back to Backlog" onPress={move('backlog')} />)
525      if (card.column === 'backlog' || card.column === 'queued') {
526        actions.push(<Button key="act-up" variant="secondary" label="Up" hotkey="u" onPress={nudge(-1)} />)
527        actions.push(<Button key="act-down" variant="secondary" label="Down" hotkey="d" onPress={nudge(1)} />)
528        actions.push(
529          <Button
530            key="act-mode" variant="secondary"
531            label={card.mode === 'sequential' ? 'Make parallel' : 'Make sequential'}
532            onPress={() => void patchCard($, card.id, c => ({ ...c, mode: c.mode === 'sequential' ? 'parallel' : 'sequential' }))}
533          />,
534        )
535      }
536      if (card.column === 'review' && card.run?.phase !== 'merge') {
537        actions.push(<Button key="act-done" label={card.mergeRequested === true ? 'Merge queued' : 'Done'} hotkey="y" variant="primary" onPress={move('done')} />)
538        actions.push(<Button key="act-back" variant="secondary" label="Send back" onPress={move('queued')} />)
539        actions.push(<Button key="act-discard" variant="secondary" label="Discard" onPress={move('backlog')} />)
540      }
541      const editable = card.column === 'backlog' || card.column === 'queued' || card.column === 'review'
542      const progress = card.progress
543      const stats = [
544        card.activity !== undefined ? `${card.activity.toolCalls} tool calls` : '',
545        card.activity !== undefined ? `last ${card.activity.lastTool} ${ago(now - card.activity.lastAt)} ago` : '',
546        card.activity !== undefined ? `${Math.round(card.activity.tokens / 1000)}k tokens` : '',
547        card.run !== undefined ? `${card.run.phase === 'merge' ? 'merging' : 'running'} for ${ago(now - card.run.startedAt)}` : '',
548      ].filter(s => s !== '')
549      return (
550        <Box flexDirection="column" marginTop={1}>
551          <Box flexDirection="row" columnGap={1}>
552            <Text color={project?.colour}>▌</Text>
553            <Text size="heading" weight="bold" wrap="wrap">
554              {card.title}
555            </Text>
556          </Box>
557          <Box flexDirection="row" columnGap={1} flexWrap="wrap" marginTop={1}>
558            {chip('chip-project', project?.name ?? '?', project?.colour ?? 'subtle')}
559            {chip('chip-column', COLUMN_NAMES[card.column], COLUMN_TONE[card.column])}
560            {chip('chip-mode', card.mode === 'parallel' ? '∥ parallel' : '→ sequential', 'subtle')}
561            <Text size="footnote" dimColor mono wrap="truncate-middle">
562              {`${card.id}${card.branch !== undefined ? ` · ${card.branch}` : ''}`}
563            </Text>
564          </Box>
565          {card.waiting !== undefined && card.waiting !== '' && (
566            <Box borderStyle="round" borderColor="warning" paddingX={1} marginTop={1}>
567              <Text color="warning" wrap="wrap">{`! Waiting on you: ${card.waiting}`}</Text>
568            </Box>
569          )}
570          {card.problem !== undefined && card.problem !== '' && (
571            <Box borderStyle="round" borderColor="error" paddingX={1} marginTop={1}>
572              <Text color="error" wrap="wrap">{`× ${card.problem}`}</Text>
573            </Box>
574          )}
575          {actions.length > 0 && (
576            <Box flexDirection="row" columnGap={2} rowGap={1} flexWrap="wrap" marginTop={1}>
577              {actions}
578            </Box>
579          )}
580          {card.column === 'review' && card.run?.phase !== 'merge' && (
581            <Box marginTop={1}>
582              <Input
583                key={`note-${card.id}`}
584                label="Note for Send back"
585                placeholder="What should Claude change?"
586                value={typing.get(`note-${card.id}`) ?? ''}
587                submitLabel="send back"
588                onInput={v => typing.set(`note-${card.id}`, v)}
589                onSubmit={async v => {
590                  typing.delete(`note-${card.id}`)
591                  toast(await applyUserMove($, await loadBoard($), card.id, 'queued', undefined, v))
592                }}
593              />
594            </Box>
595          )}
596          {project?.type === 'workspace' &&
597            panel(
598              'sec-repos',
599              `Repos${card.repos === undefined || card.repos.length === 0 ? ' · all' : ''}`,
600              <Box flexDirection="column">
601                <Box flexDirection="row" columnGap={2} flexWrap="wrap">
602                  {(project.repos ?? []).map(r => {
603                    const picked = card.repos === undefined || card.repos.length === 0 || card.repos.includes(r.name)
604                    const canPick = card.column === 'backlog' || card.column === 'queued'
605                    const label = `${picked ? '☑' : '☐'} ${r.name}`
606                    return canPick ? (
607                      <Button
608                        key={`repo-${card.id}-${r.name}`}
609                        plain
610                        onPress={() =>
611                          void patchCard($, card.id, c => {
612                            const all = (project.repos ?? []).map(x => x.name)
613                            const now = c.repos === undefined || c.repos.length === 0 ? all : c.repos
614                            const next = now.includes(r.name) ? now.filter(n => n !== r.name) : [...now, r.name]
615                            return { ...c, repos: next.length === 0 || next.length === all.length ? undefined : next }
616                          })
617                        }
618                      >
619                        <Text bold={picked} color={picked ? 'success' : undefined} dimColor={!picked}>
620                          {label}
621                        </Text>
622                      </Button>
623                    ) : (
624                      <Text key={`repo-${card.id}-${r.name}`} bold={picked} color={picked ? 'success' : undefined} dimColor={!picked}>
625                        {label}
626                      </Text>
627                    )
628                  })}
629                </Box>
630                {card.mergeRepos !== undefined && card.mergeRepos.length > 0 && (
631                  <Text color="warning" wrap="wrap">{`Still to merge: ${card.mergeRepos.join(', ')}`}</Text>
632                )}
633              </Box>,
634            )}
635          {panel(
636            'sec-brief',
637            'Brief',
638            editable ? (
639              <Input
640                key={`brief-${card.id}`}
641                placeholder="What Claude should do"
642                value={typing.get(`brief-${card.id}`) ?? card.brief}
643                submitLabel="save"
644                onInput={v => typing.set(`brief-${card.id}`, v)}
645                onSubmit={async v => {
646                  typing.delete(`brief-${card.id}`)
647                  await patchCard($, card.id, c => ({ ...c, brief: v }))
648                }}
649              />
650            ) : (
651              <Markdown key={`brief-md-${card.id}`} text={card.brief === '' ? card.title : card.brief} />
652            ),
653          )}
654          {panel(
655            'sec-acceptance',
656            `Acceptance · ${card.acceptance.length}`,
657            <Box flexDirection="column">
658              {card.acceptance.length === 0 && !editable && <Text dimColor>None.</Text>}
659              {card.acceptance.map((item, i) => (
660                <Box key={`acc-${i}`} flexDirection="row" columnGap={1}>
661                  <Text color="success">✓</Text>
662                  <Box flexGrow={1}>
663                    <Text wrap="wrap">{item}</Text>
664                  </Box>
665                  {editable && (
666                    <Button
667                      key={`acc-x-${card.id}-${i}`}
668                      label="✕"
669                      plain
670                      dimColor
671                      onPress={() => void patchCard($, card.id, c => ({ ...c, acceptance: c.acceptance.filter((_, j) => j !== i) }))}
672                    />
673                  )}
674                </Box>
675              ))}
676              {editable && (
677                <Input
678                  key={`acc-new-${card.id}`}
679                  label="Add"
680                  placeholder="A check Claude must satisfy"
681                  value={typing.get(`acc-${card.id}`) ?? ''}
682                  submitLabel="add"
683                  onInput={v => typing.set(`acc-${card.id}`, v)}
684                  onSubmit={async v => {
685                    typing.delete(`acc-${card.id}`)
686                    if (v.trim() === '') return
687                    await patchCard($, card.id, c => ({ ...c, acceptance: [...c.acceptance, v.trim()] }))
688                  }}
689                />
690              )}
691            </Box>,
692          )}
693          {card.notes.length > 0 &&
694            panel(
695              'sec-notes',
696              'Review notes',
697              <Box flexDirection="column">
698                {card.notes.map((n, i) => (
699                  <Text key={`note-${i}`} wrap="wrap">{`- ${n}`}</Text>
700                ))}
701              </Box>,
702              'warning',
703            )}
704          {panel(
705            'sec-progress',
706            'Progress',
707            <Box flexDirection="column">
708              {progress !== undefined && progress.total > 0 ? (
709                <Box flexDirection="row" columnGap={1} alignItems="center">
710                  {Svg !== undefined ? (
711                    <Svg source={svgBar(progress.done, progress.total)} alt={`${progress.done} of ${progress.total} steps done`} width={240} height={8} />
712                  ) : (
713                    <Text color="claude">{textBar(progress.done, progress.total, 20)}</Text>
714                  )}
715                  <Text tabular>{`${progress.done}/${progress.total} steps`}</Text>
716                </Box>
717              ) : (
718                <Text dimColor>No steps yet.</Text>
719              )}
720              {stats.length > 0 && (
721                <Text size="footnote" dimColor tabular wrap="wrap">
722                  {stats.join(' · ')}
723                </Text>
724              )}
725            </Box>,
726          )}
727          {card.summary !== undefined &&
728            card.summary !== '' &&
729            panel('sec-summary', 'Summary from Claude', <Markdown key={`summary-${card.id}`} text={card.summary} />, 'claude')}
730          {panel(
731            'sec-history',
732            'History',
733            <Box flexDirection="column">
734              {card.history.slice(-8).map((entry, i) => (
735                <Box key={`h-${i}`} flexDirection="row" columnGap={1}>
736                  <Box width={5}>
737                    <Text size="footnote" dimColor tabular>
738                      {ago(now - entry.at)}
739                    </Text>
740                  </Box>
741                  <Text size="footnote" wrap="truncate-end">
742                    {entry.text}
743                  </Text>
744                </Box>
745              ))}
746            </Box>,
747          )}
748        </Box>
749      )
750    }
751
752    async function projectsTab() {
753      const draft = await read($, DRAFT)
754      const editing = await read($, EDITING)
755      const set = (field: Exclude<keyof ProjectDraft, 'type'>) => (v: string) => void update($, DRAFT, d => ({ ...d, [field]: v }))
756      const edit = (p: Project) => async () => {
757        await update($, EDITING, () => p.id)
758        await update($, DRAFT, () => ({
759          name: p.name,
760          type: p.type,
761          path: p.path,
762          baseBranch: p.baseBranch,
763          maxParallel: String(p.maxParallel),
764          testCommand: p.testCommand,
765        }))
766      }
767      const clear = async () => {
768        await update($, EDITING, () => '')
769        await update($, DRAFT, () => EMPTY_DRAFT)
770      }
771      const save = async () => {
772        const fresh = await loadBoard($)
773        const old = fresh.projects.find(p => p.id === editing)
774        const max = Number.parseInt(draft.maxParallel, 10)
775        const project: Project = {
776          id: old?.id ?? `p${newId(Date.now(), Math.random())}`,
777          name: draft.name.trim(),
778          type: draft.type,
779          path: draft.path.trim().replace(/[\\/]+$/, ''),
780          baseBranch: draft.baseBranch.trim() === '' ? 'main' : draft.baseBranch.trim(),
781          maxParallel: Number.isFinite(max) && max > 0 ? max : 2,
782          testCommand: draft.testCommand.trim(),
783          colour: old?.colour ?? nextColour(fresh.projects),
784          createdAt: old?.createdAt ?? Date.now(),
785        }
786        if (project.type === 'workspace') {
787          // Saving scans the folder again, so new repos are picked up.
788          project.repos = await discoverRepos($, project.path)
789          project.baseBranch = ''
790        }
791        const problem = await checkProject($, project)
792        if (problem !== undefined) {
793          $.ui.toast(problem)
794          return
795        }
796        await saveProject($, project)
797        $.ui.toast(
798          project.type === 'workspace'
799            ? `Saved ${project.name}: found ${(project.repos ?? []).map(r => r.name).join(', ')}`
800            : `Saved ${project.name}`,
801        )
802        await clear()
803      }
804      const cap = board.settings.globalCap
805      /** One row of the form: the label in a fixed column, the control beside it. */
806      const field = (key: string, label: string, control: RenderChildren) => (
807        <Box key={key} flexDirection="row" columnGap={2} alignItems="center">
808          <Box width={16} flexShrink={0}>
809            <Text dimColor wrap="truncate-end">
810              {label}
811            </Text>
812          </Box>
813          <Box flexGrow={1}>{control}</Box>
814        </Box>
815      )
816      return (
817        <Box flexDirection="column">
818          {panel(
819            'sec-projects',
820            `Projects · ${board.projects.length}`,
821            <Box flexDirection="column">
822              {board.projects.length === 0 && <Text dimColor>None yet.</Text>}
823              {board.projects.map(p => {
824                const open = board.cards.filter(c => c.projectId === p.id && c.column !== 'done').length
825                const running = board.cards.filter(c => c.projectId === p.id && c.column === 'running').length
826                const detail =
827                  p.type === 'repo'
828                    ? `base ${p.baseBranch} · up to ${p.maxParallel} in parallel`
829                    : p.type === 'workspace'
830                      ? `${(p.repos ?? []).length} repos: ${(p.repos ?? []).map(r => `${r.name} (${r.baseBranch})`).join(', ')} · up to ${p.maxParallel} in parallel`
831                      : 'one card at a time · no git'
832                return (
833                  <Box
834                    key={`proj-${p.id}`}
835                    flexDirection="column"
836                    borderStyle="round"
837                    borderColor={p.id === editing ? 'suggestion' : p.colour}
838                    borderDimColor={p.id !== editing}
839                    hover={{ borderColor: 'suggestion', borderDimColor: false }}
840                    paddingX={1}
841                  >
842                    <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
843                      <Box flexDirection="row" columnGap={1} alignItems="center">
844                        <Button key={`proj-edit-${p.id}`} plain onPress={edit(p)}>
845                          <Text color={p.colour} bold>
846                            {p.name}
847                          </Text>
848                        </Button>
849                        {chip(`proj-type-${p.id}`, p.type, 'subtle')}
850                      </Box>
851                      <Box flexDirection="row" columnGap={1}>
852                        {running > 0 && chip(`proj-run-${p.id}`, `${running} running`, 'claude')}
853                        <Text dimColor tabular>{`${open} open`}</Text>
854                      </Box>
855                    </Box>
856                    <Text size="footnote" dimColor mono wrap="truncate-middle">
857                      {p.path}
858                    </Text>
859                    <Text size="footnote" dimColor wrap="wrap">
860                      {detail}
861                    </Text>
862                  </Box>
863                )
864              })}
865              {board.projects.length > 0 && (
866                <Text size="footnote" dimColor>
867                  Click a project's name to edit it.
868                </Text>
869              )}
870            </Box>,
871          )}
872          {panel(
873            'sec-form',
874            editing === '' ? 'New project' : `Edit ${draft.name}`,
875            <Box flexDirection="column" rowGap={1}>
876              {field('f-name', 'Name', <Input key="pf-name" placeholder="IdeaJudge" value={draft.name} onInput={set('name')} onSubmit={save} />)}
877              {field(
878                'f-type',
879                'Type',
880                <Select
881                  key="pf-type"
882                  options={[
883                    { value: 'repo', label: 'Repo: a git repository; cards get branches and are merged on Done' },
884                    { value: 'workspace', label: 'Workspace: a parent folder of several repos; cards can change any of them' },
885                    { value: 'chat', label: 'Chat: a plain folder; cards run one at a time, no git' },
886                  ]}
887                  value={draft.type}
888                  onSelect={v =>
889                    void update($, DRAFT, (d): ProjectDraft => ({ ...d, type: v === 'chat' ? 'chat' : v === 'workspace' ? 'workspace' : 'repo' }))
890                  }
891                />,
892              )}
893              {field(
894                'f-path',
895                'Folder',
896                <Input key="pf-path" placeholder="/Users/you/work/project (absolute)" value={draft.path} onInput={set('path')} onSubmit={save} />,
897              )}
898              {draft.type === 'repo' &&
899                field(
900                  'f-base',
901                  'Base branch',
902                  <Input key="pf-base" placeholder="main" value={draft.baseBranch} onInput={set('baseBranch')} onSubmit={save} />,
903                )}
904              {draft.type !== 'chat' &&
905                field(
906                  'f-max',
907                  'Max parallel',
908                  <Input key="pf-max" placeholder="2" value={draft.maxParallel} onInput={set('maxParallel')} onSubmit={save} />,
909                )}
910              {draft.type !== 'chat' &&
911                field(
912                  'f-test',
913                  'Test command',
914                  <Input
915                    key="pf-test"
916                    placeholder={draft.type === 'workspace' ? 'optional; run in each merged repo where it applies' : 'npm test (optional, run after merging)'}
917                    value={draft.testCommand}
918                    onInput={set('testCommand')}
919                    onSubmit={save}
920                  />,
921                )}
922              {draft.type === 'workspace' && (
923                <Text size="footnote" dimColor wrap="wrap">
924                  Kanbot finds the git repos directly inside the folder, and each repo's base branch, when you save. Save again later to pick up new repos.
925                </Text>
926              )}
927              <Box flexDirection="row" columnGap={2}>
928                <Button key="pf-save" label="Save" variant="primary" onPress={save} />
929                {editing !== '' && <Button key="pf-new" label="New project" variant="secondary" onPress={clear} />}
930                {editing !== '' && (
931                  <Button
932                    key="pf-remove"
933                    label="Remove"
934                    variant="secondary"
935                    onPress={async () => {
936                      const fresh = await loadBoard($)
937                      const p = fresh.projects.find(x => x.id === editing)
938                      if (p === undefined) return
939                      const said = await removeProject($, p, fresh)
940                      if (said !== undefined) $.ui.toast(said)
941                      else await clear()
942                    }}
943                  />
944                )}
945              </Box>
946            </Box>,
947            editing === '' ? 'subtle' : 'suggestion',
948          )}
949          {panel(
950            'sec-dispatcher',
951            'Dispatcher',
952            <Box flexDirection="column" rowGap={1}>
953              <Box flexDirection="row" columnGap={1} alignItems="center">
954                {chip('disp-chip', who, whoTone)}
955                <Text dimColor wrap="wrap">
956                  {ds === 'mine'
957                    ? 'This session runs the cards. Keep it open.'
958                    : 'Run /kanbot work in the session that should run cards. Keep it open; its agents inherit its permission mode.'}
959                </Text>
960              </Box>
961              {field(
962                'f-cap',
963                'Running at once',
964                <Input
965                  key="cap"
966                  placeholder="4"
967                  value={typing.get('cap') ?? String(cap)}
968                  submitLabel="save"
969                  onInput={v => typing.set('cap', v)}
970                  onSubmit={async v => {
971                    typing.delete('cap')
972                    const n = Number.parseInt(v, 10)
973                    if (!Number.isFinite(n) || n < 1 || n > 16) {
974                      $.ui.toast('Use a number from 1 to 16.')
975                      return
976                    }
977                    await $.store.set('settings', { globalCap: n })
978                    await touch($)
979                  }}
980                />,
981              )}
982              <Text size="footnote" dimColor>
983                Across all projects, 1 to 16.
984              </Text>
985            </Box>,
986          )}
987        </Box>
988      )
989    }
990
991    async function activityTab() {
992      const list = (await readActivity($)).slice(-60).reverse()
993      const titleOf = (id: string) => board.cards.find(c => c.id === id)?.title ?? id
994      return panel(
995        'sec-activity',
996        'Activity',
997        <Box flexDirection="column">
998          {list.length === 0 && <Text dimColor>Nothing yet. Activity appears once the dispatcher runs cards.</Text>}
999          {list.map((a, i) => {
1000            const colour = board.projects.find(p => p.id === a.projectId)?.colour
1001            return (
1002              <Box key={`act-${i}`} flexDirection="row" columnGap={1}>
1003                <Box width={4} flexShrink={0}>
1004                  <Text size="footnote" dimColor tabular>
1005                    {ago(now - a.at)}
1006                  </Text>
1007                </Box>
1008                <Text color={colour}>▌</Text>
1009                <Text wrap="truncate-end">
1010                  <Text color={colour} bold>{`${projectName(a.projectId)} `}</Text>
1011                  <Text dimColor>{`${titleOf(a.cardId).slice(0, Math.max(10, Math.floor(width / 4)))} · `}</Text>
1012                  {a.text}
1013                </Text>
1014              </Box>
1015            )
1016          })}
1017        </Box>,
1018      )
1019    }
1020  })
1021}
1022
hooks/board-client.tsx 177 lines
1// The board's drag-and-drop region: a Client surface module. It draws the five
2// columns, follows the pointer, and posts `move` and `open` to the hooks module.
3import type { ClientModule, ClientPointerEvent, ClientSurface } from 'claude-code'
4
5export type CardView = {
6  id: string
7  title: string
8  meta: string
9  flag: string
10  /** Theme key the flag is drawn in. */
11  flagTone: string
12  colour: string
13  selected: boolean
14}
15/** `tone` is the theme key the column's name and badge are drawn in. */
16export type ColView = { key: string; name: string; tone: string; count: number; cards: CardView[] }
17/** `rows` and `columns`: the size the pane asked for, used until the region has been laid out. */
18export type BoardProps = { cols: ColView[]; rows?: number; columns?: number }
19
20type Drag = { id: string; fromCol: number; fromIdx: number; sx: number; sy: number; x: number; y: number }
21type S = { drag?: Drag; armed: boolean }
22
23/** Rows above the first card: the column's name and a rule. */
24export const HEAD = 2
25/** Rows per card: title, status line, gap. */
26export const CARD_H = 3
27
28let latest: BoardProps = { cols: [] }
29let surf: ClientSurface<S> | undefined
30
31/** The region's size: as laid out, or what the pane asked for before the first layout. */
32export function size(props: BoardProps, s: { rows: number; columns: number }): { rows: number; columns: number } {
33  return {
34    // The desktop can report fewer rows than the region was given: never show fewer cards than asked for.
35    rows: Math.max(s.rows, props.rows ?? 12),
36    columns: s.columns >= 8 * Math.max(1, props.cols.length) ? s.columns : (props.columns ?? 100),
37  }
38}
39
40export function colWidth(columns: number, n: number): number {
41  return Math.max(8, Math.floor(columns / Math.max(1, n)))
42}
43
44/** How many cards fit in a column of `rows` rows (one row kept for "+n more"). */
45export function fit(rows: number): number {
46  return Math.max(1, Math.floor((rows - HEAD - 1) / CARD_H))
47}
48
49/** Which column a point is over, and the slot it would drop into. */
50export function hit(props: BoardProps, columns: number, rows: number, x: number, y: number): { col: number; idx: number; onCard: boolean } {
51  const n = props.cols.length
52  const w = colWidth(columns, n)
53  const col = Math.max(0, Math.min(n - 1, Math.floor(x / w)))
54  const shown = Math.min(props.cols[col]?.cards.length ?? 0, fit(rows))
55  const rel = y - HEAD
56  if (rel < 0) return { col, idx: 0, onCard: false }
57  const slot = Math.floor(rel / CARD_H)
58  const onCard = slot < shown && rel % CARD_H < 2
59  // Dropping in the lower half of a card puts the dragged card after it.
60  const idx = Math.max(0, Math.min(shown, slot + (rel % CARD_H >= 1 ? 1 : 0)))
61  return { col, idx, onCard }
62}
63
64function onPointer(ev: ClientPointerEvent): void {
65  const s = surf
66  if (s === undefined) return
67  const state = s.state ?? { armed: true }
68  const z = size(latest, s)
69  const at = hit(latest, z.columns, z.rows, ev.x, ev.y)
70  if (ev.type === 'down' && ev.button === 'left') {
71    const card = at.onCard ? latest.cols[at.col]?.cards[Math.floor((ev.y - HEAD) / CARD_H)] : undefined
72    if (card === undefined) return
73    s.setState({ ...state, drag: { id: card.id, fromCol: at.col, fromIdx: Math.floor((ev.y - HEAD) / CARD_H), sx: ev.x, sy: ev.y, x: ev.x, y: ev.y } })
74    return
75  }
76  const drag = state.drag
77  if (drag === undefined) return
78  if (ev.type === 'move') {
79    s.setState({ ...state, drag: { ...drag, x: ev.x, y: ev.y } })
80    return
81  }
82  if (ev.type === 'up') {
83    const moved = Math.abs(ev.x - drag.sx) + Math.abs(ev.y - drag.sy) >= 2
84    const target = latest.cols[at.col]
85    if (!moved) {
86      s.post({ type: 'open', id: drag.id })
87    } else if (target !== undefined) {
88      // Slots count the cards as drawn, the dragged one included: leave it out.
89      const idx = at.col === drag.fromCol && at.idx > drag.fromIdx ? at.idx - 1 : at.idx
90      if (!(at.col === drag.fromCol && idx === drag.fromIdx)) s.post({ type: 'move', id: drag.id, col: target.key, index: idx })
91    }
92    s.setState({ ...state, drag: undefined })
93  }
94}
95
96const Board: ClientModule<BoardProps, S> = (props, surface) => {
97  latest = props
98  surf = surface
99  if (surface.state === undefined) {
100    surface.onPointer(onPointer)
101    surface.setState({ armed: true })
102  }
103  const { Box, Text } = surface.elements
104  const n = props.cols.length
105  const z = size(props, surface)
106  const w = colWidth(z.columns, n)
107  // One cell between columns. No Box borders here: the desktop draws them over the text.
108  const iw = Math.max(4, w - 1)
109  const room = fit(z.rows)
110  const drag = surface.state?.drag
111  const over = drag === undefined ? undefined : hit(props, z.columns, z.rows, drag.x, drag.y)
112
113  return (
114    <Box flexDirection="row">
115      {props.cols.map((col, ci) => {
116        const isTarget = over !== undefined && over.col === ci && !(drag !== undefined && drag.fromCol === ci && over.idx === drag.fromIdx)
117        const shown = col.cards.slice(0, room)
118        const hidden = col.cards.length - shown.length
119        const dropAt = isTarget ? over?.idx : undefined
120        return (
121          <Box key={col.key} flexDirection="column" width={w}>
122            <Box key={`${col.key}-head`} flexDirection="row" width={iw} justifyContent="space-between">
123              <Text bold color={col.tone} underline={dropAt === 0 && shown.length === 0} wrap="truncate-end">
124                {`${col.name} `}
125              </Text>
126              <Text color="inverseText" backgroundColor={isTarget ? 'suggestion' : col.tone}>{` ${col.count} `}</Text>
127            </Box>
128            <Text color={isTarget ? 'suggestion' : col.tone} dimColor={!isTarget} wrap="truncate-end">
129              {'─'.repeat(iw)}
130            </Text>
131            {shown.map((card, i) => {
132              const dragging = drag?.id === card.id
133              const stripe = card.selected ? '█' : '▌'
134              return (
135                <Box key={card.id} flexDirection="column" width={iw}>
136                  <Box key={`${card.id}-face`} flexDirection="column" width={iw} hover={{ backgroundColor: 'inactive' }}>
137                    <Box flexDirection="row" width={iw}>
138                      <Text color={card.colour} dimColor={dragging}>{stripe}</Text>
139                      <Text bold color={card.flagTone}>{card.flag === '' ? ' ' : card.flag}</Text>
140                      <Text
141                        bold={card.selected}
142                        dimColor={dragging}
143                        underline={dropAt === i}
144                        color={dropAt === i ? 'suggestion' : undefined}
145                        wrap="truncate-end"
146                      >
147                        {card.title}
148                      </Text>
149                    </Box>
150                    <Box flexDirection="row" width={iw}>
151                      <Text color={card.colour} dimColor={dragging}>{stripe}</Text>
152                      <Text dimColor wrap="truncate-end">{` ${card.meta}`}</Text>
153                    </Box>
154                  </Box>
155                  {dropAt === i + 1 ? (
156                    <Text color="suggestion" wrap="truncate-end">{'─'.repeat(iw)}</Text>
157                  ) : (
158                    <Text> </Text>
159                  )}
160                </Box>
161              )
162            })}
163            {hidden > 0 && <Text dimColor wrap="truncate-end">{` +${hidden} more`}</Text>}
164            {shown.length === 0 && (
165              <Text color={isTarget ? 'suggestion' : undefined} dimColor={!isTarget} italic wrap="truncate-end">
166                {isTarget ? ' ▸ drop here' : ' empty'}
167              </Text>
168            )}
169          </Box>
170        )
171      })}
172    </Box>
173  )
174}
175
176export default Board
177
hooks/data.ts 400 lines
1// Kanbot's records in $.store, one key per record so sessions writing
2// different cards never overwrite each other, and the actions on them.
3import { atom, update } from 'claude-code'
4import type { EngineInterface } from 'claude-code'
5
6import type { ActivityEntry, Card, Column, Dispatcher, Project, Settings, WorkspaceRepo } from '../types'
7import {
8  DEFAULT_CAP,
9  PROJECT_COLOURS,
10  bottomRank,
11  branchFor,
12  isWithin,
13  joinPath,
14  newId,
15  reposFor,
16  topRank,
17  userMove,
18  worktreeFor,
19} from './model'
20
21type E = EngineInterface
22
23const REV = atom({ plugin: 'kanbot', key: 'rev' } as const, 0)
24
25export type Board = {
26  projects: Project[]
27  cards: Card[]
28  dispatcher?: Dispatcher
29  settings: Settings
30}
31
32const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
33
34export async function loadBoard($: E): Promise<Board> {
35  const keys = await $.store.keys()
36  const projects: Project[] = []
37  const cards: Card[] = []
38  for (const key of keys) {
39    if (key.startsWith('project:')) {
40      const v = await $.store.get(key)
41      if (isObject(v)) projects.push(v as Project)
42    } else if (key.startsWith('card:')) {
43      const v = await $.store.get(key)
44      if (isObject(v)) cards.push(v as Card)
45    }
46  }
47  projects.sort((a, b) => a.createdAt - b.createdAt)
48  const d = await $.store.get('dispatcher')
49  const s = await $.store.get('settings')
50  const settings: Settings = isObject(s) && typeof s.globalCap === 'number' ? (s as Settings) : { globalCap: DEFAULT_CAP }
51  return isObject(d) ? { projects, cards, dispatcher: d as Dispatcher, settings } : { projects, cards, settings }
52}
53
54/** Tells every session the board changed: each redraws on its next tick, this one now. */
55export async function touch($: E): Promise<void> {
56  const rev = Date.now()
57  await $.store.set('rev', rev)
58  await update($, REV, () => rev)
59}
60
61export async function getCard($: E, id: string): Promise<Card | undefined> {
62  const v = await $.store.get(`card:${id}`)
63  return isObject(v) ? (v as Card) : undefined
64}
65
66export async function getProject($: E, id: string): Promise<Project | undefined> {
67  const v = await $.store.get(`project:${id}`)
68  return isObject(v) ? (v as Project) : undefined
69}
70
71/** Reads the card again, applies `fn`, writes it: never from a stale copy. */
72export async function patchCard($: E, id: string, fn: (card: Card) => Card | undefined): Promise<Card | undefined> {
73  const card = await getCard($, id)
74  if (card === undefined) return undefined
75  const next = fn(card)
76  if (next === undefined) return undefined
77  const saved = { ...next, updatedAt: Date.now() }
78  await $.store.set(`card:${id}`, saved)
79  await touch($)
80  return saved
81}
82
83export function withHistory(card: Card, text: string): Card {
84  return { ...card, history: [...card.history, { at: Date.now(), text }].slice(-30) }
85}
86
87export async function logActivity($: E, entries: ActivityEntry[]): Promise<void> {
88  if (entries.length === 0) return
89  const v = await $.store.get('activity')
90  const list = Array.isArray(v) ? (v as ActivityEntry[]) : []
91  await $.store.set('activity', [...list, ...entries].slice(-200))
92}
93
94export async function readActivity($: E): Promise<ActivityEntry[]> {
95  const v = await $.store.get('activity')
96  return Array.isArray(v) ? (v as ActivityEntry[]) : []
97}
98
99export async function addCard(
100  $: E,
101  board: Board,
102  input: { projectId: string; title: string; brief: string; acceptance?: string[]; mode?: Card['mode']; repos?: string[]; origin?: string },
103): Promise<Card> {
104  const now = Date.now()
105  const card: Card = {
106    id: newId(now, Math.random()),
107    projectId: input.projectId,
108    title: input.title,
109    brief: input.brief,
110    acceptance: input.acceptance ?? [],
111    notes: [],
112    mode: input.mode ?? 'sequential',
113    column: 'backlog',
114    rank: bottomRank(board.cards, 'backlog'),
115    history: [{ at: now, text: input.origin ?? 'Created' }],
116    createdAt: now,
117    updatedAt: now,
118  }
119  if (input.repos !== undefined) card.repos = input.repos
120  await $.store.set(`card:${card.id}`, card)
121  await touch($)
122  return card
123}
124
125/** Adds cards to the bottom of Backlog in the order given. */
126export async function addCards(
127  $: E,
128  board: Board,
129  projectId: string,
130  cards: readonly { title: string; brief: string; acceptance: string[]; mode: Card['mode']; repos?: string[] }[],
131  origin: string,
132): Promise<Card[]> {
133  const added: Card[] = []
134  let seen = board
135  for (const c of cards) {
136    const card = await addCard($, seen, { projectId, ...c, origin })
137    added.push(card)
138    seen = { ...seen, cards: [...seen.cards, card] }
139  }
140  return added
141}
142
143export async function deleteCard($: E, card: Card): Promise<void> {
144  await $.store.delete(`card:${card.id}`)
145  await touch($)
146}
147
148export async function saveProject($: E, project: Project): Promise<void> {
149  await $.store.set(`project:${project.id}`, project)
150  await touch($)
151}
152
153export async function removeProject($: E, project: Project, board: Board): Promise<string | undefined> {
154  const live = board.cards.filter(c => c.projectId === project.id && c.column !== 'done')
155  if (live.length > 0) return `${project.name} still has ${live.length} open card(s). Finish or delete them first.`
156  for (const c of board.cards.filter(c => c.projectId === project.id)) await $.store.delete(`card:${c.id}`)
157  await $.store.delete(`project:${project.id}`)
158  await touch($)
159  return undefined
160}
161
162export function nextColour(projects: readonly Project[]): string {
163  return PROJECT_COLOURS[projects.length % PROJECT_COLOURS.length] ?? 'cyan'
164}
165
166/** Checks a project's folder before it is saved; answers the problem, or undefined. */
167export async function checkProject($: E, p: Project): Promise<string | undefined> {
168  if (p.name.trim() === '') return 'Give the project a name.'
169  if (!p.path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(p.path)) return 'The folder must be an absolute path.'
170  try {
171    const st = await $.fs.stat(p.path)
172    if (st.kind !== 'dir') return `${p.path} is not a folder.`
173  } catch {
174    return `${p.path} does not exist.`
175  }
176  if (p.type === 'workspace') {
177    if ((p.repos ?? []).length === 0) return `No git repos found inside ${p.path}. Each repo must be a folder directly inside it.`
178    return undefined
179  }
180  if (p.type === 'repo') {
181    const inside = await git($, p.path, ['rev-parse', '--is-inside-work-tree'])
182    if (inside.exitCode !== 0) return `${p.path} is not a git repository. Use a chat project for plain folders.`
183    const base = await git($, p.path, ['rev-parse', '--verify', '--quiet', p.baseBranch])
184    if (base.exitCode !== 0) return `The base branch "${p.baseBranch}" does not exist in ${p.path}.`
185  }
186  return undefined
187}
188
189export async function git($: E, cwd: string, args: string[], timeoutMs = 60_000) {
190  try {
191    return await $.process.run(['git', '-C', cwd, ...args], { timeoutMs })
192  } catch (err) {
193    return { exitCode: 1, stdout: '', stderr: String(err), isStdoutTruncated: false, isStderrTruncated: false }
194  }
195}
196
197/** A repo's base branch: what origin's HEAD points at, else the checked-out branch, else main. */
198async function baseOf($: E, repo: string): Promise<string> {
199  const head = await git($, repo, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
200  if (head.exitCode === 0 && head.stdout.trim() !== '') return head.stdout.trim().replace(/^origin\//, '')
201  const current = await git($, repo, ['branch', '--show-current'])
202  return current.exitCode === 0 && current.stdout.trim() !== '' ? current.stdout.trim() : 'main'
203}
204
205/** The git repos directly inside a workspace folder, each with its base branch. */
206export async function discoverRepos($: E, path: string): Promise<WorkspaceRepo[]> {
207  let entries: { name: string; kind: string }[]
208  try {
209    entries = (await $.fs.list(path)) as { name: string; kind: string }[]
210  } catch {
211    return []
212  }
213  const repos: WorkspaceRepo[] = []
214  for (const entry of entries) {
215    if (entry.kind !== 'dir' || entry.name.startsWith('.') || entry.name === 'node_modules') continue
216    const dir = joinPath(path, entry.name)
217    // Only a repo's own top folder counts, not a folder inside the parent's repo.
218    const top = await git($, dir, ['rev-parse', '--show-toplevel'])
219    const root = top.stdout.trim()
220    if (top.exitCode !== 0 || !isWithin(root, dir) || !isWithin(dir, root)) continue
221    repos.push({ name: entry.name, baseBranch: await baseOf($, dir) })
222  }
223  return repos.sort((a, b) => a.name.localeCompare(b.name))
224}
225
226/**
227 * A workspace card's folder: one worktree per repo it may change, each on the
228 * card's branch, laid out as in the workspace.
229 */
230export async function ensureWorkspace(
231  $: E,
232  project: Project,
233  card: Card,
234): Promise<{ path: string; branch: string; touched: string[] } | { problem: string }> {
235  const branch = card.branch ?? branchFor(card)
236  const folder = card.worktree ?? worktreeFor(project, card)
237  const repos = reposFor(project, card)
238  if (repos.length === 0) return { problem: `${project.name} has no repos. Open the project and save it again to find them.` }
239  const touched = new Set(card.touched ?? [])
240  for (const repo of repos) {
241    const source = joinPath(project.path, repo.name)
242    const target = joinPath(folder, repo.name)
243    if (await $.fs.exists(target)) {
244      touched.add(repo.name)
245      continue
246    }
247    const has = await git($, source, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])
248    const made =
249      has.exitCode === 0
250        ? await git($, source, ['worktree', 'add', target, branch])
251        : await git($, source, ['worktree', 'add', '-b', branch, target, repo.baseBranch])
252    if (made.exitCode !== 0) return { problem: `Could not make the ${repo.name} worktree: ${made.stderr.trim().slice(0, 300)}` }
253    touched.add(repo.name)
254  }
255  return { path: folder, branch, touched: [...touched] }
256}
257
258/** The repos of a workspace card whose branch has commits its base does not. */
259export async function reposWithWork($: E, project: Project, card: Card): Promise<string[]> {
260  const branch = card.branch ?? branchFor(card)
261  const out: string[] = []
262  for (const name of card.touched ?? []) {
263    const repo = (project.repos ?? []).find(r => r.name === name)
264    if (repo === undefined) continue
265    const r = await git($, joinPath(project.path, name), ['rev-list', '--count', `${repo.baseBranch}..${branch}`])
266    if (r.exitCode === 0 && Number.parseInt(r.stdout.trim(), 10) > 0) out.push(name)
267  }
268  return out
269}
270
271/** Removes one workspace repo's worktree and the card's branch there. */
272export async function removeRepoWorktree($: E, project: Project, card: Card, name: string, deleteBranch: 'merged' | 'force'): Promise<void> {
273  const source = joinPath(project.path, name)
274  if (card.worktree !== undefined) await git($, source, ['worktree', 'remove', '--force', joinPath(card.worktree, name)])
275  await git($, source, ['worktree', 'prune'])
276  await git($, source, ['branch', deleteBranch === 'force' ? '-D' : '-d', card.branch ?? branchFor(card)])
277}
278
279/** Makes (or finds) the card's worktree on its branch; answers its path, or a problem. */
280export async function ensureWorktree($: E, project: Project, card: Card): Promise<{ path: string; branch: string } | { problem: string }> {
281  const branch = card.branch ?? branchFor(card)
282  const path = card.worktree ?? worktreeFor(project, card)
283  if (await $.fs.exists(path)) return { path, branch }
284  const has = await git($, project.path, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])
285  const made =
286    has.exitCode === 0
287      ? await git($, project.path, ['worktree', 'add', path, branch])
288      : await git($, project.path, ['worktree', 'add', '-b', branch, path, project.baseBranch])
289  if (made.exitCode !== 0) return { problem: `Could not make the worktree: ${made.stderr.trim().slice(0, 300)}` }
290  return { path, branch }
291}
292
293export async function removeWorktree($: E, project: Project, card: Card, deleteBranch: 'merged' | 'force'): Promise<void> {
294  if (project.type === 'workspace') {
295    for (const name of card.touched ?? []) await removeRepoWorktree($, project, card, name, deleteBranch)
296    return
297  }
298  if (card.worktree !== undefined) await git($, project.path, ['worktree', 'remove', '--force', card.worktree])
299  await git($, project.path, ['worktree', 'prune'])
300  if (card.branch !== undefined) await git($, project.path, ['branch', deleteBranch === 'force' ? '-D' : '-d', card.branch])
301}
302
303/** Whether a workspace repo's base branch already holds the card's branch. */
304export async function isRepoMerged($: E, project: Project, card: Card, name: string): Promise<boolean> {
305  const repo = (project.repos ?? []).find(r => r.name === name)
306  if (repo === undefined) return false
307  const r = await git($, joinPath(project.path, name), ['merge-base', '--is-ancestor', card.branch ?? branchFor(card), repo.baseBranch])
308  return r.exitCode === 0
309}
310
311export async function isMerged($: E, project: Project, card: Card): Promise<boolean> {
312  const branch = card.branch ?? branchFor(card)
313  const r = await git($, project.path, ['merge-base', '--is-ancestor', branch, project.baseBranch])
314  return r.exitCode === 0
315}
316
317/**
318 * A person moved a card (dragged, a button, a command). Answers a sentence to
319 * show when the move was refused or needs saying, else undefined.
320 */
321export async function applyUserMove(
322  $: E,
323  board: Board,
324  cardId: string,
325  to: Column,
326  rank: number | undefined,
327  note?: string,
328): Promise<string | undefined> {
329  const card = board.cards.find(c => c.id === cardId)
330  if (card === undefined) return 'That card is gone.'
331  const project = board.projects.find(p => p.id === card.projectId)
332  const action = userMove(card, to)
333  const at = rank ?? (to === 'queued' && action.kind === 'sendback' ? topRank(board.cards, 'queued') : bottomRank(board.cards, to))
334  switch (action.kind) {
335    case 'deny':
336      return action.reason
337    case 'reorder':
338      await patchCard($, card.id, c => ({ ...c, rank: at }))
339      return undefined
340    case 'queue':
341      await patchCard($, card.id, c => withHistory({ ...c, column: 'queued', rank: at, problem: undefined }, 'Queued'))
342      return undefined
343    case 'unqueue':
344      await patchCard($, card.id, c => withHistory({ ...c, column: 'backlog', rank: at }, 'Back to Backlog'))
345      return undefined
346    case 'sendback': {
347      const text = note?.trim() ?? ''
348      await patchCard($, card.id, c =>
349        withHistory(
350          {
351            ...c,
352            column: 'queued',
353            rank: at,
354            notes: text === '' ? c.notes : [...c.notes, text],
355            run: undefined,
356            mergeRepos: undefined,
357            waiting: undefined,
358            problem: undefined,
359          },
360          text === '' ? 'Sent back' : `Sent back: ${text}`,
361        ),
362      )
363      return undefined
364    }
365    case 'discard': {
366      if (project !== undefined && project.type !== 'chat') await removeWorktree($, project, card, 'force')
367      await patchCard($, card.id, c =>
368        withHistory(
369          {
370            ...c,
371            column: 'backlog',
372            rank: at,
373            branch: undefined,
374            worktree: undefined,
375            touched: undefined,
376            mergeRepos: undefined,
377            run: undefined,
378            summary: undefined,
379            progress: undefined,
380            waiting: undefined,
381            problem: undefined,
382          },
383          'Discarded the work; back to Backlog',
384        ),
385      )
386      return undefined
387    }
388    case 'done': {
389      if (project === undefined || project.type === 'chat' || card.branch === undefined) {
390        await patchCard($, card.id, c => withHistory({ ...c, column: 'done', rank: at, waiting: undefined }, 'Done'))
391        return undefined
392      }
393      await patchCard($, card.id, c =>
394        withHistory({ ...c, mergeRequested: true, problem: undefined }, 'Done: waiting for the merge'),
395      )
396      return 'Merging: the card moves to Done once its branch is merged.'
397    }
398  }
399}
400
hooks/dispatch.ts 459 lines
1// The dispatcher: one session (the one that ran `/kanbot work`) starts queued
2// cards as background agents, follows their tool calls, and runs merges.
3import type { EngineInterface } from 'claude-code'
4
5import type { ActivityEntry, Card, Project } from '../types'
6import type { Board } from './data'
7import {
8  ensureWorkspace,
9  ensureWorktree,
10  getCard,
11  isMerged,
12  isRepoMerged,
13  removeRepoWorktree,
14  reposWithWork,
15  loadBoard,
16  logActivity,
17  patchCard,
18  removeWorktree,
19  withHistory,
20} from './data'
21import { cardPrompt, isActive, mergePrompt, pickStarts, topRank } from './model'
22
23type E = EngineInterface
24
25export const HEARTBEAT_STALE_MS = 30_000
26const MISSING_TICKS = 3
27
28/** What the dispatcher has seen of a card's agent since the last flush. */
29type Pending = {
30  toolCalls: number
31  lastTool: string
32  lastAt: number
33  tokens: number
34  todo?: { done: number; total: number }
35  tasksMade: number
36  tasksDone: Set<string>
37  tasksGone: Set<string>
38  waiting?: string
39  /** Something changed since the last flush. */
40  dirty: boolean
41}
42
43export const me = { sessionId: '', isDispatcher: false }
44const agentCard = new Map<string, string>()
45const pending = new Map<string, Pending>()
46const feed: ActivityEntry[] = []
47let busy = false
48
49function pend(cardId: string): Pending {
50  let p = pending.get(cardId)
51  if (p === undefined) {
52    p = { toolCalls: 0, lastTool: '', lastAt: 0, tokens: 0, tasksMade: 0, tasksDone: new Set(), tasksGone: new Set(), dirty: false }
53    pending.set(cardId, p)
54  }
55  return p
56}
57
58function note(card: Pick<Card, 'id' | 'projectId'>, text: string): void {
59  feed.push({ at: Date.now(), cardId: card.id, projectId: card.projectId, text })
60}
61
62export function cardForAgent(agentId: string | undefined): string | undefined {
63  return agentId === undefined ? undefined : agentCard.get(agentId)
64}
65
66/** Sums a usage record's token counts, whatever fields it carries. */
67export function tokensOf(usage: unknown): number {
68  if (typeof usage !== 'object' || usage === null) return 0
69  let n = 0
70  for (const [k, v] of Object.entries(usage)) if (k.endsWith('tokens') && typeof v === 'number') n += v
71  return n
72}
73
74function describeTool(tool: string, input: Record<string, unknown>): string {
75  const pick = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
76  const path = pick('file_path') || pick('path') || pick('notebook_path')
77  if (path !== '') return `${tool} ${path.split('/').slice(-2).join('/')}`
78  const cmd = pick('command')
79  if (cmd !== '') return `${tool}: ${cmd.slice(0, 70)}`
80  const sub = pick('subject') || pick('description') || pick('pattern') || pick('query')
81  return sub === '' ? tool : `${tool}: ${sub.slice(0, 70)}`
82}
83
84/** Called from the `tool.call` hook for every tool call of a card's agent. */
85export function sawToolCall(cardId: string, tool: string, input: Record<string, unknown>): void {
86  const p = pend(cardId)
87  p.dirty = true
88  p.toolCalls += 1
89  p.lastTool = tool
90  p.lastAt = Date.now()
91  p.waiting = undefined
92  if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
93    const todos = input.todos as { status?: unknown }[]
94    p.todo = { done: todos.filter(t => t.status === 'completed').length, total: todos.length }
95  }
96  if (tool === 'TaskUpdate' && typeof input.taskId === 'string') {
97    if (input.status === 'completed') p.tasksDone.add(input.taskId)
98    if (input.status === 'deleted') p.tasksGone.add(input.taskId)
99  }
100  feed.push({ at: p.lastAt, cardId, projectId: '', text: describeTool(tool, input) })
101}
102
103export function sawTaskMade(cardId: string): void {
104  const p = pend(cardId)
105  p.tasksMade += 1
106  p.dirty = true
107}
108
109export function sawDenied(cardId: string, tool: string, reason: string): void {
110  const p = pend(cardId)
111  p.dirty = true
112  p.waiting = `${tool} was refused: ${reason.slice(0, 200)}. Allow it in the Kanbot session's permissions, or send the card back with guidance.`
113}
114
115/** Writes what was seen into the cards. */
116async function flush($: E, board: Board): Promise<void> {
117  for (const [cardId, p] of pending) {
118    const card = board.cards.find(c => c.id === cardId)
119    if (card === undefined) {
120      pending.delete(cardId)
121      continue
122    }
123    if (!p.dirty) continue
124    await patchCard($, cardId, c => {
125      const a = c.activity ?? { lastTool: '', lastAt: 0, toolCalls: 0, tokens: 0 }
126      const total = p.todo?.total ?? Math.max(0, p.tasksMade - p.tasksGone.size)
127      const done = p.todo?.done ?? p.tasksDone.size
128      return {
129        ...c,
130        activity: {
131          lastTool: p.lastTool || a.lastTool,
132          lastAt: Math.max(p.lastAt, a.lastAt),
133          toolCalls: a.toolCalls + p.toolCalls,
134          tokens: a.tokens + p.tokens,
135        },
136        progress: total > 0 ? { done: Math.min(done, total), total } : c.progress,
137        waiting: p.waiting ?? (p.toolCalls > 0 ? undefined : c.waiting),
138      }
139    })
140    // Keep task counts (they accumulate over the run); clear the deltas.
141    p.toolCalls = 0
142    p.tokens = 0
143    p.waiting = undefined
144    p.dirty = false
145  }
146  const entries = feed.splice(0, feed.length).map(f => {
147    if (f.projectId !== '') return f
148    const card = board.cards.find(c => c.id === f.cardId)
149    return { ...f, projectId: card?.projectId ?? '' }
150  })
151  await logActivity($, entries)
152}
153
154/** Spawns a background agent and answers its id, finding it by description if the answer carries none. */
155async function spawnAgent($: E, args: { prompt: string; description: string; cwd: string }): Promise<{ agentId: string } | { deny: string }> {
156  const before = new Set((await $.agent.list()).map(a => a.id))
157  const spawned = await $.agent.spawn({ ...args, subagentType: 'general-purpose' })
158  if (spawned.deny !== undefined) return { deny: spawned.deny }
159  if (spawned.agentId !== undefined) return { agentId: spawned.agentId }
160  const found = (await $.agent.list()).find(a => !before.has(a.id) && a.description === args.description)
161  return found === undefined ? { deny: 'the agent did not start' } : { agentId: found.id }
162}
163
164async function startCard($: E, board: Board, project: Project, card: Card): Promise<void> {
165  // Claim it first, so no later tick starts it twice.
166  const claimed = await patchCard($, card.id, c =>
167    c.column === 'queued'
168      ? withHistory(
169          { ...c, column: 'running', run: { phase: 'work', startedAt: Date.now() }, waiting: undefined, problem: undefined },
170          'Started',
171        )
172      : undefined,
173  )
174  if (claimed === undefined) return
175  let cwd = project.path
176  let extra: Partial<Card> = {}
177  if (project.type === 'repo') {
178    const wt = await ensureWorktree($, project, claimed)
179    if ('problem' in wt) {
180      await patchCard($, card.id, c =>
181        withHistory({ ...c, column: 'backlog', run: undefined, problem: wt.problem }, 'Could not start'),
182      )
183      note(card, `could not start: ${wt.problem}`)
184      return
185    }
186    cwd = wt.path
187    extra = { branch: wt.branch, worktree: wt.path }
188  }
189  if (project.type === 'workspace') {
190    const ws = await ensureWorkspace($, project, claimed)
191    if ('problem' in ws) {
192      await patchCard($, card.id, c =>
193        withHistory({ ...c, column: 'backlog', run: undefined, problem: ws.problem }, 'Could not start'),
194      )
195      note(card, `could not start: ${ws.problem}`)
196      return
197    }
198    cwd = ws.path
199    extra = { branch: ws.branch, worktree: ws.path, touched: ws.touched }
200  }
201  const spawned = await spawnAgent($, {
202    prompt: cardPrompt(project, { ...claimed, ...extra }, cwd),
203    description: `Kanbot ${card.id}: ${card.title}`.slice(0, 60),
204    cwd,
205  })
206  if ('deny' in spawned) {
207    const why = spawned.deny
208    await patchCard($, card.id, c =>
209      withHistory({ ...c, ...extra, column: 'queued', rank: topRank(board.cards, 'queued'), run: undefined, problem: `Could not start: ${why}` }, 'Could not start'),
210    )
211    note(card, `could not start: ${why}`)
212    return
213  }
214  agentCard.set(spawned.agentId, card.id)
215  await patchCard($, card.id, c => ({
216    ...c,
217    ...extra,
218    run: { phase: 'work', startedAt: c.run?.startedAt ?? Date.now(), agentId: spawned.agentId },
219  }))
220  note(card, `started in ${cwd}`)
221}
222
223async function startMerge($: E, project: Project, card: Card): Promise<void> {
224  const claimed = await patchCard($, card.id, c =>
225    c.mergeRequested === true && c.run === undefined
226      ? withHistory({ ...c, mergeRequested: false, run: { phase: 'merge', startedAt: Date.now() } }, 'Merging')
227      : undefined,
228  )
229  if (claimed === undefined) return
230  let toMerge = claimed
231  if (project.type === 'workspace') {
232    const names = claimed.mergeRepos !== undefined && claimed.mergeRepos.length > 0 ? claimed.mergeRepos : await reposWithWork($, project, claimed)
233    if (names.length === 0) {
234      // Nothing was committed anywhere: nothing to merge, only worktrees to remove.
235      for (const name of claimed.touched ?? []) await removeRepoWorktree($, project, claimed, name, 'force')
236      await patchCard($, card.id, c =>
237        withHistory(
238          { ...c, column: 'done', run: undefined, branch: undefined, worktree: undefined, touched: undefined, mergeRepos: undefined },
239          'Done: no repo had commits to merge',
240        ),
241      )
242      note(card, 'done; nothing to merge')
243      return
244    }
245    const saved = await patchCard($, card.id, c => ({ ...c, mergeRepos: names }))
246    toMerge = saved ?? { ...claimed, mergeRepos: names }
247  }
248  const spawned = await spawnAgent($, {
249    prompt: mergePrompt(project, toMerge),
250    description: `Kanbot merge ${card.id}`.slice(0, 60),
251    cwd: project.path,
252  })
253  if ('deny' in spawned) {
254    const why = spawned.deny
255    await patchCard($, card.id, c => withHistory({ ...c, run: undefined, problem: `Merge did not start: ${why}` }, 'Merge failed'))
256    return
257  }
258  agentCard.set(spawned.agentId, card.id)
259  await patchCard($, card.id, c => ({
260    ...c,
261    run: { phase: 'merge', startedAt: c.run?.startedAt ?? Date.now(), agentId: spawned.agentId },
262  }))
263  note(card, 'merging')
264}
265
266/** One dispatcher tick: heartbeat, flush, check agents, start merges and cards. */
267export async function dispatch($: E, board: Board): Promise<void> {
268  if (busy) return
269  busy = true
270  try {
271    const d = board.dispatcher
272    if (d === undefined || d.sessionId !== me.sessionId) {
273      me.isDispatcher = false
274      return
275    }
276    await $.store.set('dispatcher', { ...d, heartbeatAt: Date.now() })
277    await flush($, board)
278    const fresh = await loadBoard($)
279
280    // Agents that are waiting, gone, or ended without a word.
281    const agents = await $.agent.list()
282    for (const card of fresh.cards.filter(c => isActive(c) && c.run?.agentId !== undefined)) {
283      const agentId = card.run?.agentId ?? ''
284      agentCard.set(agentId, card.id)
285      const info = agents.find(a => a.id === agentId)
286      const gone = info === undefined || info.status === 'failed' || info.status === 'killed'
287      if (gone) {
288        const missing = (card.run?.missing ?? 0) + 1
289        if (missing < MISSING_TICKS) {
290          await patchCard($, card.id, c => (c.run === undefined ? undefined : { ...c, run: { ...c.run, missing } }))
291          continue
292        }
293        await lost($, fresh, card, info === undefined ? 'its agent is gone (was the Kanbot session restarted?)' : `its agent ${info.status}`)
294        continue
295      }
296      if (info.status === 'waiting' && (card.waiting ?? '') === '') {
297        await patchCard($, card.id, c => ({ ...c, waiting: 'Claude is waiting in the Kanbot session: open it to answer.' }))
298        $.ui.toast(`Kanbot ${card.id} is waiting on you`)
299      }
300    }
301
302    if (d.isPaused) return
303
304    for (const card of fresh.cards.filter(c => c.column === 'review' && c.mergeRequested === true && c.run === undefined)) {
305      const project = fresh.projects.find(p => p.id === card.projectId)
306      const merging = fresh.cards.some(c => c.projectId === card.projectId && c.run?.phase === 'merge')
307      if (project !== undefined && !merging) await startMerge($, project, card)
308    }
309
310    const after = await loadBoard($)
311    for (const card of pickStarts(after.projects, after.cards, after.settings.globalCap)) {
312      const project = after.projects.find(p => p.id === card.projectId)
313      if (project !== undefined) await startCard($, after, project, card)
314    }
315  } finally {
316    busy = false
317  }
318}
319
320async function lost($: E, board: Board, card: Card, why: string): Promise<void> {
321  if (card.run?.phase === 'merge') {
322    await patchCard($, card.id, c => withHistory({ ...c, run: undefined, problem: `The merge stopped: ${why}. Mark it Done again to retry.` }, 'Merge stopped'))
323  } else {
324    await patchCard($, card.id, c =>
325      withHistory(
326        { ...c, column: 'queued', rank: topRank(board.cards, 'queued'), run: undefined, problem: `The run stopped: ${why}. It will start again.` },
327        'Run stopped; requeued',
328      ),
329    )
330  }
331  note(card, `stopped: ${why}`)
332}
333
334/** A card's agent finished a turn: the run ends. */
335export async function finished(
336  $: E,
337  agentId: string,
338  end: { answer: string; reason: string; usage?: unknown },
339): Promise<void> {
340  const cardId = agentCard.get(agentId)
341  if (cardId === undefined) return
342  agentCard.delete(agentId)
343  const card = await getCard($, cardId)
344  if (card === undefined || card.run?.agentId !== agentId) return
345  const board = await loadBoard($)
346  const project = board.projects.find(p => p.id === card.projectId)
347  const p = pend(cardId)
348  p.tokens += tokensOf(end.usage)
349  p.dirty = true
350  await flush($, board)
351
352  if (card.run.phase === 'merge' && project !== undefined && project.type === 'workspace') {
353    await finishWorkspaceMerge($, board, project, card, end.answer)
354    pending.delete(cardId)
355    return
356  }
357
358  if (card.run.phase === 'merge') {
359    if (project !== undefined && (await isMerged($, project, card))) {
360      await removeWorktree($, project, card, 'merged')
361      await patchCard($, cardId, c =>
362        withHistory(
363          { ...c, column: 'done', rank: topRank(board.cards, 'done'), run: undefined, branch: undefined, worktree: undefined, waiting: undefined, problem: undefined },
364          'Merged; Done',
365        ),
366      )
367      note(card, 'merged and done')
368      $.ui.toast(`Kanbot ${card.id} merged: ${card.title}`)
369    } else {
370      await patchCard($, cardId, c =>
371        withHistory({ ...c, run: undefined, problem: `Not merged. ${end.answer.trim().slice(-600)}` }, 'Merge did not complete'),
372      )
373      note(card, 'merge did not complete')
374      $.ui.toast(`Kanbot ${card.id}: the merge did not complete`)
375    }
376    pending.delete(cardId)
377    return
378  }
379
380  if (end.reason !== 'answer') {
381    await patchCard($, cardId, c =>
382      withHistory(
383        { ...c, column: 'queued', rank: topRank(board.cards, 'queued'), run: undefined, problem: `The run ended early (${end.reason}). It will start again.` },
384        `Run ended: ${end.reason}`,
385      ),
386    )
387    note(card, `run ended: ${end.reason}`)
388    pending.delete(cardId)
389    return
390  }
391  await patchCard($, cardId, c =>
392    withHistory(
393      { ...c, column: 'review', rank: topRank(board.cards, 'review'), run: undefined, summary: end.answer.trim(), waiting: undefined, problem: undefined },
394      'Finished; ready for review',
395    ),
396  )
397  note(card, 'ready for review')
398  $.ui.toast(`Kanbot ${card.id} is ready for review: ${card.title}`)
399  pending.delete(cardId)
400}
401
402/** Checks each repo the merge was to land in, cleans up what landed, keeps the rest in Review. */
403async function finishWorkspaceMerge($: E, board: Board, project: Project, card: Card, answer: string): Promise<void> {
404  const wanted = card.mergeRepos ?? []
405  const merged: string[] = []
406  for (const name of wanted) {
407    if (await isRepoMerged($, project, card, name)) {
408      await removeRepoWorktree($, project, card, name, 'merged')
409      merged.push(name)
410    }
411  }
412  const left = wanted.filter(n => !merged.includes(n))
413  if (left.length === 0) {
414    // Repos the card never changed still hold a worktree: remove those too.
415    for (const name of (card.touched ?? []).filter(n => !merged.includes(n))) await removeRepoWorktree($, project, card, name, 'force')
416    await patchCard($, card.id, c =>
417      withHistory(
418        {
419          ...c,
420          column: 'done',
421          rank: topRank(board.cards, 'done'),
422          run: undefined,
423          branch: undefined,
424          worktree: undefined,
425          touched: undefined,
426          mergeRepos: undefined,
427          waiting: undefined,
428          problem: undefined,
429        },
430        `Merged ${merged.join(', ')}; Done`,
431      ),
432    )
433    note(card, `merged ${merged.join(', ')}`)
434    $.ui.toast(`Kanbot ${card.id} merged: ${card.title}`)
435    return
436  }
437  await patchCard($, card.id, c =>
438    withHistory(
439      {
440        ...c,
441        run: undefined,
442        mergeRepos: left,
443        touched: (c.touched ?? []).filter(n => !merged.includes(n)),
444        problem: `Not merged: ${left.join(', ')}.${merged.length > 0 ? ` Merged: ${merged.join(', ')}.` : ''} Mark it Done again to retry. ${answer.trim().slice(-500)}`,
445      },
446      merged.length > 0 ? `Merged ${merged.join(', ')}; ${left.join(', ')} left` : 'Merge did not complete',
447    ),
448  )
449  note(card, `merge incomplete: ${left.join(', ')} left`)
450  $.ui.toast(`Kanbot ${card.id}: ${left.join(', ')} not merged`)
451}
452
453export function dispatcherState(board: Board, now: number): 'mine' | 'other' | 'stale' | 'none' {
454  const d = board.dispatcher
455  if (d === undefined) return 'none'
456  if (d.sessionId === me.sessionId) return 'mine'
457  return now - d.heartbeatAt > HEARTBEAT_STALE_MS ? 'stale' : 'other'
458}
459
hooks/model.ts 418 lines
1// Pure logic: no `$`, so every rule here is tested directly.
2import type { Card, Column, Project, ProjectDraft, WorkspaceRepo } from '../types'
3
4export const COLUMNS: readonly Column[] = ['backlog', 'queued', 'running', 'review', 'done']
5
6export const COLUMN_NAMES: Record<Column, string> = {
7  backlog: 'Backlog',
8  queued: 'Queued',
9  running: 'Running',
10  review: 'Review',
11  done: 'Done',
12}
13
14export const PROJECT_COLOURS = ['cyan', 'magenta', 'yellow', 'green', 'blue', 'red'] as const
15
16export const DEFAULT_CAP = 4
17
18export const EMPTY_DRAFT: ProjectDraft = {
19  name: '',
20  type: 'repo',
21  path: '',
22  baseBranch: 'main',
23  maxParallel: '2',
24  testCommand: '',
25}
26export const STALL_MS = 10 * 60 * 1000
27
28export function newId(now: number, random: number): string {
29  return now.toString(36).slice(-5) + Math.floor(random * 1296).toString(36).padStart(2, '0')
30}
31
32export function byRank(a: Card, b: Card): number {
33  return a.rank - b.rank || a.createdAt - b.createdAt
34}
35
36export function inColumn(cards: readonly Card[], column: Column, projectId?: string): Card[] {
37  return cards
38    .filter(c => c.column === column && (projectId === undefined || c.projectId === projectId))
39    .sort(byRank)
40}
41
42/** A rank that puts a card at `index` among `others` (already sorted, the moved card left out). */
43export function rankAt(others: readonly Card[], index: number): number {
44  const i = Math.max(0, Math.min(index, others.length))
45  const before = others[i - 1]
46  const after = others[i]
47  if (before === undefined && after === undefined) return 0
48  if (before === undefined) return (after?.rank ?? 0) - 1
49  if (after === undefined) return before.rank + 1
50  return (before.rank + after.rank) / 2
51}
52
53export function topRank(cards: readonly Card[], column: Column): number {
54  const list = inColumn(cards, column)
55  return list.length === 0 ? 0 : (list[0]?.rank ?? 0) - 1
56}
57
58export function bottomRank(cards: readonly Card[], column: Column): number {
59  const list = inColumn(cards, column)
60  return list.length === 0 ? 0 : (list[list.length - 1]?.rank ?? 0) + 1
61}
62
63export function limitFor(project: Project): number {
64  return project.type === 'chat' ? 1 : Math.max(1, Math.floor(project.maxParallel))
65}
66
67/** A card that occupies a run slot: working, or merging after Done. */
68export function isActive(card: Card): boolean {
69  return card.column === 'running' || (card.column === 'review' && card.run?.phase === 'merge')
70}
71
72/**
73 * Which queued cards to start now. Each project's queue drains top to bottom:
74 * a sequential card waits until nothing of its project runs and blocks the
75 * cards under it; a parallel card starts while its project and the global cap
76 * have room, and does not block. Projects take turns, so one long queue
77 * cannot starve the others.
78 */
79export function pickStarts(projects: readonly Project[], cards: readonly Card[], globalCap: number): Card[] {
80  let running = cards.filter(isActive).length
81  const picked: Card[] = []
82  const queues = projects.map(p => ({
83    project: p,
84    queue: inColumn(cards, 'queued', p.id),
85    running: cards.filter(c => c.projectId === p.id && isActive(c)).length,
86    // A running sequential card holds its project's queue until it ends.
87    blocked: cards.some(c => c.projectId === p.id && c.column === 'running' && c.mode === 'sequential'),
88  }))
89  let progress = true
90  while (progress && running < globalCap) {
91    progress = false
92    for (const q of queues) {
93      if (running >= globalCap) break
94      if (q.blocked) continue
95      const next = q.queue[0]
96      if (next === undefined) continue
97      if (next.mode === 'sequential' || q.project.type === 'chat') {
98        if (q.running > 0) {
99          q.blocked = true
100          continue
101        }
102        q.queue.shift()
103        picked.push(next)
104        q.running += 1
105        running += 1
106        q.blocked = true
107        progress = true
108        continue
109      }
110      if (q.running >= limitFor(q.project)) {
111        q.blocked = true
112        continue
113      }
114      q.queue.shift()
115      picked.push(next)
116      q.running += 1
117      running += 1
118      progress = true
119    }
120  }
121  return picked
122}
123
124export type MoveAction =
125  | { kind: 'reorder' }
126  | { kind: 'queue' }
127  | { kind: 'unqueue' }
128  | { kind: 'done' }
129  | { kind: 'sendback' }
130  | { kind: 'discard' }
131  | { kind: 'deny'; reason: string }
132
133/** What a person moving a card from one column to another means. */
134export function userMove(card: Card, to: Column): MoveAction {
135  const from = card.column
136  if (from === to) {
137    if (from === 'running') return { kind: 'deny', reason: 'Running cards keep the order they started in.' }
138    return { kind: 'reorder' }
139  }
140  if (from === 'running') return { kind: 'deny', reason: 'A running card moves to Review by itself when Claude finishes.' }
141  if (from === 'done') return { kind: 'deny', reason: 'Done cards stay done. Add a new card for follow-up work.' }
142  if (card.run?.phase === 'merge') return { kind: 'deny', reason: 'This card is merging; wait for it to finish.' }
143  if (from === 'backlog' && to === 'queued') return { kind: 'queue' }
144  if (from === 'queued' && to === 'backlog') return { kind: 'unqueue' }
145  if (from === 'review' && to === 'done') return { kind: 'done' }
146  if (from === 'review' && to === 'queued') return { kind: 'sendback' }
147  if (from === 'review' && to === 'backlog') return { kind: 'discard' }
148  if (to === 'running') return { kind: 'deny', reason: 'Queue the card; Kanbot starts it when there is room.' }
149  if (to === 'review') return { kind: 'deny', reason: 'Cards reach Review when Claude finishes them.' }
150  if (to === 'done') return { kind: 'deny', reason: 'Only a card in Review can be marked Done.' }
151  return { kind: 'deny', reason: `A card cannot move from ${COLUMN_NAMES[from]} to ${COLUMN_NAMES[to]}.` }
152}
153
154export function branchFor(card: Card): string {
155  return `kanbot/${card.id}`
156}
157
158/** A path in one spelling: forward slashes, no trailing slash, a lower-case drive letter. */
159export function normPath(path: string): string {
160  const p = path.replace(/\\/g, '/').replace(/\/+$/, '')
161  return /^[A-Za-z]:/.test(p) ? p[0]!.toLowerCase() + p.slice(1) : p
162}
163
164/** Whether `inner` is `outer` or inside it; a path with no drive matches any drive. */
165export function isWithin(inner: string, outer: string): boolean {
166  const strip = (p: string) => normPath(p).replace(/^[a-z]:/, '').toLowerCase()
167  const i = strip(inner)
168  const o = strip(outer)
169  return i === o || i.startsWith(o + '/')
170}
171
172export function joinPath(dir: string, name: string): string {
173  return `${dir.replace(/[\\/]+$/, '')}/${name}`
174}
175
176/** The workspace repos a card may change: its own pick, or every repo. */
177export function reposFor(project: Project, card: Card): WorkspaceRepo[] {
178  const all = project.repos ?? []
179  if (card.repos === undefined || card.repos.length === 0) return all
180  const picked = all.filter(r => card.repos?.includes(r.name))
181  return picked.length === 0 ? all : picked
182}
183
184export function worktreeFor(project: Project, card: Card): string {
185  const path = project.path.replace(/[\\/]+$/, '')
186  const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
187  const parent = cut <= 0 ? path : path.slice(0, cut)
188  const name = cut < 0 ? path : path.slice(cut + 1)
189  return `${parent}/.kanbot-worktrees/${name}/${card.id}`
190}
191
192export function cardPrompt(project: Project, card: Card, cwd: string): string {
193  const lines: string[] = []
194  lines.push(`You are working on a Kanbot card for the project "${project.name}".`)
195  lines.push('')
196  lines.push(`Card ${card.id}: ${card.title}`)
197  lines.push('')
198  lines.push('## Brief')
199  lines.push(card.brief.trim() === '' ? card.title : card.brief.trim())
200  if (card.acceptance.length > 0) {
201    lines.push('')
202    lines.push('## Acceptance checklist (all must hold before you finish)')
203    for (const item of card.acceptance) lines.push(`- [ ] ${item}`)
204  }
205  if (card.notes.length > 0) {
206    lines.push('')
207    lines.push('## Notes from review (newest last; address every one)')
208    for (const note of card.notes) lines.push(`- ${note}`)
209  }
210  if (card.summary !== undefined && card.notes.length > 0) {
211    lines.push('')
212    lines.push('## Your summary from the previous run')
213    lines.push(card.summary.trim())
214  }
215  lines.push('')
216  lines.push('## How to work')
217  lines.push(`- Your working directory is ${cwd}. Work only inside it.`)
218  if (project.type === 'repo') {
219    lines.push(`- You are on branch ${branchFor(card)} in a git worktree made for this card. Commit your work to this branch with clear messages.`)
220    lines.push('- Do not switch branches, merge, rebase onto other branches, or push. Kanbot merges the branch after review.')
221  } else if (project.type === 'workspace') {
222    const repos = reposFor(project, card)
223    lines.push(`- Your working directory mirrors the workspace ${project.path}: each folder in it is a git worktree of that repo, on branch ${branchFor(card)}.`)
224    lines.push(`- The repos you may change: ${repos.map(r => r.name).join(', ')}. Other repos of the workspace are not here; if the work needs one, stop and say so.`)
225    lines.push('- Commit in each repo you change, with clear messages. Leave repos you do not need untouched.')
226    lines.push('- Do not switch branches, merge, rebase onto other branches, or push. Kanbot merges each changed repo after review.')
227  } else {
228    lines.push('- This is a plain working folder, not a git repository. Save what you produce as files in it.')
229  }
230  lines.push('- Track your steps with your task list (TaskCreate and TaskUpdate, or TodoWrite) so progress shows on the board.')
231  lines.push('- If something blocks you, stop and say exactly what you need instead of guessing.')
232  lines.push('- Finish with a short summary: what changed, the files touched, how to verify it, and anything left open.')
233  return lines.join('\n')
234}
235
236export function mergePrompt(project: Project, card: Card): string {
237  if (project.type === 'workspace') return workspaceMergePrompt(project, card)
238  const branch = card.branch ?? branchFor(card)
239  const base = project.baseBranch
240  const steps = [
241    `Kanbot: merge card ${card.id} ("${card.title}") for the project "${project.name}".`,
242    `Repository: ${project.path}. Base branch: ${base}. Card branch: ${branch}.`,
243    '',
244    `1. In ${project.path}, run \`git status\`. If there are uncommitted changes, or the checked-out branch is not ${base}, stop and report that. Change nothing.`,
245    `2. Run \`git merge --no-ff ${branch} -m "Merge Kanbot card ${card.id}: ${card.title.replace(/"/g, "'")}"\`. Resolve a conflict only when the right result is clear; otherwise run \`git merge --abort\` and report the conflicting files.`,
246  ]
247  if (project.testCommand.trim() !== '') {
248    steps.push(`3. Run \`${project.testCommand.trim()}\`. If it fails, undo the merge with \`git reset --hard ORIG_HEAD\` and report the failure.`)
249  }
250  steps.push(`${project.testCommand.trim() !== '' ? 4 : 3}. Do not push, and do not delete branches or worktrees: Kanbot cleans those up.`)
251  steps.push('')
252  steps.push('End with one line that starts MERGED or NOT MERGED, then a short reason.')
253  return steps.join('\n')
254}
255
256function workspaceMergePrompt(project: Project, card: Card): string {
257  const branch = card.branch ?? branchFor(card)
258  const names = card.mergeRepos ?? []
259  const repos = (project.repos ?? []).filter(r => names.includes(r.name))
260  const message = `Merge Kanbot card ${card.id}: ${card.title.replace(/"/g, "'")}`
261  const test = project.testCommand.trim()
262  const lines = [
263    `Kanbot: merge card ${card.id} ("${card.title}") for the workspace "${project.name}" at ${project.path}.`,
264    `Branch ${branch} is merged into each of these repos (folder: base branch):`,
265    ...repos.map(r => `- ${r.name}: ${r.baseBranch}`),
266    '',
267    `1. Check every repo first: in ${project.path}/<repo>, run \`git status\`. If any has uncommitted changes, or its checked-out branch is not its base branch, stop and report that. Merge nothing.`,
268    `2. Then, one repo at a time, run \`git merge --no-ff ${branch} -m "${message}"\`. Resolve a conflict only when the right result is clear; otherwise run \`git merge --abort\` in that repo and stop, reporting the conflicting files. Do not merge the remaining repos.`,
269  ]
270  if (test !== '') {
271    lines.push(`3. After each merge, run \`${test}\` in that repo if it applies there. If it fails, undo that merge with \`git reset --hard ORIG_HEAD\` and stop, reporting the failure.`)
272  }
273  lines.push(`${test !== '' ? 4 : 3}. Do not push, and do not delete branches or worktrees: Kanbot cleans those up.`)
274  lines.push('')
275  lines.push('End with one line per repo that starts MERGED or NOT MERGED, then the repo and a short reason.')
276  return lines.join('\n')
277}
278
279export type Counts = { running: number; waiting: number; review: number; queued: number }
280
281export function counts(cards: readonly Card[]): Counts {
282  return {
283    running: cards.filter(c => c.column === 'running').length,
284    waiting: cards.filter(c => c.waiting !== undefined && c.waiting !== '').length,
285    review: cards.filter(c => c.column === 'review').length,
286    queued: cards.filter(c => c.column === 'queued').length,
287  }
288}
289
290export function statusLine(c: Counts, isDispatching: boolean | undefined): string {
291  const parts = [`${c.running} running`, `${c.queued} queued`]
292  if (c.waiting > 0) parts.push(`${c.waiting} waiting`)
293  parts.push(`${c.review} review`)
294  const who = isDispatching === undefined ? ' · no dispatcher' : isDispatching ? ' · dispatching' : ''
295  return `kanbot: ${parts.join(' · ')}${who}`
296}
297
298export function ago(ms: number): string {
299  if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s`
300  if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`
301  if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`
302  return `${Math.round(ms / 86_400_000)}d`
303}
304
305/** The one-line status under a card's title. */
306export function cardMeta(card: Card, projectName: string, now: number): string {
307  const bits: string[] = [projectName]
308  if (card.mode === 'parallel') bits.push('∥')
309  if (card.progress !== undefined && card.progress.total > 0) bits.push(`${card.progress.done}/${card.progress.total}`)
310  if (card.column === 'running' && card.activity !== undefined) bits.push(ago(now - card.activity.lastAt))
311  if (card.run?.phase === 'merge') bits.push('merging')
312  return bits.join(' · ')
313}
314
315export function cardFlag(card: Card, now: number): string {
316  if (card.waiting !== undefined && card.waiting !== '') return '!'
317  if (card.problem !== undefined && card.problem !== '') return '×'
318  if (card.column === 'running') {
319    const last = card.activity?.lastAt ?? card.run?.startedAt ?? now
320    if (now - last > STALL_MS) return '…'
321  }
322  return ''
323}
324
325/** Parses `/kanbot add` text: `title`, `title | brief`, or `project: title | brief`. */
326export function parseAdd(args: string, projects: readonly Project[]): { projectId?: string; title: string; brief: string } {
327  let rest = args.trim()
328  let projectId: string | undefined
329  const colon = rest.indexOf(':')
330  if (colon > 0) {
331    const name = rest.slice(0, colon).trim().toLowerCase()
332    const match = projects.find(p => p.name.toLowerCase() === name || p.id === name)
333    if (match !== undefined) {
334      projectId = match.id
335      rest = rest.slice(colon + 1).trim()
336    }
337  }
338  const bar = rest.indexOf('|')
339  const title = (bar < 0 ? rest : rest.slice(0, bar)).trim()
340  const brief = bar < 0 ? '' : rest.slice(bar + 1).trim()
341  return projectId === undefined ? { title, brief } : { projectId, title, brief }
342}
343
344/** A card the model proposes from a plan, checked and ready to add to Backlog. */
345export type PlannedCard = { title: string; brief: string; acceptance: string[]; mode: Card['mode']; repos?: string[] }
346
347export const MAX_PLANNED = 20
348
349/** Reads a project named at the start of `text` (by name or id); the rest is left over. */
350export function leadingProject(text: string, projects: readonly Project[]): { project?: Project; rest: string } {
351  const t = text.trim()
352  const lower = t.toLowerCase()
353  const byLength = [...projects].sort((a, b) => b.name.length - a.name.length)
354  for (const p of byLength) {
355    for (const name of [p.name.toLowerCase(), p.id.toLowerCase()]) {
356      if (name === '' || !lower.startsWith(name)) continue
357      const after = t.slice(name.length)
358      if (after === '' || /^[\s:]/.test(after)) return { project: p, rest: after.replace(/^[\s:]+/, '').trim() }
359    }
360  }
361  return { rest: t }
362}
363
364/** Finds a project by name or id, ignoring case. */
365export function findProject(name: string, projects: readonly Project[]): Project | undefined {
366  const n = name.trim().toLowerCase()
367  return projects.find(p => p.name.toLowerCase() === n || p.id.toLowerCase() === n)
368}
369
370/** Checks the queue_cards tool's `cards` against the project; answers the cards, or what is wrong. */
371export function checkPlanned(raw: unknown, project: Project): { cards: PlannedCard[] } | { error: string } {
372  if (!Array.isArray(raw) || raw.length === 0) return { error: '`cards` must be a non-empty list.' }
373  if (raw.length > MAX_PLANNED) return { error: `At most ${MAX_PLANNED} cards at once; merge some steps into one card.` }
374  const known = (project.repos ?? []).map(r => r.name)
375  const cards: PlannedCard[] = []
376  for (const [i, item] of raw.entries()) {
377    const at = `Card ${i + 1}`
378    if (typeof item !== 'object' || item === null) return { error: `${at} is not an object.` }
379    const c = item as Record<string, unknown>
380    const title = typeof c.title === 'string' ? c.title.trim() : ''
381    if (title === '') return { error: `${at} has no title.` }
382    if (title.length > 120) return { error: `${at}'s title is over 120 characters; put the detail in the brief.` }
383    const brief = typeof c.brief === 'string' ? c.brief.trim() : ''
384    if (brief === '') return { error: `${at} (${title}) has no brief. The agent that runs it cannot see this conversation.` }
385    const acceptance = Array.isArray(c.acceptance) ? c.acceptance.filter((a): a is string => typeof a === 'string' && a.trim() !== '').map(a => a.trim()) : []
386    const mode = c.mode === 'parallel' ? 'parallel' : 'sequential'
387    const card: PlannedCard = { title, brief, acceptance, mode }
388    if (project.type === 'workspace' && Array.isArray(c.repos) && c.repos.length > 0) {
389      const repos = c.repos.filter((r): r is string => typeof r === 'string')
390      const unknown = repos.filter(r => !known.includes(r))
391      if (unknown.length > 0) return { error: `${at} (${title}) names repos ${unknown.join(', ')} that ${project.name} does not have. It has: ${known.join(', ')}.` }
392      // Every repo is the same as none: the card may change them all.
393      if (repos.length < known.length) card.repos = [...new Set(repos)]
394    }
395    cards.push(card)
396  }
397  return { cards }
398}
399
400/** What the model is asked to do when the person runs `/kanbot queue`. */
401export function queuePrompt(project: Project, guidance: string): string {
402  const repos = project.type === 'workspace' ? (project.repos ?? []).map(r => r.name) : []
403  return [
404    `Turn the plan we agreed in this conversation into Kanbot cards for the project "${project.name}" (${project.type}, ${project.path}), and add them with the mcp__kanbot__queue_cards tool.`,
405    '',
406    'How to split and write them:',
407    '- One card per piece of work that can be reviewed and merged on its own, in the order they should run. Prefer a few substantial cards over many tiny ones.',
408    '- Each card is run later by a fresh background Claude agent in its own worktree. It cannot see this conversation, so the brief must stand alone: the goal, the files or areas involved, the decisions we made here, constraints, and what done looks like.',
409    '- acceptance: one to five checks a reviewer can verify.',
410    "- mode: sequential when a card builds on an earlier card (it waits for the cards above it, and only sees their changes once they are merged); parallel when it can run alongside the others.",
411    ...(repos.length > 0 ? [`- repos: the repos the card changes, from: ${repos.join(', ')}. Leave it out when a card may change any of them.`] : []),
412    '- If this conversation has no clear plan to turn into cards, say so and do not call the tool.',
413    '',
414    'The cards land in Backlog for review; nothing runs until they are moved to Queued. Afterwards, list the cards you added in one short line each.',
415    ...(guidance !== '' ? ['', `Extra guidance: ${guidance}`] : []),
416  ].join('\n')
417}
418
hooks/style.ts 38 lines
1// Shared looks: the colour each column and status is drawn in, and text bars.
2import type { Column } from '../types'
3
4/** Theme keys, so the board follows the person's theme. */
5export const COLUMN_TONE: Record<Column, string> = {
6  backlog: 'subtle',
7  queued: 'suggestion',
8  running: 'claude',
9  review: 'warning',
10  done: 'success',
11}
12
13/** The colour a card's flag (from cardFlag) is drawn in. */
14export function flagTone(flag: string): string {
15  if (flag === '!') return 'warning'
16  if (flag === '×') return 'error'
17  return 'subtle'
18}
19
20/** A text progress bar, `███░░░`, `width` cells wide. */
21export function textBar(done: number, total: number, width: number): string {
22  const w = Math.max(1, width)
23  const filled = total <= 0 ? 0 : Math.round((Math.min(done, total) / total) * w)
24  return '█'.repeat(filled) + '░'.repeat(w - filled)
25}
26
27/** The same bar as SVG for surfaces that draw it (no theme keys inside SVG, so fixed colours). */
28export function svgBar(done: number, total: number, width = 240, height = 8): string {
29  const frac = total <= 0 ? 0 : Math.min(done, total) / total
30  const r = height / 2
31  return (
32    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
33    `<rect width="${width}" height="${height}" rx="${r}" fill="#8a8a8a" fill-opacity="0.25"/>` +
34    (frac > 0 ? `<rect width="${Math.max(height, Math.round(width * frac))}" height="${height}" rx="${r}" fill="#d97757"/>` : '') +
35    `</svg>`
36  )
37}
38
types/index.d.ts 123 lines
1// Kanbot's data model and its $.state contract.
2
3export type ProjectType = 'repo' | 'chat' | 'workspace'
4
5/** One git repo inside a workspace project: its folder name and base branch. */
6export type WorkspaceRepo = { name: string; baseBranch: string }
7
8export type Project = {
9  id: string
10  name: string
11  type: ProjectType
12  /**
13   * Absolute folder: a git repo for `repo`, a plain folder for `chat`, a
14   * parent folder holding several repos for `workspace`.
15   */
16  path: string
17  /** Repo projects: the branch cards branch from and merge into. */
18  baseBranch: string
19  /** Workspace projects: the repos found inside `path`, each with its own base. */
20  repos?: WorkspaceRepo[]
21  /** Most cards of this project running at once (chat projects: always 1). */
22  maxParallel: number
23  /** Optional command the merge agent runs after merging (e.g. `npm test`). */
24  testCommand: string
25  colour: string
26  createdAt: number
27}
28
29export type Column = 'backlog' | 'queued' | 'running' | 'review' | 'done'
30
31export type CardMode = 'sequential' | 'parallel'
32
33export type CardRun = {
34  /** The background agent working the card, as $.agent.spawn answered it. */
35  agentId?: string
36  /** `work` does the card; `merge` merges its branch after Done. */
37  phase: 'work' | 'merge'
38  startedAt: number
39  /** Ticks in a row the dispatcher could not find the agent. */
40  missing?: number
41}
42
43export type Card = {
44  id: string
45  projectId: string
46  title: string
47  brief: string
48  acceptance: string[]
49  /** Notes added when a card is sent back from Review, oldest first. */
50  notes: string[]
51  mode: CardMode
52  column: Column
53  /** Order within its column: lower is higher on the board. */
54  rank: number
55  /**
56   * Repo and workspace cards: the card's branch and its worktree (for a
57   * workspace, the card folder holding one worktree per repo), kept across runs.
58   */
59  branch?: string
60  worktree?: string
61  /** Workspace cards: the repos the card may change; absent means all of them. */
62  repos?: string[]
63  /** Workspace cards: the repos that have a worktree for this card. */
64  touched?: string[]
65  /** Workspace cards: the repos a merge in progress, or a retry, still has to merge. */
66  mergeRepos?: string[]
67  run?: CardRun
68  /** Set when Robert marks a repo card Done; the dispatcher then merges it. */
69  mergeRequested?: boolean
70  progress?: { done: number; total: number }
71  activity?: { lastTool: string; lastAt: number; toolCalls: number; tokens: number }
72  /** Why the card needs Robert's attention, if it does. */
73  waiting?: string
74  /** Claude's final answer from the last run. */
75  summary?: string
76  /** The last thing that went wrong (a failed merge, a lost agent). */
77  problem?: string
78  history: { at: number; text: string }[]
79  createdAt: number
80  updatedAt: number
81}
82
83export type Dispatcher = {
84  sessionId: string
85  heartbeatAt: number
86  isPaused: boolean
87}
88
89export type Settings = {
90  /** Most cards running at once across every project. */
91  globalCap: number
92}
93
94export type ActivityEntry = { at: number; cardId: string; projectId: string; text: string }
95
96export type ProjectDraft = {
97  name: string
98  type: ProjectType
99  path: string
100  baseBranch: string
101  maxParallel: string
102  testCommand: string
103}
104
105export type Tab = 'board' | 'card' | 'projects' | 'activity'
106
107declare module 'claude-code' {
108  interface PluginState {
109    kanbot: {
110      tab: Tab
111      /** The card the Card tab shows; '' for none. */
112      selected: string
113      /** 'all' or a project id. */
114      filter: string
115      /** The store's last-seen revision: bumping it redraws the board. */
116      rev: number
117      draft: ProjectDraft
118      /** The project the form edits; '' for a new one. */
119      editing: string
120    }
121  }
122}
123