SLOPSHOPPER

work-panel

A right-side Claude Code panel for your task tracker: browse roadmaps, start a ready task, and clear-and-start with a handoff note when you stay in the same…

newpaneguardcommandprompttool
v0.1.0MITupdated 2026-10-07ilikeeatingrice/claude-code-work-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-panel
│ ┃ Work ✕ › fix the failing auth test and add an audit log call │ ┃ WORK │ ┃ ⏺ Read(src/auth.ts) │ ┃ No roadmaps here yet. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Talk through a design with the agent, then ⎿ Added 2 lines, removed 1 line │ ┃ type /work create <name>. ⏺ 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 │ │ › /work │ ⎿ work-panel: Work panel open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Work
WORK No roadmaps here yet. Talk through a design with the agent, then type /work create <name>.
README

Work Panel

A Claude Code plugin for big, long-running work: a feature that takes many tasks and many sessions.

  • Design → roadmap. Talk a feature through with the agent, then type /work create <name>. The agent writes the plan, cuts it into tasks with their dependencies, shows you a preview, and writes nothing to the tracker until you say OK.
  • Clean context, kept knowledge. Each task gets a fresh session. When the next task is in the same roadmap, the old session first writes a handoff note into the old task's journal, and the new session starts with it. Unrelated work starts clean.
  • One task, one agent. The panel offers only tasks whose prerequisites are done, and locks a task another live session is working on.

It is one plugin with three parts: a panel on the right side of the terminal, a roadmap skill the agent follows, and a small tracker script that keeps tasks as Markdown files in your repo.

Work Panel demo

Full-quality video with sound (mp4, 16:9) · 4:5 version

Install

In a Claude Code terminal session:

/plugin install work-panel --marketplace ilikeeatingrice/claude-code-work-panel

Or run it from a clone: claude --plugin-dir /path/to/claude-code-work-panel.

Python 3.10+ is needed for the tracker. No other dependencies.

Use it

  1. Design. Discuss the feature with the agent as usual.
  2. Create. /work create checkout-redesign. The agent writes docs/programs/checkout-redesign/PLAN.md, then shows the preview: every task, and what each one waits on. Reply OK (or ask for changes).
  3. Open the panel. /work shows it; it stays until you type /work again. New sessions start with it hidden. Arrow keys move, Enter opens a roadmap or picks a task.
  4. Start. Pick a ● task. In a fresh session the button is Start; it sends the first message, and the agent claims the task.
  5. Next task. When a task is done, pick the next one and press Clear and start (twice, to confirm).

What the panel shows

MarkStateCan you start it?
●Ready: every prerequisite is DoneYes
○Waiting: shows what it needs (needs .15)No
◐In progressOnly if no live session is on it
■Blocked: shows its resume conditionNo
✓Done, folded into one N done lineNo

What this session is doing

Once a session has a task, the panel shows only that task's roadmap (the rest fold into one row) and a header:

▲ THIS SESSION
TASK-001.02 · Guest checkout without an account
now: writing tests for the guest token · 2m ago

The agent keeps it current through a small focus tool the plugin gives it: it calls it when it claims or switches a task, at each milestone, and when it finishes. If an agent never calls it, the panel still follows its claims: after a tool call that marks a task In Progress, the panel switches to that task. After a plugin reload it reads the task back from the session's own live file, and when it still doesn't know (a session that claimed its task earlier), it asks the session's model once, with no tools, and checks the answer against the tracker. If the tracker stops listing the session's task (say its file no longer parses), the panel keeps the task and says so instead of forgetting it.

The task the session is on is drawn in bold teal; a task another live session holds, in plain teal.

What the button does

Your sessionButtonWhat happens
Fresh, nothing said yetStartSends the first message for the task.
Working on a task in the same roadmapClear and startThe session writes a handoff note (changes, commands, commits, decisions, findings, risk, next action), the plugin saves it on the old task with the branch and git status, runs /clear, and sends the first message with a summary of the note.
A different roadmap, or no taskClear and startPlain /clear, then the first message. No note.

The old conversation stays saved; claude --resume opens it.

Who is working on what

Each session running the plugin rewrites a small file, ~/.claude/work-panel/live/<session id>.json, every minute. A task named by a live file (written in the last 3 minutes) shows being worked on now and is locked. A stale claim can be taken over; one younger than 2 hours with no live session asks for a second press. Sessions without the plugin write no file, so the panel can't see them.

The files it keeps

Everything lives in your repo as plain text:

WORK.md                          the active roadmaps
backlog.config.yml               task id prefix, folders
backlog/tasks/task-001.02 - Coupon-codes.md
docs/programs/<roadmap>/PLAN.md  the design

Tasks use the Backlog.md file format: YAML front matter (status, labels, dependencies, parent), acceptance criteria, and a comments block that serves as the task's journal. See tracker/README.md for the format and commands (plans, tree, ready, validate, init, create, comment, status).

Settings

In /config → Work Panel:

SettingDefaultMeaning
treeCommandscripts/work_tracker.py tree --jsonUse your own tracker instead of the bundled one. When this script is missing and the folder has a WORK.md, the bundled tracker is used.
startHintemptyAdded to each first message, e.g. Use the work-tracker skill.
noteAuthor@claudeAuthor written on handoff notes.

