A shared task tracker for agents and people working in one repository: claim tasks, declare the files you touch, get refused on overlaps, review. /tasks board…

A shared task tracker for a team of Claude agents (and people) working in one repository. Agents claim tasks before they edit, declare the files they touch, and get refused when their work overlaps another in-progress task. You watch it all from a /tasks board inside Claude Code.
scripts/task.py, Python 3.9+, stdlib only): tasks with statuses, priorities, areas, labels, links, sub-tasks, blockers, comments and full history in one SQLite file (WAL mode, so many agents read and write at once). Claims are atomic. Every read takes --json.touches. Starting a task whose globs overlap another in-progress task is refused with the owner's name; --force "<reason>" overrides, logs it and tells them.task.py inbox), and optionally run a command of yours./tasks opens a board pane (board, list, progress, conflicts, task detail), a status line shows the counts, a band above the prompt flags conflicts, blocked tasks and reviews waiting on you, and toasts announce new blocks, rejected reviews and conflict overrides. At session start Claude is told the exact command to use.python scripts/board.py, a live keyboard-driven board built on Textual (python -m pip install textual)./plugin marketplace add azoof-ahmed/claude-mods
/plugin install task-tracker@claude-mods
or in one step:
/plugin install task-tracker --marketplace azoof-ahmed/claude-mods
Then, in a project: /tasks init (or python <plugin>/scripts/task.py init) creates .claude/tasks/tasks.db and a roles template beside it.
Set them in /config, or in settings under pluginConfigs["task-tracker"].options.
| Option | Default | What it does |
|---|---|---|
python | python | Interpreter for the CLI. If it does not start, python, python3 and py are tried in turn. |
identity | user | The name the /tasks pane acts as. Agents use --as <name> or TASKS_AS. |
idPrefix | TASK | Task ids read <prefix>-<n>. |
dbPath | .claude/tasks/tasks.db | The database, relative to the project root (nearest folder with .git or .claude). |
rolesFile | .claude/tasks/roles.toml | Names and permission levels. Missing: everyone may do everything. |
labels | bug,feature,chore,docs,test,blocked-external | The label vocabulary; other labels are refused. Empty: any label. |
importers | (none) | Sources task.py import reads; see Importers. |
notifyCommand | (none) | Run for each notification, with {to} {sender} {key} {prio} {text} filled in (no shell). |
refreshSeconds | 3 | How often the status line, band and pane check for changes. 0 turns live updates off. |
The plugin hands these to the CLI as environment variables (TASKS_PREFIX, TASKS_DB, TASKS_ROLES, TASKS_LABELS, TASKS_IMPORTERS, TASKS_NOTIFY_CMD), and sets TASKS_CLI to the CLI's path, so the model's shell and any worker started from the session use the same tracker. A project can pin its own settings, which beat the plugin options, in .claude/claude-mods.json:
{ "task-tracker": { "idPrefix": "WEB", "labels": "bug,feature,infra" } }
Precedence: CLI flags (--db, --roles, --prefix, --labels, --importers, --notify-cmd) > the project file
environment variables > defaults.
task.py whereshows what was resolved and from where.
In Claude Code:
| Command | What it does |
|---|---|
/tasks | Open the board pane and summarise the counts. |
/tasks TASK-12 (or /tasks 12) | Open one task. |
/tasks board list progress conflicts | Switch the pane's view. |
/tasks area=api prio=high label=bug, /tasks clear | Filter the board. |
/tasks init | Create the tracker in this project. |
From any shell (T = python "$TASKS_CLI", or the path to scripts/task.py):
T new "Rate-limit the login endpoint" --touches "src/auth/**" --label feature --area api
T --as worker-a claim TASK-1 # atomic; refuses on file conflicts
T --as worker-a touch TASK-1 "src/middleware/**"
T --as worker-a move TASK-1 in_review --reviewer reviewer --proof logs/test-run.txt
T --as reviewer review TASK-1 --approve
T mine | list | show TASK-1 | board | conflicts | stats | log | inbox | where
The skill bundled with the plugin teaches Claude this loop.
task.py init writes a template:
default_level = "member" # level for a name not listed below
[levels]
admin = ["user", "coordinator"]
lead = ["reviewer"]
member = ["worker-a"]
viewer = []
[identity]
no_assign = [] # names that are tools, never assigned
strict_names = false # true: assignees and reviewers must be listed above
[review]
default_reviewer = "" # reviewer set on every new task
require_proof = false # true: in_review needs --proof
| Level | May |
|---|---|
| admin | everything |
| lead | create, assign anyone, review, move any task, import |
| member | create, claim, assign only themselves (anyone else becomes a proposal), update their own tasks, comment |
| viewer | read only |
Names are self-declared (--as, TASKS_AS), so roles are guard rails for cooperating agents, not security.
task.py import (lead or admin) seeds or refreshes tasks from your own sources and is safe to run repeatedly. Each entry in importers (comma-separated) is a .py file or module name defining records(ctx), or a command whose stdout is JSON (an array, or one record per line). A record:
{ "ext_key": "issues:42", "title": "Crash on empty input", "status": "todo", "priority": "high",
"labels": ["bug"], "links": ["url:https://example.com/issues/42"], "touches": ["src/parser/**"],
"parent": "issues:40", "comments": ["imported from the issue tracker"] }
ext_key and title are required. A re-import updates a field only while the tracker still holds the value the last import wrote, so changes made in the tracker (a claim, a move) are never overwritten.
python -m pip install textual
python scripts/board.py [--area api] [--label bug]
Keys: 1-4 views, enter opens a task, / searches, a p l cycle area, priority and label filters, c clears them, r refreshes, q quits.
claude plugin validate .
claude plugin test .
python -m unittest discover -s scripts/tests
claude --plugin-dir .
MIT
hooks/register.tsx 706 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Card, Conflict, Detail, Filters, Snapshot, TaskEvent, View, Where } from '../types'
5
6// The tracker is the Python CLI shipped in this plugin (scripts/task.py); every live read is one `snapshot` call.
7const PANE = 'task-tracker'
8const TITLE = 'Tasks'
9
10const where = atom({ plugin: 'task-tracker', key: 'where' } as const, null)
11const snap = atom({ plugin: 'task-tracker', key: 'snap' } as const, null)
12const error = atom({ plugin: 'task-tracker', key: 'error' } as const, null)
13const filters = atom({ plugin: 'task-tracker', key: 'filters' } as const, { area: '', priority: '', label: '' })
14const view = atom({ plugin: 'task-tracker', key: 'view' } as const, 'board')
15const back = atom({ plugin: 'task-tracker', key: 'back' } as const, 'board')
16const detail = atom({ plugin: 'task-tracker', key: 'detail' } as const, null)
17const page = atom({ plugin: 'task-tracker', key: 'page' } as const, 0)
18
19const COLUMNS = ['todo', 'in_progress', 'in_review', 'blocked', 'done'] as const
20const PRIORITIES = ['urgent', 'high', 'med', 'low'] as const
21const STATUS_LABEL: Record<string, string> = {
22 backlog: 'Backlog', todo: 'Todo', in_progress: 'In progress', in_review: 'In review', blocked: 'Blocked',
23 done: 'Done today', cancelled: 'Cancelled',
24}
25const STATUS_ICON: Record<string, string> = {
26 backlog: '◌', todo: '○', in_progress: '◐', in_review: '◑', blocked: '⊘', done: '●', cancelled: '✕',
27}
28const STATUS_COLOR: Record<string, string> = {
29 backlog: 'inactive', todo: 'text', in_progress: 'warning', in_review: 'suggestion', blocked: 'error',
30 done: 'success', cancelled: 'inactive',
31}
32const PRIO_ICON: Record<string, string> = { urgent: '‼', high: '▲', med: '■', low: '▽' }
33const PRIO_COLOR: Record<string, string> = { urgent: 'error', high: 'warning', med: 'subtle', low: 'inactive' }
34
35/** The manifest's userConfig defaults. */
36const DEFAULTS: Record<string, string> = {
37 python: 'python',
38 identity: 'user',
39 idPrefix: 'TASK',
40 dbPath: '.claude/tasks/tasks.db',
41 rolesFile: '.claude/tasks/roles.toml',
42 labels: 'bug,feature,chore,docs,test,blocked-external',
43 importers: '',
44 notifyCommand: '',
45}
46
47type $T = EngineInterface
48type Run = { ok: boolean; out: string; err: string; code: number }
49
50/** This load's options; set once by register. Plain values only, nothing per session. */
51const cfg = {
52 python: 'python',
53 identity: 'user',
54 refreshMs: 3000,
55 env: {} as Record<string, string>,
56}
57/** The interpreter that started last time (python, python3 or py). */
58let found: string | null = null
59
60function opt(options: PluginOptions, key: string): string {
61 const v = options[key]
62 return v === undefined || v === null ? (DEFAULTS[key] ?? '') : String(v)
63}
64
65function quote(s: string): string {
66 return /^[\w./:\\@=,+-]+$/.test(s) ? s : `"${s.replace(/"/g, '\\"')}"`
67}
68
69function short(s: string | null | undefined, n: number): string {
70 const t = (s ?? '').replace(/\s+/g, ' ')
71 return t.length <= n ? t : t.slice(0, Math.max(0, n - 1)) + '…'
72}
73
74function filterArgs(f: Filters): string[] {
75 const out: string[] = []
76 if (f.area) out.push('--area', f.area)
77 if (f.priority) out.push('--priority', f.priority)
78 if (f.label) out.push('--label', f.label)
79 return out
80}
81
82function scriptPath($: $T): string {
83 return `${$.plugin.root}/scripts/task.py`
84}
85
86/**
87 * The options reach the CLI as environment variables, which rank below the project's own
88 * .claude/claude-mods.json: a project's settings win over one person's plugin options. Every process this
89 * session starts later (the model's Bash, shell workers) inherits them, and TASKS_CLI names the CLI.
90 */
91async function exportEnv($: $T): Promise<void> {
92 const v = cfg.env
93 await $.env.set('TASKS_CLI', scriptPath($))
94 await $.env.set('TASKS_PREFIX', v.TASKS_PREFIX)
95 await $.env.set('TASKS_DB', v.TASKS_DB)
96 await $.env.set('TASKS_ROLES', v.TASKS_ROLES)
97 await $.env.set('TASKS_LABELS', v.TASKS_LABELS)
98 await $.env.set('TASKS_IMPORTERS', v.TASKS_IMPORTERS)
99 await $.env.set('TASKS_NOTIFY_CMD', v.TASKS_NOTIFY_CMD)
100}
101
102async function exec($: $T, exe: string, cwd: string, args: string[]): Promise<Run> {
103 const r = await $.process.run([exe, scriptPath($), '--as', cfg.identity, ...args], {
104 cwd,
105 env: { ...cfg.env, NO_COLOR: '1', PYTHONIOENCODING: 'utf-8', TASKS_WIDTH: '110' },
106 timeoutMs: 20000,
107 })
108 return { ok: r.exitCode === 0, out: r.stdout, err: r.stderr.trim(), code: r.exitCode }
109}
110
111/** Runs the CLI; the first interpreter that starts is kept (the python option, then python, python3, py). */
112async function runTask($: $T, args: string[]): Promise<Run> {
113 const order = found ? [found] : [cfg.python, ...['python', 'python3', 'py'].filter(p => p !== cfg.python)]
114 const cwd = await $.session.root()
115 let last = ''
116 for (const exe of order) {
117 try {
118 const r = await exec($, exe, cwd, args)
119 // 9009 / "Python was not found": the Windows store alias, not a real interpreter.
120 if (r.code === 9009 || /Python was not found/i.test(r.err)) {
121 last = `${exe}: not a Python interpreter`
122 continue
123 }
124 found = exe
125 return r
126 } catch (err) {
127 last = `${exe}: ${String(err)}`
128 }
129 }
130 return { ok: false, out: '', err: `no Python found (tried ${order.join(', ')}; set the python option). ${last}`, code: -1 }
131}
132
133async function loadWhere($: $T): Promise<Where | null> {
134 const r = await runTask($, ['where', '--json'])
135 if (!r.ok) {
136 await update($, error, () => short(r.err || 'task.py where failed', 300))
137 return null
138 }
139 try {
140 const w = JSON.parse(r.out) as Where
141 await update($, where, () => w)
142 return w
143 } catch {
144 await update($, error, () => 'task.py where printed no JSON')
145 return null
146 }
147}
148
149async function refresh($: $T, isFirst = false): Promise<void> {
150 const w = (await read($, where)) ?? (await loadWhere($))
151 if (!w || !w.db_exists) return
152 const f = await read($, filters)
153 const storeKey = `lastEvent:${w.db}`
154 const since = (await $.store.get(storeKey)) as number | undefined
155 const args = ['snapshot', ...filterArgs(f)]
156 if (since !== undefined && !isFirst) args.push('--since', String(since))
157 const r = await runTask($, args)
158 if (!r.ok) {
159 await update($, error, () => short(r.err || 'task snapshot failed', 300))
160 $.ui.status('tasks: tracker unreadable')
161 return
162 }
163 let s: Snapshot
164 try {
165 s = JSON.parse(r.out) as Snapshot
166 } catch {
167 await update($, error, () => 'task snapshot printed no JSON')
168 return
169 }
170 const prev = await read($, snap)
171 await update($, error, () => null)
172 await update($, snap, () => s)
173 await $.store.set(storeKey, s.stats.last_event)
174 statusLine($, s)
175 if (!isFirst && since !== undefined) announce($, s.events, prev, s)
176}
177
178async function openDetail($: $T, key: string): Promise<string | null> {
179 const r = await runTask($, ['show', key, '--json'])
180 if (!r.ok) return r.err || `could not read ${key}`
181 const d = JSON.parse(r.out) as Detail
182 const cur = await read($, view)
183 if (cur !== 'detail') await update($, back, () => cur)
184 await update($, detail, () => d)
185 await update($, view, () => 'detail' as View)
186 return null
187}
188
189async function initTracker($: $T): Promise<string> {
190 const r = await runTask($, ['init'])
191 await update($, where, () => null)
192 await loadWhere($)
193 await refresh($, true)
194 return r.ok ? r.out.trim() : `task.py init failed: ${r.err}`
195}
196
197/** The command an agent types; the options already sit in its environment. */
198function commandPrefix($: $T): string {
199 return `${found ?? cfg.python} ${quote(scriptPath($))}`
200}
201
202async function poll($: $T, state: { stamp: string }): Promise<void> {
203 const cur = (await read($, where)) ?? (await loadWhere($))
204 if (!cur) return
205 const a = await $.fs.stat(cur.db).catch(() => null)
206 const wal = await $.fs.stat(`${cur.db}-wal`).catch(() => null)
207 const now = `${a?.mtimeMs}:${wal?.mtimeMs}:${wal?.size}`
208 if (now === state.stamp) return
209 state.stamp = now
210 if (a && !cur.db_exists) await update($, where, () => ({ ...cur, db_exists: true }))
211 if (a) await refresh($)
212}
213
214// ------------------------------------------------------------------------------------------- register
215
216export const register: Register = (on, options) => {
217 cfg.python = opt(options, 'python').trim() || 'python'
218 cfg.identity = opt(options, 'identity').trim() || 'user'
219 cfg.refreshMs = Math.max(0, Number(options.refreshSeconds ?? 3)) * 1000
220 cfg.env = {
221 TASKS_PREFIX: opt(options, 'idPrefix'),
222 TASKS_DB: opt(options, 'dbPath'),
223 TASKS_ROLES: opt(options, 'rolesFile'),
224 TASKS_LABELS: opt(options, 'labels'),
225 TASKS_IMPORTERS: opt(options, 'importers'),
226 TASKS_NOTIFY_CMD: opt(options, 'notifyCommand'),
227 }
228 found = null
229
230 // ----------------------------------------------------------------------------------------- session
231
232 on('session.start', async ($, e, next) => {
233 await exportEnv($)
234 await $.command.register({
235 name: 'tasks',
236 description: 'Task tracker: board, list, progress, conflicts; /tasks TASK-12 for one task; /tasks init',
237 argumentHint: '[<id> | board | list | progress | conflicts | init | area=x prio=high label=bug | clear]',
238 })
239 await update($, where, () => null)
240 const w = await loadWhere($)
241 if (w?.db_exists) await refresh($, true)
242 if (cfg.refreshMs > 0) {
243 const state = { stamp: '' }
244 $.clock.every(cfg.refreshMs, () => poll($, state))
245 }
246 return next(e)
247 })
248
249 on('classic.SessionStart', async ($, e, next) => {
250 const r = await next(e)
251 const w = (await read($, where)) ?? (await loadWhere($))
252 if (!w) return r
253 const cmd = commandPrefix($)
254 const lines = w.db_exists
255 ? [
256 `This project has a task tracker. Use it with: ${cmd} <command> ($TASKS_CLI holds the script path; add ` +
257 `--as <name> or set TASKS_AS when you were given a role). \`${cmd} --help\` lists every command.`,
258 `Loop: \`mine\` between steps; \`claim ${w.prefix}-<n>\` before editing; \`touch ${w.prefix}-<n> "<glob>"\` ` +
259 `for the files you edit (overlaps with other in-progress tasks are refused); \`move ${w.prefix}-<n> ` +
260 `in_review|done|blocked -m "why"\` when finished or stuck; \`new "title" --touches "<glob>"\` for untracked work.`,
261 ]
262 : [`No task tracker in this project yet; \`/tasks init\` or \`${cmd} init\` creates one.`]
263 return { ...r, additionalContext: [...(r.additionalContext ?? []), lines.join('\n')] }
264 }).catch(($, e, next) => next(e))
265
266 // ----------------------------------------------------------------------------------------- /tasks
267
268 on('command.run', { command: 'tasks' }, async ($, e) => {
269 const toks = (e.args ?? '').trim().split(/\s+/).filter(Boolean)
270 let text = ''
271 let w = await read($, where)
272 if (!w?.db_exists) w = await loadWhere($)
273 const keyRe = new RegExp(`^(?:${(w?.prefix ?? 'TASK').replace(/[^\w]/g, '\\$&')}-?)?(\\d+)$`, 'i')
274 for (const t of toks) {
275 const m = keyRe.exec(t)
276 if (t === 'init') {
277 text = await initTracker($)
278 w = await read($, where)
279 } else if (m && w?.db_exists) {
280 const key = `${w.prefix}-${m[1]}`
281 const err = await openDetail($, key)
282 const r = await runTask($, ['show', key, '--last', '8'])
283 text = err ?? r.out
284 } else if (['board', 'list', 'progress', 'conflicts'].includes(t)) {
285 await update($, view, () => t as View)
286 } else if (t === 'clear') {
287 await update($, filters, () => ({ area: '', priority: '', label: '' }))
288 } else {
289 const kv = /^(area|prio|priority|label)=(.*)$/.exec(t)
290 if (kv) {
291 const field = kv[1] === 'prio' ? 'priority' : (kv[1] ?? 'area')
292 await update($, filters, f => ({ ...f, [field]: kv[2] ?? '' }))
293 } else if (!m) {
294 text = `unknown argument ${t}. Try: /tasks ${w?.prefix ?? 'TASK'}-12 | board | list | progress | conflicts | init | area=api prio=high label=bug`
295 }
296 }
297 }
298 await update($, page, () => 0)
299 await refresh($, true)
300 const opened = await $.ui.open({ id: PANE, title: TITLE })
301 if (!text) {
302 const s = await read($, snap)
303 const b = s?.stats.by_status
304 if (!w?.db_exists) {
305 text = `No task tracker in ${w?.root ?? 'this project'} yet. \`/tasks init\` creates ${w?.db ?? 'it'}.`
306 } else if (s && b) {
307 text =
308 `Tasks: ${b.in_progress} in progress, ${b.in_review} in review, ${b.blocked} blocked, ` +
309 `${s.stats.done_today} done today, ${s.stats.conflicts} file conflicts.` +
310 (opened.isPlaced ? ' Board open in the pane.' : ' Widen the terminal to see the board pane.')
311 } else {
312 text = `The tracker could not be read: ${(await read($, error)) ?? 'unknown error'}`
313 }
314 }
315 return { text }
316 }).catch(() => ({ text: 'The task tracker failed. Check its setup with: python <plugin>/scripts/task.py where' }))
317
318 // ----------------------------------------------------------------------------------------- the band
319
320 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
321 const s = await read($, snap)
322 const blocked = s?.stats.by_status.blocked ?? 0
323 if (!s || e.props.hasSurvey || (s.stats.conflicts === 0 && s.review_for_me.length === 0 && blocked === 0)) {
324 return next(e)
325 }
326 const { Box, Text, Button } = $.ui.resolve(e)
327 return (
328 <Box gap={2}>
329 <Text bold>tasks</Text>
330 {s.stats.conflicts > 0 && <Text color="error">⚠ {s.stats.conflicts} file conflict{s.stats.conflicts > 1 ? 's' : ''}</Text>}
331 {blocked > 0 && <Text color="error">⊘ {blocked} blocked</Text>}
332 {s.review_for_me.length > 0 && <Text color="suggestion">◑ {s.review_for_me.length} review{s.review_for_me.length > 1 ? 's' : ''} for you</Text>}
333 <Button
334 key="open"
335 label="Open board"
336 onPress={async () => {
337 await update($, view, () => (s.stats.conflicts > 0 ? 'conflicts' : 'board') as View)
338 await $.ui.open({ id: PANE, title: TITLE })
339 }}
340 />
341 </Box>
342 )
343 })
344
345 // ----------------------------------------------------------------------------------------- the pane
346
347 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
348 const els = $.ui.resolve(e)
349 const { Box, Text, Button, Markdown } = els
350 const Select = 'Select' in els ? els.Select : null
351 const s = await read($, snap)
352 const w = await read($, where)
353 const err = await read($, error)
354 const v = await read($, view)
355 const f = await read($, filters)
356 const pg = await read($, page)
357 const cols = e.props.bodyColumns
358 const rows = Math.max(12, (e.viewport?.rows ?? 30) - 2)
359
360 if (w && !w.db_exists) {
361 return (
362 <Box flexDirection="column" gap={1}>
363 <Text bold>{TITLE}</Text>
364 <Text>No task tracker in this project yet.</Text>
365 <Text dimColor>It will live at {w.db}</Text>
366 <Button key="init" label="Create the tracker" onPress={async () => { await initTracker($) }} />
367 </Box>
368 )
369 }
370 if (!s) {
371 return (
372 <Box flexDirection="column">
373 <Text bold>{TITLE}</Text>
374 <Text color="error">{err ?? 'loading…'}</Text>
375 <Button key="retry" label="Retry" onPress={async () => { await update($, where, () => null); await refresh($, true) }} />
376 </Box>
377 )
378 }
379
380 const setView = (to: View) => async () => {
381 await update($, view, () => to)
382 await update($, page, () => 0)
383 }
384 const setFilter = (field: keyof Filters) => async (value: string) => {
385 await update($, filters, cur => ({ ...cur, [field]: value === '*' ? '' : value }))
386 await update($, page, () => 0)
387 await refresh($, true)
388 }
389 const press = (key: string) => async () => {
390 await openDetail($, key)
391 }
392
393 const b = s.stats.by_status
394 const header = (
395 <Box flexDirection="column">
396 <Box gap={2} flexWrap="wrap">
397 <Text bold>▤ {TITLE}</Text>
398 <Text color="warning">◐ {b.in_progress ?? 0} active</Text>
399 <Text color="suggestion">◑ {b.in_review ?? 0} review</Text>
400 <Text color="error">⊘ {b.blocked ?? 0} blocked</Text>
401 <Text color="success">● {s.stats.done_today} today</Text>
402 <Text dimColor>{b.todo ?? 0} todo · {b.backlog ?? 0} backlog</Text>
403 {s.stats.conflicts > 0 && <Text color="error" bold>⚠ {s.stats.conflicts} conflicts</Text>}
404 {s.stats.proposed > 0 && <Text color="suggestion">? {s.stats.proposed} proposed</Text>}
405 <Text dimColor>you: {s.me.name} ({s.me.level})</Text>
406 </Box>
407 <Box gap={1} flexWrap="wrap">
408 <Button key="v-board" hotkey="b" label="Board" variant={v === 'board' ? 'primary' : undefined} onPress={setView('board')} />
409 <Button key="v-list" hotkey="l" label="List" variant={v === 'list' ? 'primary' : undefined} onPress={setView('list')} />
410 <Button key="v-progress" hotkey="p" label="Progress" variant={v === 'progress' ? 'primary' : undefined} onPress={setView('progress')} />
411 <Button key="v-conflicts" hotkey="c" label={`Conflicts ${s.stats.conflicts}`} variant={v === 'conflicts' ? 'primary' : undefined} onPress={setView('conflicts')} />
412 {Select && s.areas.length > 0 && (
413 <Select
414 key="f-area"
415 label="area"
416 value={f.area || '*'}
417 options={[{ label: 'all areas', value: '*' }, ...s.areas.map(a => ({ label: a, value: a }))]}
418 onSelect={setFilter('area')}
419 />
420 )}
421 {Select && (
422 <Select
423 key="f-prio"
424 label="priority"
425 value={f.priority || '*'}
426 options={[{ label: 'any priority', value: '*' }, ...PRIORITIES.map(p => ({ label: `${PRIO_ICON[p]} ${p}`, value: p }))]}
427 onSelect={setFilter('priority')}
428 />
429 )}
430 {Select && s.labels.length > 0 && (
431 <Select
432 key="f-label"
433 label="label"
434 value={f.label || '*'}
435 options={[{ label: 'any label', value: '*' }, ...s.labels.map(l => ({ label: l, value: l }))]}
436 onSelect={setFilter('label')}
437 />
438 )}
439 <Button key="refresh" hotkey="r" label="↻" onPress={() => refresh($, true)} />
440 </Box>
441 {err && <Text color="error">{err}</Text>}
442 </Box>
443 )
444
445 const cardLine = (c: Card, width: number) => (
446 <Button key={`card-${c.key}`} plain onPress={press(c.key)}>
447 <Text color={PRIO_COLOR[c.priority]}>{PRIO_ICON[c.priority]}</Text> <Text bold>{c.key}</Text>{' '}
448 <Text color="claude">{short(c.assignee ?? (c.proposed ? `?${c.proposed}` : '—'), 12)}</Text>
449 {'\n'}
450 <Text>{short(c.title, width - 1)}</Text>
451 </Button>
452 )
453
454 let body
455 if (v === 'detail') {
456 body = detailView(await read($, detail), cols, { Box, Text, Button, Markdown }, setView(await read($, back)), press)
457 } else if (v === 'progress') {
458 body = progressView(s, cols, { Box, Text })
459 } else if (v === 'conflicts') {
460 body = conflictsView(s.conflicts, { Box, Text, Button }, press)
461 } else if (v === 'list' || cols < 100) {
462 const all: { status: string; card: Card }[] = []
463 for (const st of COLUMNS) for (const c of s.board[st] ?? []) all.push({ status: st, card: c })
464 const per = Math.max(5, rows - 8)
465 const shown = all.slice(pg * per, pg * per + per)
466 const kw = Math.max(7, ...all.map(x => x.card.key.length))
467 body = (
468 <Box flexDirection="column">
469 {shown.map(({ status, card }) => (
470 <Button key={`row-${card.key}`} plain onPress={press(card.key)}>
471 <Text color={STATUS_COLOR[status]}>{STATUS_ICON[status]} {short(STATUS_LABEL[status], 11).padEnd(11)}</Text>{' '}
472 <Text color={PRIO_COLOR[card.priority]}>{PRIO_ICON[card.priority]}</Text> <Text bold>{card.key.padEnd(kw)}</Text>{' '}
473 <Text color="claude">{short(card.assignee ?? '—', 12).padEnd(12)}</Text>{' '}
474 {short(card.title, Math.max(10, cols - 48))}
475 <Text dimColor> {card.area ?? ''}</Text>
476 </Button>
477 ))}
478 <Box gap={1}>
479 {pg > 0 && <Button key="prev" hotkey="k" label="◀ prev" onPress={() => update($, page, n => n - 1)} />}
480 {(pg + 1) * per < all.length && <Button key="next" hotkey="j" label="next ▶" onPress={() => update($, page, n => n + 1)} />}
481 <Text dimColor>{all.length === 0 ? 'no tasks match' : `${pg * per + 1}-${Math.min(all.length, (pg + 1) * per)} of ${all.length}`}</Text>
482 </Box>
483 </Box>
484 )
485 } else {
486 const width = Math.floor((cols - (COLUMNS.length - 1)) / COLUMNS.length)
487 const fit = Math.max(2, Math.floor((rows - 7) / 2))
488 body = (
489 <Box gap={1}>
490 {COLUMNS.map(st => {
491 const cards = s.board[st] ?? []
492 const count = s.counts[st] ?? 0
493 return (
494 <Box key={`col-${st}`} flexDirection="column" width={width}>
495 <Text color={STATUS_COLOR[st]} bold>
496 {STATUS_ICON[st]} {STATUS_LABEL[st]} <Text dimColor>{count}</Text>
497 </Text>
498 <Text color={STATUS_COLOR[st]} dimColor>{'─'.repeat(Math.max(1, width))}</Text>
499 {cards.slice(0, fit).map(c => cardLine(c, width))}
500 {count > fit && <Text dimColor>+{count - fit} more (List)</Text>}
501 </Box>
502 )
503 })}
504 </Box>
505 )
506 }
507
508 return (
509 <Box flexDirection="column" gap={1}>
510 {header}
511 {body}
512 </Box>
513 )
514 })
515}
516
517// ------------------------------------------------------------------------------------------- status + toasts
518
519function statusLine($: $T, s: Snapshot): void {
520 const b = s.stats.by_status
521 const parts = [`◐ ${b.in_progress ?? 0}`, `◑ ${b.in_review ?? 0}`, `⊘ ${b.blocked ?? 0}`, `● ${s.stats.done_today} today`]
522 if (s.stats.conflicts) parts.push(`⚠ ${s.stats.conflicts} conflict${s.stats.conflicts > 1 ? 's' : ''}`)
523 $.ui.status(`tasks ${parts.join(' ')}`)
524}
525
526function announce($: $T, events: TaskEvent[], prev: Snapshot | null, s: Snapshot): void {
527 const lines: string[] = []
528 for (const ev of events) {
529 const d = ev.detail as Record<string, string | undefined>
530 if (ev.action === 'move' && d.to === 'blocked') {
531 lines.push(`⊘ ${ev.key} blocked by ${ev.actor}: ${short(ev.title, 50)}${d.note ? ` (${short(d.note, 60)})` : ''}`)
532 } else if (ev.action === 'conflict_override') {
533 lines.push(`⚠ ${ev.key} started over a file conflict by ${ev.actor}: ${short(String(d.reason ?? ''), 60)}`)
534 } else if (ev.action === 'review' && d.verdict === 'reject') {
535 lines.push(`✕ ${ev.key} review rejected by ${ev.actor}`)
536 }
537 }
538 const before = new Set(prev?.review_for_me ?? [])
539 for (const k of s.review_for_me) if (!before.has(k)) lines.push(`◑ review requested from you: ${k}`)
540 if (prev && s.stats.conflicts > prev.stats.conflicts) {
541 const c = s.conflicts[s.conflicts.length - 1]
542 if (c) lines.push(`⚠ new file conflict: ${c.a} (${c.a_assignee}) × ${c.b} (${c.b_assignee})`)
543 }
544 if (lines.length > 3) {
545 $.ui.toast(`${lines[0]} (+${lines.length - 1} more: /tasks)`)
546 } else {
547 for (const l of lines) $.ui.toast(l)
548 }
549}
550
551// ------------------------------------------------------------------------------------------- views
552
553type El = ReturnType<EngineInterface['ui']['resolve']>
554
555function progressView(s: Snapshot, cols: number, { Box, Text }: Pick<El, 'Box' | 'Text'>) {
556 const barW = Math.max(10, Math.min(30, cols - 60))
557 const row = (name: string, d: Record<string, number>, key: string) => {
558 const tot = Object.entries(d).reduce((n, [k, x]) => (k === 'cancelled' ? n : n + x), 0)
559 const pct = tot ? Math.floor((100 * (d.done ?? 0)) / tot) : 0
560 const full = Math.round((pct * barW) / 100)
561 return (
562 <Box key={key} gap={1}>
563 <Text>{short(name, 22).padEnd(22)}</Text>
564 <Text color="success">{'█'.repeat(full)}</Text>
565 <Text dimColor>{'░'.repeat(barW - full)}</Text>
566 <Text bold>{String(pct).padStart(3)}%</Text>
567 <Text dimColor>
568 {d.done ?? 0}/{tot} done
569 </Text>
570 <Text color="warning">◐{d.in_progress ?? 0}</Text>
571 <Text color="suggestion">◑{d.in_review ?? 0}</Text>
572 <Text color="error">⊘{d.blocked ?? 0}</Text>
573 </Box>
574 )
575 }
576 const areas = Object.entries(s.stats.areas).filter(([k]) => k !== '-')
577 const labels = Object.entries(s.stats.labels)
578 return (
579 <Box flexDirection="column">
580 <Text bold>Overall</Text>
581 {row('all tasks', s.stats.by_status, 'all')}
582 {areas.length > 0 && <Text bold>Areas</Text>}
583 {areas.map(([k, d]) => row(k, d, `a-${k}`))}
584 {labels.length > 0 && <Text bold>Labels</Text>}
585 {labels.map(([k, d]) => row(k, d, `l-${k}`))}
586 </Box>
587 )
588}
589
590function conflictsView(cs: Conflict[], { Box, Text, Button }: Pick<El, 'Box' | 'Text' | 'Button'>, press: (k: string) => () => Promise<void>) {
591 if (cs.length === 0) return <Text color="success">✓ No file conflicts between in-progress tasks.</Text>
592 return (
593 <Box flexDirection="column" gap={1}>
594 {cs.map((c, i) => (
595 <Box key={`c-${i}`} flexDirection="column">
596 <Box gap={1}>
597 <Text color="error">⚠</Text>
598 <Button key={`ca-${i}`} plain onPress={press(c.a)}>
599 <Text bold>{c.a}</Text> <Text color="claude">{c.a_assignee ?? '—'}</Text> {short(c.a_title, 40)}
600 </Button>
601 <Text dimColor>×</Text>
602 <Button key={`cb-${i}`} plain onPress={press(c.b)}>
603 <Text bold>{c.b}</Text> <Text color="claude">{c.b_assignee ?? '—'}</Text> {short(c.b_title, 40)}
604 </Button>
605 </Box>
606 <Text dimColor>
607 {' '}
608 {c.a_glob} overlaps {c.b_glob}
609 </Text>
610 </Box>
611 ))}
612 </Box>
613 )
614}
615
616function detailView(
617 d: Detail | null,
618 cols: number,
619 { Box, Text, Button, Markdown }: Pick<El, 'Box' | 'Text' | 'Button' | 'Markdown'>,
620 goBack: () => Promise<void>,
621 press: (k: string) => () => Promise<void>,
622) {
623 if (!d) return <Text dimColor>No task selected.</Text>
624 const bodyText = d.body.split('\n').slice(0, 30).join('\n')
625 return (
626 <Box flexDirection="column" gap={1}>
627 <Box gap={2}>
628 <Button key="back" hotkey="x" label="◀ Back" onPress={goBack} />
629 <Text bold>{d.key}</Text>
630 <Text bold>{short(d.title, cols - 20)}</Text>
631 </Box>
632 <Box gap={2} flexWrap="wrap">
633 <Text color={STATUS_COLOR[d.status]}>{`${STATUS_ICON[d.status] ?? ''} ${STATUS_LABEL[d.status] ?? d.status}`}</Text>
634 <Text color={PRIO_COLOR[d.priority]}>{`${PRIO_ICON[d.priority] ?? ''} ${d.priority}`}</Text>
635 <Text>
636 assignee <Text color="claude">{d.assignee ?? '—'}</Text>
637 </Text>
638 {d.proposed && <Text color="suggestion">proposed {d.proposed}</Text>}
639 <Text dimColor>reviewer {d.reviewer ?? '—'}</Text>
640 <Text dimColor>area {d.area ?? '—'}</Text>
641 <Text dimColor>by {d.creator}</Text>
642 </Box>
643 {(d.labels.length > 0 || d.links.length > 0) && (
644 <Text dimColor>
645 {d.labels.map(l => `#${l}`).join(' ')} {d.links.map(l => `${l.kind}:${l.value}`).join(' ')}
646 </Text>
647 )}
648 {d.touches.length > 0 && <Text color="warning">touches {d.touches.join(', ')}</Text>}
649 {(d.blocked_by.length > 0 || d.blocks.length > 0) && (
650 <Box gap={1}>
651 {d.blocked_by.length > 0 && <Text color="error">waits on</Text>}
652 {d.blocked_by.map(k => (
653 <Button key={`bb-${k}`} plain onPress={press(k)}>
654 <Text bold>{k}</Text>
655 </Button>
656 ))}
657 {d.blocks.length > 0 && <Text dimColor>blocks</Text>}
658 {d.blocks.map(k => (
659 <Button key={`bl-${k}`} plain onPress={press(k)}>
660 <Text bold>{k}</Text>
661 </Button>
662 ))}
663 </Box>
664 )}
665 {bodyText.trim() && <Markdown text={bodyText} />}
666 {d.children.length > 0 && (
667 <Box flexDirection="column">
668 <Text bold>Sub-tasks</Text>
669 {d.children.map(ch => (
670 <Button key={`ch-${ch.key}`} plain onPress={press(ch.key)}>
671 <Text color={STATUS_COLOR[ch.status]}>{STATUS_ICON[ch.status]}</Text> <Text bold>{ch.key}</Text>{' '}
672 <Text color="claude">{ch.assignee ?? '—'}</Text> {short(ch.title, cols - 30)}
673 </Button>
674 ))}
675 </Box>
676 )}
677 {d.comments.length > 0 && (
678 <Box flexDirection="column">
679 <Text bold>Comments</Text>
680 {d.comments.slice(-6).map((c, i) => (
681 <Text key={`cm-${i}`}>
682 <Text dimColor>{c.ts.slice(5, 16).replace('T', ' ')}</Text> <Text color="claude">{c.author}</Text> {short(c.body, cols * 2)}
683 </Text>
684 ))}
685 </Box>
686 )}
687 <Box flexDirection="column">
688 <Text bold>History</Text>
689 {d.history.slice(-10).map((ev, i) => (
690 <Text key={`h-${i}`}>
691 <Text dimColor>{ev.ts.slice(5, 16).replace('T', ' ')}</Text> <Text color="claude">{ev.actor}</Text> {ev.action}{' '}
692 <Text dimColor>{short(describe(ev.detail), cols - 40)}</Text>
693 </Text>
694 ))}
695 </Box>
696 </Box>
697 )
698}
699
700function describe(d: Record<string, unknown>): string {
701 return Object.entries(d)
702 .filter(([, v]) => v !== null && v !== undefined && v !== '' && !(Array.isArray(v) && v.length === 0))
703 .map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
704 .join(' ')
705}
706types/index.d.ts 108 lines1export type Priority = 'urgent' | 'high' | 'med' | 'low'
2
3export type Card = {
4 key: string
5 title: string
6 priority: Priority
7 assignee: string | null
8 area: string | null
9 proposed: string | null
10}
11
12export type TaskEvent = {
13 id: number
14 ts: string
15 actor: string
16 key: string | null
17 title: string | null
18 action: string
19 detail: Record<string, unknown>
20}
21
22export type Conflict = {
23 a: string
24 a_title: string
25 a_assignee: string | null
26 a_glob: string
27 b: string
28 b_title: string
29 b_assignee: string | null
30 b_glob: string
31}
32
33export type Snapshot = {
34 me: { name: string; level: string; via: string }
35 prefix: string
36 stats: {
37 by_status: Record<string, number>
38 done_today: number
39 conflicts: number
40 proposed: number
41 last_event: number
42 areas: Record<string, Record<string, number>>
43 labels: Record<string, Record<string, number>>
44 }
45 counts: Record<string, number>
46 board: Record<string, Card[]>
47 events: TaskEvent[]
48 conflicts: Conflict[]
49 areas: string[]
50 labels: string[]
51 review_for_me: string[]
52 unread_notes: number
53}
54
55export type Detail = {
56 key: string
57 title: string
58 body: string
59 status: string
60 priority: string
61 assignee: string | null
62 proposed: string | null
63 reviewer: string | null
64 creator: string
65 area: string | null
66 parent: string | null
67 created: string
68 updated: string
69 labels: string[]
70 links: { kind: string; value: string }[]
71 touches: string[]
72 blocked_by: string[]
73 blocks: string[]
74 children: { key: string; title: string; status: string; assignee: string | null }[]
75 comments: { author: string; ts: string; body: string }[]
76 history: { ts: string; actor: string; action: string; detail: Record<string, unknown> }[]
77}
78
79/** What `task.py where --json` reports: the resolved configuration. */
80export type Where = {
81 root: string
82 db: string
83 db_exists: boolean
84 roles: string
85 roles_exists: boolean
86 prefix: string
87 labels: string[]
88}
89
90export type Filters = { area: string; priority: string; label: string }
91
92export type View = 'board' | 'list' | 'progress' | 'conflicts' | 'detail'
93
94declare module 'claude-code' {
95 interface PluginState {
96 'task-tracker': {
97 where: Where | null
98 snap: Snapshot | null
99 error: string | null
100 filters: Filters
101 view: View
102 back: View
103 detail: Detail | null
104 page: number
105 }
106 }
107}
108