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

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.
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.The Board, Card, Projects and Activity tabs, with sample data.
Code tab of the desktop app

Terminal

claude --version./plugin in a session first: if your organization only allows managed mods, user-installed mods like this one will not load.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.
/kanbot. The board opens in a side pane.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..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.<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.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.
/kanbot queue IdeaJudge./kanbot queue IdeaJudge keep it to three cards.kanbot/<card id>, at <parent of repo>/.kanbot-worktrees/<repo>/<card id>. Your main checkout is not touched while it works.<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.--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.| Command | Does | |
|---|---|---|
/kanbot | Open 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 work | Make this session the dispatcher (work force takes over from another) | |
/kanbot pause / resume | Stop or restart starting new cards | |
/kanbot stop | Stop dispatching from this session | |
/kanbot projects | Open the Projects tab |
Pane keys: b Board, c Card, p Projects, a Activity.
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.
claude plugin validate .
claude plugin test .hooks/register.tsx 1022 lines1// 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}
1022hooks/board-client.tsx 177 lines1// 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
177hooks/data.ts 400 lines1// 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}
400hooks/dispatch.ts 459 lines1// 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}
459hooks/model.ts 418 lines1// 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}
418hooks/style.ts 38 lines1// 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}
38types/index.d.ts 123 lines1// 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