A custom tracker command must print the same JSON as python3 tracker/roadmap.py tree --json: a list of roadmaps, each with plan_id, state, parent_task_id, counts and tasks; each task with task_id, title, status, ready, waiting_on, open_children, resume_condition, last_comment_at (UTC YYYY-MM-DD HH:MM) and path. The panel draws ready; it never computes it.

How it is built

  • hooks/register.tsx: the Claude Code hooks module. The panel is a pane drawn by a ui.render hook. The handoff note comes from $.model.fork over the session's own history with no tools; then $.command.run({ command: 'clear' }) and $.prompt.submit start the next session. State lives in module variables, because /clear gives the session a new id.
  • skills/roadmap/: how the agent creates a roadmap (with the preview gate), claims a task, hands off, and retires a roadmap. A project's own tracking doc (docs/work-tracking.md) takes precedence when present.
  • tracker/roadmap.py: the tracker, Python standard library only (uses PyYAML when installed).

Check with claude plugin validate . and python3 -m pytest tracker/tests.

Demo video

demo/ is a Remotion project that recreates the Claude Code terminal cell by cell. The music is original, generated by demo/audio/make_track.py at 120 BPM, and every cut, key press and caption sits on its beat grid (demo/src/beat.ts; npm run sync prints the check).

cd demo && npm install
npm run audio        # regenerate audio/track.wav (needs Python with numpy)
npm run render       # 16:9  ->  out/work-panel-demo.mp4
npm run render:4x5   # 4:5   ->  out/work-panel-demo-4x5.mp4
npm run gif

If Remotion can't download its headless Chrome, set REMOTION_CHROME to a local chrome-headless-shell binary.

License

MIT

