SLOPSHOPPER

task-tracker

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…

newpanebandcommandtoaststatus
v0.1.0MITupdated 2026-10-09azoof-ahmed/claude-mods/task-tracker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-tracker
│ ┃ Tasks ✕ › fix the failing auth test and add an audit log call │ ┃ Tasks │ ┃ task.py where printed no JSON ⏺ Read(src/auth.ts) │ ┃ [ Retry ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tasks │ ⎿ task-tracker: No task tracker in this project yet. `/tasks init` │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tasks
Tasks task.py where printed no JSON [ Retry ]
README

task-tracker

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.

  • The CLI (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.
  • File conflicts: each task lists the globs it 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.
  • Roles: an optional TOML file gives each name a level (admin, lead, member, viewer). No file: everyone may do everything.
  • Notifications: assignments, review requests, blocked and unblocked tasks, @mentions and conflict overrides land in each name's inbox (task.py inbox), and optionally run a command of yours.
  • In Claude Code: /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.
  • Terminal app (optional): python scripts/board.py, a live keyboard-driven board built on Textual (python -m pip install textual).

Install

/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.

Options

Set them in /config, or in settings under pluginConfigs["task-tracker"].options.

OptionDefaultWhat it does
pythonpythonInterpreter for the CLI. If it does not start, python, python3 and py are tried in turn.
identityuserThe name the /tasks pane acts as. Agents use --as <name> or TASKS_AS.
idPrefixTASKTask ids read <prefix>-<n>.
dbPath.claude/tasks/tasks.dbThe database, relative to the project root (nearest folder with .git or .claude).
rolesFile.claude/tasks/roles.tomlNames and permission levels. Missing: everyone may do everything.
labelsbug,feature,chore,docs,test,blocked-externalThe 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).
refreshSeconds3How 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 where shows what was resolved and from where.

Usage

In Claude Code:

CommandWhat it does
/tasksOpen the board pane and summarise the counts.
/tasks TASK-12 (or /tasks 12)Open one task.
/tasks board list progress conflictsSwitch the pane's view.
/tasks area=api prio=high label=bug, /tasks clearFilter the board.
/tasks initCreate 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.

Roles

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
LevelMay
admineverything
leadcreate, assign anyone, review, move any task, import
membercreate, claim, assign only themselves (anyone else becomes a proposal), update their own tasks, comment
viewerread only

Names are self-declared (--as, TASKS_AS), so roles are guard rails for cooperating agents, not security.

Importers

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.

Terminal board

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.

Develop

claude plugin validate .
claude plugin test .
python -m unittest discover -s scripts/tests
claude --plugin-dir .

License

MIT

Source 2 files
hooks/register.tsx 706 lines
1import { 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}
706
types/index.d.ts 108 lines
1export 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