Source 2 files
hooks/register.tsx 782 lines
1import type { Register } from 'claude-code'
2
3import type { TreePlan, TreeTask, LiveEntry } from '../types'
4
5// The Work panel: browse the roadmaps of the task tracker in the folder this
6// session runs in, start a task whose prerequisites are done, and on "Clear
7// and start" in the same roadmap have this session write its handoff note
8// first. The tracker is read through one command (`treeCommand`, JSON on
9// stdout; the shape is in ../types/index.d.ts and the README).
10//
11// State lives in module variables, not $.state: /clear gives the process a new
12// session id and $.state belongs to the old one, while the module keeps going.
13
14const PANE = 'work-panel'
15const LIVE_FRESH_MS = 3 * 60_000
16const CLAIM_GUARD_MS = 2 * 60 * 60_000
17const TEAL = '#6fb7b9'
18const AMBER = '#e0a85a'
19
20let root = ''
21let treeArgv: string[] = []
22let startHint = ''
23let noteAuthor = '@claude'
24let liveDir = ''
25let isEnabled = false
26let sessionId = ''
27
28let plans: TreePlan[] = []
29let loadError: string | undefined
30let live = new Map<string, LiveEntry>()
31const expanded = new Set<string>()
32let selected: string | undefined
33let armed: string | undefined
34let busy: string | undefined
35let problem: string | undefined
36let sessionTask: { id: string; plan: string } | undefined
37// This session's task when the tracker does not list it (a task file that no
38// longer parses, say): kept and shown with a warning instead of forgotten.
39let missingTask: string | undefined
40let showOthers = false
41let nowText: string | undefined
42let nowAt = 0
43let isTurnRunning = false
44let isFresh = true
45let heartbeat: { cancel: () => void } | undefined
46
47// ---------- data ----------
48
49async function refresh($: any) {
50  if (!isEnabled) await resolveTracker($)
51  if (!isEnabled) {
52    plans = []
53    $.ui.invalidate('ui.render')
54    return
55  }
56  try {
57    let out = await $.process.run(treeArgv, { cwd: root, timeoutMs: 20_000 })
58    const bundled = bundledArgv($)
59    if (out.exitCode !== 0 && treeArgv.join(' ') !== bundled.join(' ') && (await $.fs.exists(`${root}/WORK.md`))) {
60      // The configured tracker failed (an older copy without `tree`, say);
61      // the bundled one reads the same files.
62      const retry = await $.process.run(bundled, { cwd: root, timeoutMs: 20_000 })
63      if (retry.exitCode === 0) {
64        treeArgv = bundled
65        out = retry
66      }
67    }
68    if (out.exitCode !== 0) {
69      loadError = (out.stderr || out.stdout || 'tree failed').trim().split('\n').slice(-1)[0]
70    } else {
71      plans = JSON.parse(out.stdout) as TreePlan[]
72      loadError = undefined
73    }
74  } catch (err) {
75    loadError = String((err as Error)?.message ?? err)
76  }
77  live = await readLive($)
78  if (missingTask && !sessionTask) {
79    const found = findTask(missingTask)
80    if (found) {
81      sessionTask = { id: missingTask, plan: found.plan.plan_id }
82      expanded.add(found.plan.plan_id)
83      missingTask = undefined
84    }
85  }
86  try {
87    isFresh = (await $.session.messages()).length === 0
88  } catch {
89    isFresh = false
90  }
91  $.ui.invalidate('ui.render')
92}
93
94async function readLive($: any): Promise<Map<string, LiveEntry>> {
95  const map = new Map<string, LiveEntry>()
96  try {
97    if (!(await $.fs.exists(liveDir))) return map
98    const now = await $.clock.now()
99    for (const entry of await $.fs.list(liveDir)) {
100      if (!entry.name.endsWith('.json')) continue
101      try {
102        const value = JSON.parse(await $.fs.read(`${liveDir}/${entry.name}`)) as LiveEntry
103        if (value.sessionId !== sessionId && now - value.at > 24 * 60 * 60_000) {
104          // a session that ended a day ago: drop its file
105          await $.process.run(['rm', '-f', `${liveDir}/${entry.name}`])
106          continue
107        }
108        if (!value.task || value.sessionId === sessionId) continue
109        if (now - value.at > LIVE_FRESH_MS) continue
110        map.set(value.task, value)
111      } catch {
112        // a half-written file from another session: skip it this round
113      }
114    }
115  } catch {
116    // no live folder yet
117  }
118  return map
119}
120
121async function beat($: any) {
122  if (!isEnabled || !sessionId) return
123  const value: LiveEntry = {
124    sessionId,
125    task: sessionTask?.id ?? missingTask ?? null,
126    now: nowText ?? null,
127    nowAt,
128    cwd: root,
129    at: await $.clock.now(),
130  }
131  await $.fs.write(`${liveDir}/${sessionId}.json`, JSON.stringify(value))
132}
133
134// After a reload the module starts blank: take this session's task back from
135// its own live file, which outlives the module.
136async function restoreFromLive($: any) {
137  try {
138    const path = `${liveDir}/${sessionId}.json`
139    if (!(await $.fs.exists(path))) return
140    const saved = JSON.parse(await $.fs.read(path)) as LiveEntry
141    if (!saved.task) return
142    nowText = saved.now ?? undefined
143    nowAt = saved.nowAt ?? 0
144    const found = findTask(saved.task)
145    if (!found) {
146      missingTask = saved.task
147      return
148    }
149    sessionTask = { id: saved.task, plan: found.plan.plan_id }
150    expanded.add(found.plan.plan_id)
151    nowText = saved.now ?? undefined
152    nowAt = saved.nowAt ?? 0
153  } catch {
154    // no saved state
155  }
156}
157
158// When the panel does not know this session's task but the conversation has
159// history, ask the session's own model once (no tools, cached prefix).
160let askedModel = false
161async function askModelForTask($: any) {
162  if (sessionTask || missingTask || isFresh || askedModel || !plans.length) return
163  askedModel = true
164  const example = plans.find(plan => plan.tasks.length)?.tasks[0]?.task_id ?? 'TASK-001.01'
165  try {
166    const reply = await $.model.fork({
167      prompt: `Which tracked task is this session working on right now (task ids look like ${example})? Reply with exactly one line: the task id, a space, a vertical bar, a space, then what you are doing now in under 8 words. If this session is not working on a tracked task, reply NONE.`,
168    })
169    if (!reply.isAnswered || !reply.text) return
170    const line = reply.text.trim().split('\n')[0] ?? ''
171    const match = line.match(/\b([A-Za-z][A-Za-z0-9]*-\d+(?:\.\d+)*)\b\s*\|?\s*(.*)$/)
172    if (!match) return
173    const id = match[1]!.toUpperCase()
174    const found = findTask(id)
175    if (!found || found.task.status === 'Done' || found.task.completed) return
176    sessionTask = { id, plan: found.plan.plan_id }
177    expanded.add(found.plan.plan_id)
178    showOthers = false
179    nowText = (match[2] ?? '').trim().slice(0, 80) || undefined
180    nowAt = await $.clock.now()
181    await beat($)
182    $.ui.invalidate('ui.render')
183  } catch {
184    // the question is a convenience; the panel works without it
185  }
186}
187
188function findTask(id: string): { plan: TreePlan; task: TreeTask } | undefined {
189  for (const plan of plans) {
190    const task = plan.tasks.find(one => one.task_id === id)
191    if (task) return { plan, task }
192  }
193  return undefined
194}
195
196function lastCommentMs(task: TreeTask): number | undefined {
197  if (!task.last_comment_at) return undefined
198  const ms = Date.parse(`${task.last_comment_at.trim().replace(' ', 'T')}Z`)
199  return Number.isNaN(ms) ? undefined : ms
200}
201
202// Can this task be started from the panel, and does it need a second press?
203function startability(task: TreeTask, now: number): { canStart: boolean; why?: string; guard?: string } {
204  if (task.completed || task.status === 'Done') return { canStart: false, why: 'done' }
205  if (sessionTask?.id === task.task_id) return { canStart: false, why: 'this session' }
206  if (task.status === 'Blocked') return { canStart: false, why: task.resume_condition ? `blocked · ${task.resume_condition}` : 'blocked' }
207  if (task.status === 'In Progress') {
208    const holder = live.get(task.task_id)
209    if (holder) return { canStart: false, why: 'being worked on now' }
210    const last = lastCommentMs(task)
211    const age = last === undefined ? undefined : now - last
212    const ago = age === undefined ? 'claimed' : `claimed ${agoText(age)} ago`
213    if (age !== undefined && age < CLAIM_GUARD_MS) return { canStart: true, why: `${ago} · no live session`, guard: `${ago}, no live session seen` }
214    return { canStart: true, why: `${ago} · nobody on it now` }
215  }
216  if (task.status === 'To Do' && task.ready) return { canStart: true }
217  if (task.status === 'To Do') {
218    const waits = [...task.waiting_on, ...task.open_children].map(shortId)
219    return { canStart: false, why: waits.length ? `needs ${waits.join(', ')}` : 'not ready' }
220  }
221  return { canStart: false, why: task.status.toLowerCase() }
222}
223
224function agoText(ms: number): string {
225  const min = Math.round(ms / 60_000)
226  if (min < 60) return `${min}m`
227  const h = Math.round(min / 60)
228  if (h < 48) return `${h}h`
229  return `${Math.round(h / 24)}d`
230}
231
232function nowLine(text: string, ageMs: number): string {
233  const ago = ageMs < 60_000 ? 'just now' : `${agoText(ageMs)} ago`
234  return text === 'done' ? `finished ${ago}` : `now: ${text} · ${ago}`
235}
236
237function shortId(id: string): string {
238  const dot = id.indexOf('.')
239  return dot === -1 ? id : id.slice(dot)
240}
241
242type Case = 'start' | 'same' | 'other'
243
244function caseFor(plan: TreePlan): Case {
245  if (isFresh && !sessionTask) return 'start'
246  if (sessionTask && sessionTask.plan === plan.plan_id) return 'same'
247  return 'other'
248}
249
250// ---------- the start flow ----------
251
252const NOTE_PROMPT = `Write the handoff note for the task this session worked on, for the next session that continues the same roadmap. Use exactly this shape, replacing every placeholder with exact facts from this session (write "None" where nothing applies). Reply with the note only, no preamble.
253
254session end
255  Changes: <exact files and behavior changed>
256  Commands/results: \`<exact command>\`: <trimmed actual result>
257  Commits or dirty state: <SHA(s), or the exact git status>
258  Decisions: <decisions made and why>
259  Findings: <facts discovered, including failed attempts>
260  Residual risk: <known unverified behavior, or None>
261  Next action: <the next concrete action or None>`
262
263async function appendComment($: any, path: string, entry: string) {
264  const text: string = await $.fs.read(path)
265  const end = '<!-- COMMENTS:END -->'
266  const at = text.lastIndexOf(end)
267  if (at === -1) throw new Error('task file has no comments block')
268  const now = new Date(await $.clock.now())
269  const iso = now.toISOString().replace(/\.\d+Z$/, 'Z')
270  const created = `${iso.slice(0, 10)} ${iso.slice(11, 16)}`
271  const body = entry.trim().split('\n')
272  const record = `author: ${noteAuthor}\ncreated: ${created}\n---\n${iso}: ${body[0]}\n${body.slice(1).join('\n')}\n---\n`
273  const before = text.slice(0, at)
274  await $.fs.write(path, `${before}${before.endsWith('\n') ? '' : '\n'}${record}${text.slice(at)}`)
275}
276
277async function gitFacts($: any): Promise<string> {
278  try {
279    const branch = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root })
280    const status = await $.process.run(['git', 'status', '--short'], { cwd: root })
281    const lines = status.stdout.trim().split('\n').filter(Boolean)
282    const shown = lines.slice(0, 15).map((line: string) => `    ${line}`).join('\n')
283    return `  Branch (read by the work panel): ${branch.stdout.trim()}\n  git status --short (${lines.length} lines):\n${shown || '    clean'}${lines.length > 15 ? '\n    …' : ''}`
284  } catch {
285    return '  Branch: (could not read git)'
286  }
287}
288
289function firstMessage(plan: TreePlan, task: TreeTask, note?: { from: string; text: string }): string {
290  const lines = [
291    `Start ${task.task_id} (${task.title}). Roadmap: ${plan.plan_id} (${plan.parent_task_id}).${startHint ? ` ${startHint}` : ''}`,
292  ]
293  if (task.status === 'In Progress') {
294    lines.push(
295      `${task.task_id} is already In Progress${task.assignees.length ? ` (assignee ${task.assignees.join(', ')})` : ''}, but no live session is working on it. Read its latest comment first, take over the claim, and record the takeover in its journal.`,
296    )
297  }
298  if (note) {
299    lines.push(
300      '',
301      `This continues the same roadmap. The previous session just handed off ${note.from}; its full note is the latest comment on ${note.from}. Read it before you start. The note:`,
302      '',
303      note.text.trim(),
304    )
305  }
306  return lines.join('\n')
307}
308
309async function start($: any, planId: string, taskId: string) {
310  const found = findTask(taskId)
311  if (!found || found.plan.plan_id !== planId) return
312  const { plan, task } = found
313  const which = caseFor(plan)
314  problem = undefined
315  armed = undefined
316  try {
317    let note: { from: string; text: string } | undefined
318    if (which === 'same' && sessionTask) {
319      const from = findTask(sessionTask.id)
320      busy = `writing the handoff note on ${sessionTask.id}…`
321      $.ui.invalidate('ui.render')
322      const reply = await $.model.fork({ prompt: NOTE_PROMPT })
323      if (!reply.isAnswered || !reply.text?.trim()) {
324        throw new Error(`the note could not be written (${reply.reason ?? 'empty reply'}); nothing was cleared`)
325      }
326      const text = `${reply.text.trim()}\n${await gitFacts($)}`
327      if (from) await appendComment($, from.task.path, text)
328      note = { from: sessionTask.id, text }
329    }
330    if (which !== 'start') {
331      busy = 'clearing…'
332      $.ui.invalidate('ui.render')
333      await $.command.run({ command: 'clear' })
334      sessionId = await $.session.id()
335    }
336    sessionTask = { id: task.task_id, plan: plan.plan_id }
337    missingTask = undefined
338    nowText = 'starting'
339    nowAt = await $.clock.now()
340    expanded.add(plan.plan_id)
341    showOthers = false
342    selected = undefined
343    busy = undefined
344    isFresh = false
345    await beat($)
346    $.ui.invalidate('ui.render')
347    await $.prompt.submit({ text: firstMessage(plan, task, note) })
348  } catch (err) {
349    busy = undefined
350    problem = String((err as Error)?.message ?? err)
351    $.ui.invalidate('ui.render')
352  }
353}
354
355// ---------- hooks ----------
356
357// About 30% of the terminal, 56-96 columns; remembered for opens at startup.
358function paneColumns(terminalColumns: number | undefined): number {
359  return Math.min(96, Math.max(56, Math.round((terminalColumns ?? 190) * 0.3)))
360}
361
362async function openPane($: any, terminalColumns?: number) {
363  const stored = await $.store.get('columns')
364  const columns = terminalColumns ? paneColumns(terminalColumns) : typeof stored === 'number' ? stored : paneColumns(undefined)
365  await $.store.set('columns', columns)
366  return $.ui.open({ id: PANE, title: 'Work', columns, focus: true })
367}
368
369// The command's first word: a script path relative to the session folder
370// (a .py one runs with that folder's .venv python, else python3), or a
371// program on PATH.
372async function resolveTree($: any, command: string): Promise<string[] | undefined> {
373  const words = command.trim().split(/\s+/).filter(Boolean)
374  const first = words[0]
375  if (!first) return undefined
376  const isPath = first.includes('/')
377  if (isPath && !(await $.fs.exists(`${root}/${first}`))) return undefined
378  if (first.endsWith('.py')) {
379    const venv = `${root}/.venv/bin/python`
380    return [(await $.fs.exists(venv)) ? venv : 'python3', ...words]
381  }
382  return words
383}
384
385let treeCommand = ''
386
387const FOCUS_TOOL = 'mcp__work-panel__focus'
388const FOCUS_SPEC = {
389  name: 'focus',
390  description:
391    "Tell the Work Panel (the task list beside this conversation) which tracked task this session is working on and what you are doing right now. Call it when you claim or switch to a task, at each milestone (a new step, tests running, waiting on the person), and with done: true when the task is finished. Keep `now` to a short phrase, e.g. 'writing tests for coupon rules'. It only updates the panel; it changes no files.",
392  inputSchema: {
393    type: 'object',
394    properties: {
395      task_id: { type: 'string', description: 'The tracked task id, e.g. TASK-001.02' },
396      now: { type: 'string', description: 'What you are doing right now, a short phrase (under 60 characters)' },
397      done: { type: 'boolean', description: 'True when the task is finished' },
398    },
399    required: ['task_id', 'now'],
400  },
401}
402
403function bundledArgv($: any): string[] {
404  return ['python3', `${$.plugin.root}/tracker/roadmap.py`, 'tree', '--json']
405}
406
407// The configured tracker command when its script is here; otherwise the
408// bundled tracker once the folder has a WORK.md registry.
409async function resolveTracker($: any) {
410  const argv = await resolveTree($, treeCommand)
411  if (argv) {
412    treeArgv = argv
413  } else if (await $.fs.exists(`${root}/WORK.md`)) {
414    treeArgv = bundledArgv($)
415  } else {
416    isEnabled = false
417    return
418  }
419  isEnabled = true
420}
421
422function createPrompt(name: string): string {
423  return [
424    `Create a roadmap named "${name}" from the design we discussed in this conversation.`,
425    'Follow the "Create a roadmap from a design" steps of the roadmap skill (work-panel:roadmap):',
426    'write the PLAN.md, cut the slices, show me the dry-run preview, and wait for my OK before anything is written.',
427  ].join(' ')
428}
429
430export const register: Register = (on, options) => {
431  const settings = options as { treeCommand?: string; startHint?: string; noteAuthor?: string }
432  on('session.start', async ($, e, next) => {
433    const started = await next(e)
434    root = e.cwd
435    treeCommand = settings.treeCommand || 'scripts/work_tracker.py tree --json'
436    startHint = settings.startHint || 'Use the roadmap skill (work-panel:roadmap).'
437    noteAuthor = settings.noteAuthor || '@claude'
438    const home = (await $.process.run(['printenv', 'HOME'])).stdout.trim()
439    liveDir = `${home}/.claude/work-panel/live`
440    sessionId = await $.session.id()
441    await $.command.register({
442      name: 'work',
443      description: 'Show or hide the Work panel; /work create <name> turns the design discussed here into a roadmap',
444      argumentHint: '[create <name>]',
445    })
446    await $.tool.register(FOCUS_SPEC)
447    await refresh($)
448    await restoreFromLive($)
449    $.ui.invalidate('ui.render')
450    await beat($)
451    heartbeat?.cancel()
452    heartbeat = $.clock.every(60_000, () => {
453      void beat($).then(() => (isTurnRunning ? undefined : refresh($)))
454    })
455    return started
456  })
457
458  on('session.end', async ($, e, next) => {
459    // The process goes on after /clear under a new id; free the old id's entry.
460    if (isEnabled && e.sessionId) {
461      try {
462        await $.fs.write(`${liveDir}/${e.sessionId}.json`, JSON.stringify({ sessionId: e.sessionId, task: null, cwd: root, at: 0 }))
463      } catch {
464        // nothing to free
465      }
466    }
467    if (e.reason === 'clear') {
468      sessionTask = undefined
469      missingTask = undefined
470      nowText = undefined
471      askedModel = false
472      isFresh = true
473    }
474    return next(e)
475  })
476
477  on('command.run', { command: 'work' }, async ($, e) => {
478    const args = (e.args ?? '').trim()
479    if (/^create\b/i.test(args)) {
480      const name = args.replace(/^create\b/i, '').trim()
481      if (!name) return { text: 'Usage: /work create <roadmap name>' }
482      if (isTurnRunning) return { text: 'Work panel: wait for the current turn to finish, then /work create again.' }
483      $.clock.after(50, () => void $.prompt.submit({ text: createPrompt(name) }))
484      return { text: `Work panel: asked the agent to draft the roadmap "${name}". It will show a preview first.` }
485    }
486    const panes = await $.ui.panes()
487    // A pane opened unasked waits undrawn below 144 columns: only one the
488    // person can see is closed; otherwise /work opens it for real.
489    if (panes.some(pane => pane.id === PANE && pane.isPlaced && pane.isShown)) {
490      await $.ui.close({ id: PANE })
491      return { text: 'Work panel hidden.' }
492    }
493    await refresh($)
494    void askModelForTask($)
495    const opened = await openPane($, e.presentation?.columns)
496    return { text: opened.isPlaced ? 'Work panel open.' : 'Work panel waits for a wider terminal.' }
497  })
498
499  on('prompt.submit', async ($, e, next) => {
500    isTurnRunning = true
501    isFresh = false
502    return next(e)
503  }).catch(($, e, next) => next(e))
504
505  // The agent reports its task and what it is doing now; the panel shows it.
506  on('tool.call', { tool: FOCUS_TOOL }, async ($, e) => {
507    const input = e as unknown as { task_id?: string; now?: string; done?: boolean }
508    const id = String(input.task_id ?? '').trim().toUpperCase()
509    let found = findTask(id)
510    if (!found) {
511      await refresh($)
512      found = findTask(id)
513    }
514    if (!found) return { result: `Work Panel: no task ${id} in this folder's tracker; nothing changed.` }
515    if (sessionTask?.id !== id) {
516      expanded.add(found.plan.plan_id)
517      showOthers = false
518      selected = undefined
519      armed = undefined
520    }
521    sessionTask = { id, plan: found.plan.plan_id }
522    missingTask = undefined
523    nowText = input.done ? 'done' : String(input.now ?? '').trim().slice(0, 80) || undefined
524    nowAt = await $.clock.now()
525    await beat($)
526    $.ui.invalidate('ui.render')
527    return { result: `Work Panel shows this session on ${id}${nowText ? ` (now: ${nowText})` : ''}.` }
528  }).catch(() => ({ result: 'Work Panel could not update; carry on with the task.' }))
529
530  // This session's task follows its claims as they happen: after a tool call
531  // that names a task and looks like a status change, the tracker is re-read,
532  // and a named task that has just become In Progress is this session's task.
533  on('tool.call', async ($, e, next) => {
534    const result = await next(e)
535    if (!isEnabled || e.tool === FOCUS_TOOL) return result
536    const fields = e as unknown as Record<string, unknown>
537    const text = [fields.command, fields.file_path, fields.new_string, fields.content]
538      .filter(v => typeof v === 'string')
539      .join(' ')
540    if (!/in progress|status|tasks\//i.test(text)) return result
541    const named = new Set<string>()
542    for (const match of text.matchAll(/\b([a-z][a-z0-9]*)-(\d+(?:\.\d+)*)\b/gi)) {
543      named.add(`${match[1]!.toUpperCase()}-${match[2]}`)
544    }
545    const before = [...named].filter(id => {
546      const found = findTask(id)
547      return found !== undefined && found.task.status !== 'In Progress'
548    })
549    if (!before.length) return result
550    await refresh($)
551    for (const id of before) {
552      const found = findTask(id)
553      if (found && found.task.status === 'In Progress' && sessionTask?.id !== id) {
554        sessionTask = { id, plan: found.plan.plan_id }
555        missingTask = undefined
556        expanded.add(found.plan.plan_id)
557        showOthers = false
558        selected = undefined
559        armed = undefined
560        await beat($)
561        $.ui.invalidate('ui.render')
562      }
563    }
564    return result
565  }).catch(($, e, next) => next(e))
566
567  on('turn.complete', async ($, e, next) => {
568    const done = await next(e)
569    isTurnRunning = false
570    if (isEnabled) void refresh($).then(() => askModelForTask($))
571    return done
572  })
573
574  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
575    const { Box, Text, Button } = $.ui.resolve(e)
576    // The pane's own body width (viewport.columns is the whole conversation's).
577    const width = Math.max(30, (e.props?.bodyColumns ?? 58) - 1)
578    const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s)
579    const now = Date.now()
580
581    if (!isEnabled) {
582      return (
583        <Box flexDirection="column" gap={1}>
584          <Text bold>WORK</Text>
585          <Text dimColor wrap="wrap">No roadmaps here yet.</Text>
586          <Text wrap="wrap">{'Talk through a design with the agent, then type /work create <name>.'}</Text>
587        </Box>
588      )
589    }
590    if (loadError && !plans.length) {
591      return (
592        <Box flexDirection="column">
593          <Text color="error">Could not read the tracker</Text>
594          <Text dimColor wrap="wrap">{loadError}</Text>
595          <Button plain dimColor onPress={() => void refresh($)}>Retry</Button>
596        </Box>
597      )
598    }
599
600    const current = sessionTask ? findTask(sessionTask.id) : undefined
601    const lostId = current ? undefined : (sessionTask?.id ?? missingTask)
602    const active = plans.filter(plan => plan.state === 'Active')
603    const pickedRow = selected ? findTask(selected) : undefined
604
605    // A session with a task sees its own roadmap; the rest fold into one row.
606    const focus = sessionTask ? active.find(plan => plan.plan_id === sessionTask?.plan) : undefined
607    const others = focus ? active.length - 1 : 0
608    const shown = focus && !showOthers ? [focus] : active
609
610    const rows: any[] = []
611    for (const plan of shown) {
612      const isOpen = expanded.has(plan.plan_id)
613      const isSame = sessionTask?.plan === plan.plan_id
614      const meta = `${plan.counts.done}/${plan.counts.total} · ${plan.counts.ready} ready`
615      const name = `${plan.parent_task_id.replace(/^[A-Za-z]+-/, '')} ${plan.plan_id}`
616      rows.push(
617        <Box key={`p-${plan.plan_id}`} flexDirection="row" justifyContent="space-between">
618          <Button
619            key={`plan-${plan.plan_id}`}
620            plain
621            label={`${isOpen ? '▾' : '▸'} ${cut(name, width - meta.length - 4)}`}
622            onPress={() => {
623              if (isOpen) expanded.delete(plan.plan_id)
624              else expanded.add(plan.plan_id)
625              $.ui.invalidate('ui.render')
626            }}
627          />
628          <Text color={isSame ? TEAL : undefined} dimColor={!isSame}>{isSame ? 'this roadmap' : meta}</Text>
629        </Box>,
630      )
631      if (!isOpen) continue
632      const open = plan.tasks.filter(task => task.status !== 'Done' && !task.completed)
633      const doneCount = plan.tasks.length - open.length
634      const order = (task: TreeTask) => {
635        const s = startability(task, now)
636        if (task.status === 'In Progress') return s.canStart ? 1 : 0
637        if (s.canStart) return 1
638        if (task.status === 'Blocked') return 3
639        return 2
640      }
641      const sorted = [...open].sort((a, b) => order(a) - order(b))
642      for (const task of sorted) {
643        const s = startability(task, now)
644        const mark =
645          task.status === 'In Progress' ? '◐' : task.status === 'Blocked' ? '■' : s.canStart ? '●' : '○'
646        const isPicked = selected === task.task_id
647        // A task someone is on right now is drawn in teal; this session's own, bold.
648        const isMine = s.why === 'this session'
649        const working = isMine || s.why === 'being worked on now'
650        const label = `  ${isPicked ? '▶' : mark} ${shortId(task.task_id).padEnd(4)} ${cut(task.title, width - 12)}`
651        rows.push(
652          s.canStart ? (
653            <Button
654              key={`t-${task.task_id}`}
655              plain
656              dimColor={!isPicked && task.status === 'In Progress'}
657              label={label}
658              onPress={() => {
659                selected = task.task_id
660                armed = undefined
661                problem = undefined
662                $.ui.invalidate('ui.render')
663              }}
664            />
665          ) : (
666            <Text
667              key={`t-${task.task_id}`}
668              dimColor={!working}
669              bold={isMine}
670              color={working ? TEAL : task.status === 'Blocked' ? AMBER : undefined}
671              wrap="truncate-end"
672            >
673              {label}
674            </Text>
675          ),
676        )
677        if (s.why) {
678          rows.push(
679            <Text key={`w-${task.task_id}`} dimColor={!isMine} color={working ? TEAL : undefined} wrap="truncate-end">
680              {`         ${cut(s.why, width - 10)}`}
681            </Text>,
682          )
683        }
684      }
685      if (doneCount) rows.push(<Text key={`d-${plan.plan_id}`} dimColor>{`  ✓ ${doneCount} done`}</Text>)
686    }
687    if (others > 0) {
688      rows.push(
689        <Button
690          key="others"
691          plain
692          dimColor
693          label={showOthers ? '▴ only this roadmap' : `▸ ${others} other roadmap${others === 1 ? '' : 's'}`}
694          onPress={() => {
695            showOthers = !showOthers
696            $.ui.invalidate('ui.render')
697          }}
698        />,
699      )
700    }
701
702    let action: any = null
703    if (busy) {
704      action = <Text color={TEAL}>{busy}</Text>
705    } else if (pickedRow) {
706      const which = caseFor(pickedRow.plan)
707      const s = startability(pickedRow.task, now)
708      const id = shortId(pickedRow.task.task_id)
709      const verb = which === 'start' ? 'Start' : 'Clear and start'
710      const needsConfirm = which !== 'start' || Boolean(s.guard)
711      const isArmed = armed === pickedRow.task.task_id
712      const hint =
713        which === 'same'
714          ? `Same roadmap: a handoff note goes on ${sessionTask?.id} first.`
715          : which === 'other'
716            ? 'No handoff note: different roadmap or no task here.'
717            : 'Fresh session: nothing to clear.'
718      action = (
719        <Box flexDirection="column">
720          {s.guard ? <Text color={AMBER} wrap="wrap">{`Careful: ${s.guard}.`}</Text> : null}
721          <Button
722            key="go"
723            variant="primary"
724            autoFocus
725            label={isArmed ? `Press again: ${verb.toLowerCase()} ${id}` : `${verb} ${pickedRow.task.task_id}`}
726            onPress={() => {
727              if (isTurnRunning) {
728                problem = 'Wait for the current turn to finish.'
729                $.ui.invalidate('ui.render')
730                return
731              }
732              if (needsConfirm && !isArmed) {
733                armed = pickedRow.task.task_id
734                $.ui.invalidate('ui.render')
735                return
736              }
737              const planId = pickedRow.plan.plan_id
738              const taskId = pickedRow.task.task_id
739              $.clock.after(50, () => void start($, planId, taskId))
740            }}
741          />
742          <Text dimColor wrap="wrap">{hint}</Text>
743        </Box>
744      )
745    } else {
746      action = <Text dimColor>Pick a ● task to start it.</Text>
747    }
748
749    return (
750      <Box flexDirection="column" gap={1}>
751        <Box flexDirection="row" justifyContent="space-between">
752          <Text bold>WORK</Text>
753          <Text dimColor>{`${active.length} roadmap${active.length === 1 ? '' : 's'} · /work hides`}</Text>
754        </Box>
755        {current ? (
756          <Box flexDirection="column">
757            <Text color={AMBER}>
758              {current.task.status === 'Done' || current.task.completed ? '▲ THIS SESSION · done' : '▲ THIS SESSION'}
759            </Text>
760            <Text>{cut(`${current.task.task_id} · ${current.task.title}`, width)}</Text>
761            {nowText ? <Text dimColor>{cut(nowLine(nowText, now - nowAt), width)}</Text> : null}
762          </Box>
763        ) : lostId ? (
764          <Box flexDirection="column">
765            <Text color={AMBER}>▲ THIS SESSION</Text>
766            <Text>{cut(`${lostId} · not in the tracker's list`, width)}</Text>
767            <Text color="warning" wrap="wrap">
768              Its task file may not parse. Run the tracker's validate command to see why.
769            </Text>
770          </Box>
771        ) : null}
772        <Box flexDirection="column">{rows}</Box>
773        <Box flexDirection="column">
774          {action}
775          {problem ? <Text color="error" wrap="wrap">{problem}</Text> : null}
776          {loadError ? <Text color="warning" wrap="wrap">{`Tracker: ${loadError}`}</Text> : null}
777        </Box>
778      </Box>
779    )
780  })
781}
782
types/index.d.ts 42 lines
1// Shapes the Work panel reads: the tracker command's JSON (treeCommand) and
2// the per-session live files under ~/.claude/work-panel/live/.
3
4export type TreeTask = {
5  task_id: string
6  title: string
7  status: string
8  priority: string | null
9  ordinal: number
10  assignees: string[]
11  labels: string[]
12  parent_task_id: string | null
13  dependencies: string[]
14  ready: boolean
15  waiting_on: string[]
16  open_children: string[]
17  resume_condition: string | null
18  last_comment_at: string | null
19  completed: boolean
20  path: string
21}
22
23export type TreePlan = {
24  plan_id: string
25  priority: number
26  state: string
27  path: string
28  parent_task_id: string
29  parent_title: string | null
30  counts: { total: number; done: number; ready: number; in_progress: number; blocked: number; waiting: number }
31  tasks: TreeTask[]
32}
33
34export type LiveEntry = {
35  sessionId: string
36  task: string | null
37  now?: string | null
38  nowAt?: number
39  cwd: string
40  at: number
41}
42