SLOPSHOPPER

backlog-tools

Backlog-native Claude Code skills for the Backlog product itself — writing a Backlog import plan from an agreed specification, running one item of such a plan…

newpanebandguardcommandtoast
v0.17.0MITupdated 2026-10-09JSdotNet/Backlog/plugins/backlog-tools
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · backlog-tools
│ ┃ Plan progress ✕ › fix the failing auth test and add an audit log call │ ┃ No plan running yet. │ ┃ Run /backlog-tools:execute-plan +tag to ⏺ Read(src/auth.ts) │ ┃ start one. ⎿ 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 │ │ › /plan-progress │ ⎿ backlog-tools: Plan progress pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plan progress
No plan running yet. Run /backlog-tools:execute-plan +tag to start one.
README

backlog-tools

Backlog-native tooling — skills specific to the Backlog product itself, as opposed to general-purpose or knowledge-folder tooling. Five skills: one for each direction of a plan, one for running a whole plan, one for bringing another tool's items into the Inbox, and one for the remarks a person leaves while reading — and a pane that follows a running plan.

  • import-plan — turns an agreed specification into a Backlog import plan (ADR 0007: .devbook/arc42/adr/0007-import-reuses-the-entry-text-grammar.md). Every entry is a prompt an AI session runs, or a task or test only the user does — never both in one entry. Prompts are the default: while it writes the plan it interviews you — the open decisions, the values and access the work needs, what a pass looks like — and writes the answers into the prompts, so no step stops to ask when it runs. A manual check is offered as an AI-run QA prompt first, and a step stays a task or test only when you keep it, with a Kept manual: line saying why. Every step, whatever its kind, names its repository with repo:. It always ships a review view next to the raw plan — one HTML page built from skills/import-plan/assets/plan-review.html that parses the embedded plan itself and shows its checks, dependency order and entries, published as an artifact every run before the plan text is shown — a local HTML file only when the host has no artifacts. User-invoked only (disable-model-invocation: true); it never talks to the Backlog app or GitHub. A plan imports at two levels (ADR 0013: .devbook/arc42/adr/0013-imported-plan-is-a-roadmap-item-laid-out-by-import.md): it opens with one plan entry, which Import turns into the Roadmap Item, and the step entries under it share its +tag, so the item gathers them:
  # VS Code desktop rollout

  `plan` `*high` `+vscode-desktop-rollout` `repo:backlog-desktop`

  # Add the export command

  `prompt` `!ready` `+vscode-desktop-rollout` `id:add-command` `repo:backlog-desktop` `effort:5`

Asked for the roadmap level, it writes plan entries only, one per agreed feature chapter, ordered by after: from the chapters' depends-on lists.

  • run-plan-item — runs one entry of such a plan after it is copied out of the Backlog app and pasted into a session. The app puts the invocation on the first line of every entry it copies — /backlog-tools:run-plan-item entry <id>:, the entry under it — so the paste runs the skill outright; it is also model-invoked on the marker line every generated prompt entry opens with (Backlog plan item …`). Both shapes are defined in skills/import-plan/assets/backlog-import-grammar.md. It checks the item is still outstanding before doing anything — pasting the same item twice is expected and must not redo finished work, and declines a plan entry in one line, since only Import acts on one — and then carries out the instructions the way the current repository says work is done. When a backlog MCP server is in the session's tool list it reads the entry's status from it (read_item) and reports Ready → In progress → Done back (transition), every call carrying the repository` read off the git remote; without one it falls back to searching git and says the status has to be set by hand.
  • execute-plan — runs a whole plan, after the pattern of Matt Pocock's implement-spec. It reads the plan from the backlog MCP server by its +tag (get_plan_items) or from a file import-plan wrote, builds the graph its after: tokens draw, and changes nothing itself: each prompt entry whose prerequisites are done goes to its own sub-agent, briefed by skills/execute-plan/assets/item-brief.md, which makes its own worktree and runs the entry through run-plan-item — so every entry passes the repository's gate and its own Personal Validation. Entries that wait on nothing run in parallel. An entry is done when its pull request merges; the orchestrator then moves it to Done and starts what waited on it. It stops at task and test entries, listing what they hold up, starts the application in its own worktree for the first one that validates landed work, and resumes from the plan's state when invoked again. Three landing modes, defined in skills/execute-plan/assets/landing-modes.md: per item, each pull request to the base branch; branch, each into one plan/<tag without its +> branch that the orchestrator merges into, ending in a single pull request to the base branch; and stacked, each cut from and targeting its predecessor's branch, so a chain runs before anything merges and the person merges bottom-up. Attended, the orchestrator relays each Personal Validation to the person; unattended (branch or stacked), each entry parks at its gate and lands as a draft pull request whose body is the review handoff, so the whole plan runs without anyone present and Personal Validation happens on the drafts — in branch mode on the closing draft to the base branch. A failed entry blocks only what depends on it. It titles its own session <tag>:execute-plan and labels each sub-agent <tag>:<n> - <Title>, and a ready entry in another repository is handed to a new session in that repository's checkout running the same plan, per skills/execute-plan/assets/handoff.md. User-invoked only. The UserPromptSubmit hook stays quiet on a prompt that names this skill, so a pasted plan is not mistaken for one item.
  • import-inbox — turns an export from another to-do tool into an inbox import manifest (ADR 0017: .devbook/arc42/adr/0017-inbox-import-is-a-capture-source-with-a-markdown-manifest.md). The manifest is Markdown with front matter, one # item per open task, each with a meta fence of capture facts. The Inbox's Sources panel imports it through Import file…. Completed tasks are dropped. The person chooses which labels become tags and which lists file into which Inbox Lists. Microsoft To Do is the first tool it knows. Another tool is added as a row of the skill's Formats table, never as a second manifest shape, so the product needs no converter per tool. Like the plan skill, it always ships a review view, built from skills/import-inbox/assets/inbox-import-review.html. The view parses the embedded manifest itself and runs the checks of the grammar in skills/import-inbox/assets/inbox-import-manifest.md. That matters more here: a hand-edited manifest can break in ways a generated one never does. User-invoked only (disable-model-invocation: true); it never talks to the Backlog app, the source tool, or GitHub.
  • handle-remarks — empties the other inbox: the private reading notes a person left on a repository's Devbook chapters in the app. It reads them over MCP (list_annotations), writes each answer into the chapter as a devbook annotation fence through the devbook plugin's own annotations.mjs, and only then resolves the note (resolve_annotation). Fence first, resolve second, because that is the order whose half-done state is recoverable. The two annotation kinds stay two things — .devbook/arc42/adr/0012-backlog-is-an-mcp-server-inside-the-desktop-app.md §6 is the decision, and the app is never the fence's writer. run-plan-item carries a short form of the same procedure for the notes an item it just ran leaves answerable.

The plan-item marker exists because a plan is written before the app has given its entries an id, so it restates the id:, +tag, repo: and after: the metadata line carries. The entry line carries what only the app knows — the stored id — and is what makes any copied entry a runnable paste.

hooks/hooks.json adds a UserPromptSubmit hook (Claude Code) that notices either line in a prompt and nudges the session to invoke run-plan-item, so triggering does not rest on the skill description alone. It needs grep on the hook shell, which Claude Code provides on every platform it runs on.

It also registers hooks/telemetry-forwarder.mjs for PreToolUse, PostToolUse, SubagentStop, PreCompact, Stop and SessionEnd. Each event is posted to the app's /telemetry endpoint — beside /mcp, on the same port and behind the same bearer token — and the app attributes the tool calls, delegated agents, token usage and context gauge to the delivery run that session is driving, which the Sessions pane shows under the run's row. The port and token come from BACKLOG_MCP_PORT and BACKLOG_MCP_TOKEN, else from the app's own settings.json. The tool-call events are matched to shell, edits, sub-agents, skills and MCP tools, the calls a run's figures are read for, so a session is not paying a process spawn on every Read and Grep. The script needs Node, drops the tool's output before posting, gives up after two seconds and exits 0 on any error, so a closed app costs a session nothing but a refused connection.

The plan-progress pane

hooks/hooks.json also names a hooks module, hooks/register.tsx — Claude Code's function hooks, beside the command hooks above. It draws an execute-plan run in two places:

  • The band above the prompt, while any entry is in flow: the plan's tag and done over total, then one row per entry running, at a gate or blocked — its delivery flow's stages (✓ done, ● in progress, ✗ blocked, – skipped, ○ pending) and the stage it stands at, tagged gate or blocked. Entries done or manual fold into one also: line. A band another plugin draws there, such as delivery-run-view's flow band, stays under it.
  • The Plan progress pane, the band's details button or /backlog-tools:plan-progress: the whole plan as a matrix — every entry a row, every stage a column under its short name (scp, imp, b&t, pv, …) — with the gate's review link and each block's reason under it.

The status line carries the counts, and a toast says when an entry reaches its gate or blocks.

It writes nothing and calls nothing: it reads the calls the run already makes.

What it showsRead from
The entries and their statusesget_plan_items and transition, on any MCP server
Which sub-agent runs which entryThe first line of skills/execute-plan/assets/item-brief.md, in the Agent call's prompt — change it and parseBrief in hooks/parse.ts with it
Each entry's stagesstart_run, update_stage and finish_run on any delivery surface, from the sub-agent's loop or one it spawned
How the entry endedThe sub-agent's one-line return: gate, pr <url>, blocked <stage>: …, done-before

The module's state is declared in types/index.d.ts. claude plugin validate plugins/backlog-tools checks the module against it, and claude plugin test plugins/backlog-tools runs tests/, which drive the hooks against the engine and a scripted run (tests/demo.ts). Once Claude Code has loaded the plugin from this checkout it lays its declarations in the ignored .claude-plugin/types/, and tsc -p plugins/backlog-tools type-checks the module. Function hooks are early access in Claude Code: a release can change the API, and the pane is what breaks.

The plugin ships one manifest, .claude-plugin/plugin.json, for Claude Code.

Microsoft To Do export

Microsoft To Do has no export of its own. What a route has to deliver is a stable id per task, because the import knows an item by {tool}:{external_id}. Without an id, every re-import duplicates. The routes compared on 2026-09-26:

RouteStable id per taskCompleted flagList nameWho sees the data
Microsoft Graph, read with Microsoft's Microsoft.Graph.Authentication PowerShell module (chosen)yes: the todoTask idyes: statusyes: the enclosing listonly Microsoft and the person's machine; the scope is read-only Tasks.Read
Graph Explorer, copying responses by handyesyesyes, one list per queryonly Microsoft. But each list and every @odata.nextLink page is a separate copy, which does not scale past a handful of tasks
Microsoft-To-Do-Export (open source CLI, daylamtayari)its raw JSON is Graph's shape; its CSV and Todoist formats are notyes, with --completedyesthe person's machine, but it asks for a Graph Explorer token with Tasks.ReadWrite pasted into it
Microsoft To Do Exporter (hosted web app)not documentednot documentedyesa third-party site that reads every task with Tasks.Read
To Do Vo Do (browser app)yes: its CSV carries idyesnot documented for its CSVa third-party app; by its own account the data goes between Microsoft and the browser only
Classic Outlook's CSV export (work accounts, which sync To Do into Outlook Tasks)noyesthe Outlook folderonly the person's machine; unavailable for personal accounts

The Graph route wins. It is first-party, read-only, works for work, school, and personal accounts, pages through every list, and keeps Graph's id. The steps and the script are in the skill's own Inputs section, and they are what the plan's round-trip test runs. One limitation applies to every route, because it comes from Graph itself: a task's id changes when the task is moved to another list. Graph's immutable ids cover Outlook items but not todoTask. So a task moved between two exports is imported a second time.

Install

Claude Code, inside a session started at the repository root:

/plugin marketplace add .
/plugin install backlog-tools@jsdotnet-backlog
Source 4 files
hooks/register.tsx 321 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Worker } from '../types'
5import {
6  isPlanItems,
7  isTransition,
8  newWorker,
9  normStatus,
10  parseBrief,
11  parsePlan,
12  parseReturn,
13  parseRunId,
14  surfaceOp,
15  tally,
16} from './parse'
17import { GLYPH, KIND, abbrev, alsoLine, bandRows, cellsOf, columnsOf, currentStage, matrixLayout, matrixSvg, rowsOf } from './view'
18
19// The execute-plan view: a band above the prompt with the entries still in flow, and a pane —
20// the band's details — with the whole plan as a matrix of entries by delivery-flow stage. Both
21// are read from the calls execute-plan and its sub-agents already make; nothing here writes.
22const PANE = 'plan-progress'
23const plan = atom({ plugin: 'backlog-tools', key: 'plan' } as const, null)
24const workers = atom({ plugin: 'backlog-tools', key: 'workers' } as const, [])
25const parents = atom({ plugin: 'backlog-tools', key: 'parents' } as const, {})
26
27const patchWorker = (list: Worker[], agentId: string, fn: (w: Worker) => Worker) =>
28  list.map(w => (w.agentId === agentId ? fn(w) : w))
29
30/** The execute-plan worker a loop belongs to, walking nested spawns up. */
31async function rootOf($: EngineInterface, agentId: string | undefined) {
32  if (!agentId) return undefined
33  const list = await read($, workers)
34  const map = await read($, parents)
35  let id: string | undefined = agentId
36  for (let hop = 0; id && hop < 8; hop++) {
37    if (list.some(w => w.agentId === id)) return id
38    id = map[id]
39  }
40  return undefined
41}
42
43async function refreshStatus($: EngineInterface) {
44  const c = tally(await read($, plan), await read($, workers))
45  if (c.total === 0) return
46  const parts = [`plan ${c.done}/${c.total}`]
47  if (c.running) parts.push(`${c.running} running`)
48  if (c.gate) parts.push(`${c.gate} at gate`)
49  if (c.blocked) parts.push(`${c.blocked} blocked`)
50  $.ui.status(parts.join(' · '))
51}
52
53export const register: Register = on => {
54  on('session.start', async ($, e, next) => {
55    await $.command.register({
56      name: 'plan-progress',
57      description: 'Show execute-plan progress and each entry’s delivery flow in a pane',
58    })
59    return next(e)
60  })
61
62  on('command.run', { command: 'plan-progress' }, async $ => {
63    await $.ui.open({ id: PANE, title: 'Plan progress' })
64    return { text: 'Plan progress pane opened.' }
65  })
66
67  // A pane never fails the call it watches: a hook that throws hands the call on untouched.
68  on('tool.call', async ($, e, next) => {
69    const tool = e.tool as string
70    const args = e as unknown as Record<string, unknown>
71
72    if (isPlanItems(tool)) {
73      const ran = await next(e)
74      const parsed = 'text' in ran ? parsePlan(String(args.planId ?? ''), ran.text) : null
75      if (parsed && parsed.entries.length > 0 && !ran.isError) {
76        // Another plan's items: the workers of the previous one are not this plan's.
77        if ((await read($, plan))?.tag !== parsed.tag) await update($, workers, () => [])
78        await update($, plan, () => parsed)
79        await refreshStatus($)
80      }
81      return ran
82    }
83
84    if (isTransition(tool) && args.id) {
85      const ran = await next(e)
86      if (ran.deny !== undefined || ran.isError) return ran
87      const status = normStatus(args.status)
88      await update($, plan, p =>
89        p ? { ...p, entries: p.entries.map(x => (x.id === args.id ? { ...x, status } : x)) } : p,
90      )
91      await refreshStatus($)
92      return ran
93    }
94
95    const op = surfaceOp(tool)
96    const owner = op ? await rootOf($, e.agentId) : undefined
97    if (!op || !owner) return next(e)
98
99    const ran = await next(e)
100    if (ran.deny !== undefined || ran.isError) return ran
101    // Any report from the worker's loop means it is working again, after a gate answered too.
102    const resume = (w: Worker): Worker => (w.state === 'gate' ? { ...w, state: 'running', note: '' } : w)
103    if (op === 'start_run') {
104      const runId = parseRunId(ran.text)
105      const stages = Array.isArray(args.stages) ? args.stages.map(String) : []
106      await update($, workers, list =>
107        patchWorker(list, owner, w => {
108          // A reattach (resumed:true) keeps the statuses already seen.
109          const same = w.runId === runId && w.stages.length === stages.length
110          return {
111            ...resume(w),
112            flow: String(args.skillId ?? ''),
113            runId,
114            stages,
115            stageStatus: same ? w.stageStatus : stages.map(() => 'pending'),
116            runStatus: 'in_progress',
117          }
118        }),
119      )
120    } else if (op === 'update_stage') {
121      const i = Number(args.stageIndex)
122      const status = String(args.status ?? '')
123      await update($, workers, list =>
124        patchWorker(list, owner, w => {
125          // Another run of the same worker (a second flow) is not this one's stages.
126          if (args.runId !== w.runId || !(i >= 0 && i < w.stages.length)) return w
127          const stageStatus = [...w.stageStatus]
128          stageStatus[i] = status
129          return { ...resume(w), stageStatus }
130        }),
131      )
132    } else {
133      await update($, workers, list =>
134        patchWorker(list, owner, w =>
135          args.runId === w.runId ? { ...w, runStatus: String(args.status ?? '') } : w,
136        ),
137      )
138    }
139    return ran
140  }).catch(($, e, next) => next(e))
141
142  on('agent.spawn', async ($, e, next) => {
143    const ran = await next(e)
144    const agentId = ran.agentId
145    if (!agentId) return ran
146
147    const brief = parseBrief(e.prompt)
148    if (brief) {
149      // A brief for another plan starts the pane over: the previous run's plan and workers go.
150      const isNewPlan = (await read($, plan))?.tag !== brief.tag
151      await update($, workers, list => [
152        ...(isNewPlan ? [] : list.filter(w => w.entryId !== brief.entryId)),
153        newWorker(agentId, brief),
154      ])
155      if (isNewPlan) await update($, parents, () => ({}))
156      await update($, plan, p => (p && p.tag === brief.tag ? p : { tag: brief.tag, entries: [] }))
157      await refreshStatus($)
158    } else if (e.parentAgentId && (await rootOf($, e.parentAgentId))) {
159      const parent = e.parentAgentId
160      await update($, parents, map => ({ ...map, [agentId]: parent }))
161    }
162    return ran
163  }).catch(($, e, next) => next(e))
164
165  on('turn.complete', async ($, e, next) => {
166    const agentId = e.agentId
167    if (agentId && (await read($, workers)).some(w => w.agentId === agentId)) {
168      const back = e.isAborted ? { state: 'stopped' as const, note: 'aborted' } : parseReturn(e.answer)
169      await update($, workers, list => patchWorker(list, agentId, w => ({ ...w, ...back })))
170      await refreshStatus($)
171      const w = (await read($, workers)).find(x => x.agentId === agentId)
172      if (w && back.state === 'gate') $.ui.toast(`${w.n} - ${w.title}: waiting at the gate`)
173      if (w && back.state === 'blocked') $.ui.toast(`${w.n} - ${w.title}: blocked ${back.note}`)
174    }
175    return next(e)
176  })
177
178  // The details: every entry of the plan, one row each, one block per stage of its flow. Every
179  // column is a Box of a set width, so the matrix holds on the desktop's proportional text too.
180  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
181    const { Box, Text } = $.ui.resolve(e)
182    const p = await read($, plan)
183    const list = await read($, workers)
184    const cols = Math.max(30, e.props.bodyColumns ?? e.viewport?.columns ?? 60)
185
186    if (!p && list.length === 0) {
187      return (
188        <Box flexDirection="column">
189          <Text dimColor>No plan running yet.</Text>
190          <Text dimColor>Run /backlog-tools:execute-plan +tag to start one.</Text>
191        </Box>
192      )
193    }
194
195    const c = tally(p, list)
196    const rows = rowsOf(p, list)
197    const stages = columnsOf(rows)
198    const attention = (['gate', 'blocked'] as const).filter(k => c[k] > 0).map(k => `${c[k]} ${KIND[k].label}`)
199    const summary = [`${c.done}/${c.total} done`, ...attention].join(' · ')
200
201    // A surface that draws SVG gets design C as drawn: rounded blocks with gaps between them.
202    if (e.surface !== 'terminal') {
203      const { Svg } = $.ui.resolve(e)
204      return (
205        <Svg
206          source={matrixSvg(p?.tag ?? 'Plan', summary, rows, stages)}
207          alt={`${p?.tag ?? 'Plan'}: ${summary}; ${rows.length} entries by delivery stage`}
208        />
209      )
210    }
211
212    const layout = matrixLayout(cols, stages.length)
213    const notes = rows.filter(r => r.w && r.w.note !== '' && r.w.state !== 'running')
214
215    return (
216      <Box flexDirection="column">
217        <Box flexDirection="row" justifyContent="space-between">
218          <Text bold wrap="truncate-end">{p?.tag ?? 'Plan'}</Text>
219          <Text>{summary}</Text>
220        </Box>
221        <Box height={1} />
222        {layout.hasHeadings && stages.length > 0 && (
223          <Box flexDirection="row">
224            <Box width={2 + layout.titleWidth + 1} />
225            {stages.map((s, i) => (
226              <Box key={`h${i}`} width={layout.cell} marginRight={layout.gap} justifyContent="center">
227                <Text dimColor wrap="truncate-end">{abbrev(s)}</Text>
228              </Box>
229            ))}
230          </Box>
231        )}
232        {rows.map(r => (
233          <Box key={r.id} flexDirection="row">
234            <Box width={2}>
235              <Text color={KIND[r.kind].color}>{KIND[r.kind].mark}</Text>
236            </Box>
237            <Box width={layout.titleWidth} marginRight={1}>
238              <Text wrap="truncate-end">{`${r.n} ${r.title}`}</Text>
239            </Box>
240            {(r.w?.stages ?? []).map((_, i) => {
241              const cell = GLYPH[r.w?.stageStatus[i] ?? 'pending'] ?? GLYPH.pending!
242              return (
243                <Box key={`c${i}`} width={layout.cell} marginRight={layout.gap} justifyContent="center">
244                  <Text color={cell.color} dimColor={cell.dim}>{cell.g}</Text>
245                </Box>
246              )
247            })}
248            {r.kind === 'manual' && <Text dimColor>{r.type}</Text>}
249            {r.w && r.w.stages.length === 0 && r.w.state === 'running' && <Text dimColor>starting…</Text>}
250          </Box>
251        ))}
252        {notes.length > 0 && <Box height={1} />}
253        {notes.map(r => (
254          <Text key={`n${r.id}`} dimColor wrap="truncate-end">
255            <Text color={KIND[r.kind].color}>{KIND[r.kind].mark}</Text> {r.n} {r.w ? currentStage(r.w) || r.w.state : ''}: {r.w?.note ?? ''}
256          </Text>
257        ))}
258      </Box>
259    )
260  })
261
262  // The band above the prompt (design F): the entries still in flow, three columns — the entry,
263  // its stage marks, where it stands — each a Box of a set width so the rows line up. Whatever
264  // the plugins beneath draw there (a flow band of this session's own run) stays, under it.
265  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
266    if (e.props.hasSurvey) return next(e)
267    const p = await read($, plan)
268    const list = await read($, workers)
269    const rows = rowsOf(p, list)
270    const { shown, more } = bandRows(rows, e.props.maxRows)
271    if (shown.length === 0) return next(e)
272
273    const { Box, Text, Button } = $.ui.resolve(e)
274    const c = tally(p, list)
275    const also = alsoLine(rows)
276    const labelWidth = Math.min(32, Math.max(14, Math.floor(e.props.bodyColumns / 3)))
277    const markWidth = Math.max(...shown.map(r => r.w?.stages.length ?? 0)) + 2
278    const band = (
279      <Box flexDirection="column">
280        <Box flexDirection="row" justifyContent="space-between">
281          <Text wrap="truncate-end">
282            <Text bold>execute-plan</Text>  <Text dimColor>{p?.tag ?? ''}</Text>  {c.done}/{c.total} done
283            {also !== '' && <Text dimColor>  also: {also}</Text>}
284          </Text>
285          <Button key="details" label="details" onPress={() => $.ui.open({ id: PANE, title: 'Plan progress' })} />
286        </Box>
287        {shown.map(r => {
288          const stage = r.w ? currentStage(r.w) : ''
289          return (
290            <Box key={r.id} flexDirection="row">
291              <Box width={labelWidth} marginRight={2}>
292                <Text dimColor wrap="truncate-end">{`${r.n} ${r.title}`}</Text>
293              </Box>
294              <Box width={markWidth} flexDirection="row">
295                {cellsOf(r.w).map((cell, i) => (
296                  <Box key={`c${i}`} width={1}>
297                    <Text color={cell.color} dimColor={cell.dim}>{cell.g}</Text>
298                  </Box>
299                ))}
300              </Box>
301              <Text wrap="truncate-end">
302                <Text color={r.kind === 'blocked' ? 'red' : 'yellow'}>{stage || (r.w && r.w.stages.length === 0 ? 'starting…' : '')}</Text>
303                {r.kind === 'gate' && <Text color="magenta"> gate</Text>}
304                {r.kind === 'blocked' && <Text color="red"> blocked</Text>}
305              </Text>
306            </Box>
307          )
308        })}
309        {more > 0 && <Text dimColor>+{more} more in flow — details</Text>}
310      </Box>
311    )
312    const below = await next(e)
313    return below ? (
314      <Box flexDirection="column">
315        {band}
316        {below}
317      </Box>
318    ) : band
319  })
320}
321
hooks/parse.ts 129 lines
1import type { Plan, PlanEntry, Worker, WorkerState } from '../types'
2
3/** get_plan_items on any server whose tool name ends in it. */
4export const isPlanItems = (tool: string) => /^mcp__.*__get_plan_items$/.test(tool)
5export const isTransition = (tool: string) => /^mcp__.*backlog.*__transition$/.test(tool)
6/** start_run/update_stage/finish_run on any delivery surface, bound or bare. */
7export const surfaceOp = (tool: string): 'start_run' | 'update_stage' | 'finish_run' | null => {
8  const m = /^mcp__.*__(start_run|update_stage|finish_run)$/.exec(tool)
9  return m ? (m[1] as 'start_run' | 'update_stage' | 'finish_run') : null
10}
11
12/** `Draft`, `in-progress`, `InProgress` → `draft`, `inprogress`. */
13export const normStatus = (s: unknown) => String(s ?? '').toLowerCase().replace(/[^a-z]/g, '')
14
15const parseJson = (text: string | undefined): unknown => {
16  if (!text) return null
17  try {
18    return JSON.parse(text)
19  } catch {
20    const at = text.indexOf('{')
21    if (at < 0) return null
22    try {
23      return JSON.parse(text.slice(at))
24    } catch {
25      return null
26    }
27  }
28}
29
30/** A stored title carries its step number (`3 - Sort by effort`): the number and the rest. */
31export const splitTitle = (title: string, fallback: number) => {
32  const m = /^(\d+)\s*-\s*(.*)$/.exec(title.trim())
33  return m ? { n: Number(m[1]), title: (m[2] ?? '').trim() } : { n: fallback, title: title.trim() }
34}
35
36/** The PlanItemsPayload: { planId, count, entries: EntryPayload[] }, wire tokens lowercase. */
37export const parsePlan = (tag: string, text: string | undefined): Plan | null => {
38  const body = parseJson(text) as { planId?: string; entries?: Record<string, unknown>[] } | null
39  if (!body || !Array.isArray(body.entries)) return null
40  const entries: PlanEntry[] = []
41  for (const raw of body.entries) {
42    const type = String(raw.type ?? '').toLowerCase()
43    if (type === 'plan') continue
44    const { n, title } = splitTitle(String(raw.title ?? ''), entries.length + 1)
45    entries.push({
46      id: String(raw.id ?? ''),
47      n,
48      title,
49      type,
50      status: normStatus(raw.status),
51    })
52  }
53  return { tag: body.planId ?? tag, entries }
54}
55
56/** The run id start_run answered with, under any of the spellings a surface uses. */
57export const parseRunId = (text: string | undefined): string => {
58  const body = parseJson(text) as Record<string, unknown> | null
59  const id = body?.runId ?? body?.id ?? (body?.run as Record<string, unknown> | undefined)?.id
60  if (id) return String(id)
61  const m = /run[_ ]?id["':\s]+([\w-]+)/i.exec(text ?? '')
62  return m?.[1] ?? ''
63}
64
65/** The brief's opening line: You run one entry of the Backlog plan `<tag>`: `<n> - <Title>` (id `<id>`). */
66export const parseBrief = (prompt: string) => {
67  const m = /entry of the Backlog plan `([^`]+)`: `(\d+)\s*-\s*([^`]+)` \(id `([^`]+)`\)/.exec(prompt)
68  if (!m) return null
69  const { title } = splitTitle(m[3] ?? '', 0)
70  return { tag: m[1] ?? '', n: Number(m[2]), title, entryId: m[4] ?? '' }
71}
72
73/** A sub-agent's one-line return: done-before, gate, pr <url>, blocked <stage>: <reason>. */
74export const parseReturn = (answer: string): { state: WorkerState; note: string } => {
75  const lines = answer.trim().split(/\r?\n/)
76  for (const line of [lines[0] ?? '', ...lines.slice(1).reverse()]) {
77    const l = line.replace(/^[`*\s]+/, '')
78    const m = /^(done-before|gate|pr|blocked)\b[:\s]*(.*)$/i.exec(l)
79    if (m) return { state: (m[1] ?? '').toLowerCase() as WorkerState, note: (m[2] ?? '').trim() }
80  }
81  return { state: 'stopped', note: (lines[0] ?? '').slice(0, 80) }
82}
83
84export const newWorker = (agentId: string, b: NonNullable<ReturnType<typeof parseBrief>>): Worker => ({
85  agentId,
86  entryId: b.entryId,
87  n: b.n,
88  title: b.title,
89  state: 'running',
90  note: '',
91  flow: '',
92  runId: '',
93  stages: [],
94  stageStatus: [],
95  runStatus: '',
96})
97
98/** Progress counted per entry, a worker's state overriding the stored status. */
99export const tally = (plan: Plan | null, workers: Worker[]) => {
100  const byEntry = new Map(workers.map(w => [w.entryId, w]))
101  // A plan read from a file never passes get_plan_items: its workers are all there is.
102  const ids = plan && plan.entries.length > 0 ? plan.entries.map(e => e.id) : workers.map(w => w.entryId)
103  const c = { total: ids.length, done: 0, running: 0, gate: 0, review: 0, blocked: 0, manual: 0, waiting: 0 }
104  for (const id of ids) {
105    const e = plan?.entries.find(x => x.id === id)
106    const w = byEntry.get(id)
107    const k = kindOf(e, w)
108    c[k] += 1
109  }
110  return c
111}
112
113export type Kind = 'done' | 'running' | 'gate' | 'review' | 'blocked' | 'manual' | 'waiting'
114
115export const kindOf = (e: PlanEntry | undefined, w: Worker | undefined): Kind => {
116  if (e && (e.status === 'done' || e.status === 'archived')) return 'done'
117  if (w) {
118    if (w.state === 'running') return 'running'
119    if (w.state === 'gate') return 'gate'
120    if (w.state === 'pr') return 'review'
121    if (w.state === 'done-before') return 'done'
122    // Aborted, or a return that is none of the four: it needs the person as much as a block.
123    if (w.state === 'blocked' || w.state === 'stopped') return 'blocked'
124  }
125  if (e && (e.type === 'task' || e.type === 'test')) return 'manual'
126  if (e?.status === 'inprogress') return 'running'
127  return 'waiting'
128}
129
hooks/view.ts 180 lines
1import type { Plan, PlanEntry, Worker } from '../types'
2import { kindOf } from './parse'
3import type { Kind } from './parse'
4
5// What the pane's matrix and the band draw, worked out apart from drawing them so the tests
6// can hold the layout without a surface.
7
8export type Cell = { g: string; color?: string; dim?: boolean }
9
10/** A stage's mark, in the band language delivery-run-view draws a flow in. */
11export const CELL: Record<string, Cell> = {
12  done: { g: '✓', color: 'green' },
13  in_progress: { g: '●', color: 'yellow' },
14  blocked: { g: '✗', color: 'red' },
15  skipped: { g: '–', dim: true },
16  pending: { g: '○', dim: true },
17}
18
19export const KIND: Record<Kind, { mark: string; color?: string; label: string }> = {
20  done: { mark: '✓', color: 'green', label: 'done' },
21  running: { mark: '▶', color: 'cyan', label: 'running' },
22  gate: { mark: '◆', color: 'yellow', label: 'at gate' },
23  review: { mark: '◇', color: 'magenta', label: 'PR open' },
24  blocked: { mark: '✗', color: 'red', label: 'blocked' },
25  // One cell wide in every terminal, so the matrix columns stay aligned.
26  manual: { mark: '✎', color: 'yellow', label: 'manual' },
27  waiting: { mark: '○', label: 'waiting' },
28}
29
30const SHORT: Record<string, string> = {
31  'update base': 'upd', scope: 'scp', plan: 'pln', implement: 'imp', review: 'rev',
32  'build test': 'b&t', verify: 'ver', 'spec check': 'spc', ready: 'rdy',
33  'personal validation': 'pv', 'create pull request': 'pr', 'create pr': 'pr', 'report back': 'rb',
34  summary: 'sum', drafting: 'dft', 'check review': 'c&r',
35}
36
37/**
38 * A stage's column heading: the delivery phases by their usual short name, whether start_run
39 * named them by title (`Build & Test`) or by id (`phase-build-test`); others cut to 3.
40 */
41export const abbrev = (stage: string) => {
42  const key = stage.trim().toLowerCase().replace(/^phase-/, '').replace(/[-_&]+/g, ' ').replace(/\s+/g, ' ').trim()
43  return SHORT[key] ?? key.replace(/ /g, '').slice(0, 3)
44}
45
46export const cellsOf = (w: Worker | undefined): Cell[] =>
47  w ? w.stages.map((_, i) => CELL[w.stageStatus[i] ?? 'pending'] ?? { g: '○', dim: true }) : []
48
49/** The stage a worker stands at: the one running or blocked, else none. */
50export const currentStage = (w: Worker) => {
51  const i = w.stageStatus.findIndex(s => s === 'in_progress' || s === 'blocked')
52  return i >= 0 ? (w.stages[i] ?? '') : ''
53}
54
55export type Row = { id: string; n: number; title: string; type: string; kind: Kind; w?: Worker; e?: PlanEntry }
56
57/** Every entry of the plan in plan order; without a plan read, the workers. */
58export const rowsOf = (plan: Plan | null, workers: Worker[]): Row[] => {
59  const byEntry = new Map(workers.map(w => [w.entryId, w]))
60  return plan && plan.entries.length > 0
61    ? plan.entries.map(e => ({ id: e.id, n: e.n, title: e.title, type: e.type, kind: kindOf(e, byEntry.get(e.id)), w: byEntry.get(e.id), e }))
62    : workers.map(w => ({ id: w.entryId, n: w.n, title: w.title, type: 'prompt', kind: kindOf(undefined, w), w }))
63}
64
65/** The columns of the matrix: the longest stage list any worker declared. */
66export const columnsOf = (rows: Row[]) =>
67  rows.reduce<string[]>((best, r) => (r.w && r.w.stages.length > best.length ? r.w.stages : best), [])
68
69/** A matrix cell as design C draws it: a block per stage, its mark dark on top. */
70export const BLOCK: Record<string, { bg: string; g: string }> = {
71  done: { bg: '#5fd38d', g: '' },
72  in_progress: { bg: '#56c8e8', g: '▶' },
73  blocked: { bg: '#f2767a', g: '✗' },
74  skipped: { bg: '#3a3a42', g: '–' },
75  pending: { bg: '#26262c', g: '' },
76}
77
78/** The same cell in a terminal, where a block cannot leave a gap: one coloured glyph. */
79export const GLYPH: Record<string, Cell> = {
80  done: { g: '■', color: 'green' },
81  in_progress: { g: '▶', color: 'cyan' },
82  blocked: { g: '✗', color: 'red' },
83  skipped: { g: '–', dim: true },
84  pending: { g: '·', dim: true },
85}
86
87/**
88 * How wide the terminal matrix draws in `cols` cells: a mark and a title column, then one cell
89 * per stage — three wide and a gap, under its short name, when that leaves the title 14 cells,
90 * else two wide and no headings. The title column stops at 36, so a wide pane does not push
91 * the cells away from the names.
92 */
93export const matrixLayout = (cols: number, stageCount: number) => {
94  const wide = cols - 2 - 1 - 14 >= stageCount * 4
95  const cell = wide ? 3 : 2
96  const gap = wide ? 1 : 0
97  const titleWidth = Math.min(36, Math.max(10, cols - 2 - 1 - stageCount * (cell + gap)))
98  return { cell, gap, hasHeadings: wide, titleWidth }
99}
100
101const HEX: Record<string, string> = {
102  green: '#5fd38d', cyan: '#56c8e8', yellow: '#e8c25a', magenta: '#d58be8', red: '#f2767a',
103}
104const xml = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
105const clip = (text: string, max: number) => (text.length <= max ? text : `${text.slice(0, max - 1)}…`)
106
107/**
108 * Design C as one SVG, for a surface that draws one: a dark card with the plan's tag and
109 * counts, the stage headings, a row per entry with a rounded block per stage, and the gate's
110 * review link and each block's reason under it.
111 */
112export const matrixSvg = (tag: string, summary: string, rows: Row[], stages: string[]) => {
113  const pad = 16
114  const labelWidth = 260
115  const cell = 30
116  const gap = 4
117  const rowHeight = 24
118  const notes = rows.filter(r => r.w && r.w.note !== '' && r.w.state !== 'running')
119  const width = pad * 2 + labelWidth + Math.max(stages.length, 4) * (cell + gap)
120  const top = 68
121  const notesTop = top + rows.length * rowHeight + 12
122  const height = notesTop + notes.length * 20 + (notes.length > 0 ? 8 : 0)
123  const out: string[] = [
124    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="JetBrains Mono, ui-monospace, Menlo, Consolas, monospace" font-size="13">`,
125    `<rect width="${width}" height="${height}" rx="8" fill="#121215"/>`,
126    `<text x="${pad}" y="28" fill="#e6e6ea" font-weight="700">${xml(tag)}</text>`,
127    `<text x="${width - pad}" y="28" fill="#e6e6ea" text-anchor="end">${xml(summary)}</text>`,
128  ]
129  const x0 = pad + labelWidth
130  stages.forEach((s, i) => {
131    out.push(`<text x="${x0 + i * (cell + gap) + cell / 2}" y="56" fill="#9a9aa3" font-size="11" text-anchor="middle">${xml(abbrev(s))}</text>`)
132  })
133  rows.forEach((r, j) => {
134    const y = top + j * rowHeight
135    const kind = KIND[r.kind]
136    out.push(`<text x="${pad}" y="${y + 14}" fill="${HEX[kind.color ?? ''] ?? '#9a9aa3'}">${xml(kind.mark)}</text>`)
137    out.push(`<text x="${pad + 20}" y="${y + 14}" fill="#e6e6ea">${xml(clip(`${r.n} ${r.title}`, 32))}</text>`)
138    const w = r.w
139    if (w && w.stages.length > 0) {
140      w.stages.forEach((_, i) => {
141        const b = BLOCK[w.stageStatus[i] ?? 'pending'] ?? BLOCK.pending!
142        const x = x0 + i * (cell + gap)
143        out.push(`<rect x="${x}" y="${y}" width="${cell}" height="18" rx="3" fill="${b.bg}"/>`)
144        if (b.g) out.push(`<text x="${x + cell / 2}" y="${y + 13}" fill="#121215" font-size="11" text-anchor="middle">${xml(b.g)}</text>`)
145      })
146    } else if (r.kind === 'manual' || (w && w.state === 'running')) {
147      out.push(`<text x="${x0}" y="${y + 14}" fill="#9a9aa3">${r.kind === 'manual' ? xml(r.type) : 'starting…'}</text>`)
148    }
149  })
150  notes.forEach((r, j) => {
151    const w = r.w!
152    const kind = KIND[r.kind]
153    out.push(`<text x="${pad}" y="${notesTop + j * 20 + 12}" fill="#9a9aa3" font-size="12"><tspan fill="${HEX[kind.color ?? ''] ?? '#9a9aa3'}">${xml(kind.mark)}</tspan> ${r.n} ${xml(currentStage(w) || w.state)}: ${xml(clip(w.note, 90))}</text>`)
154  })
155  out.push('</svg>')
156  return out.join('')
157}
158
159/** The band's rows: the entries still in flow — running, at a gate, or blocked. */
160export const liveRows = (rows: Row[]) =>
161  rows.filter(r => r.w && (r.kind === 'running' || r.kind === 'gate' || r.kind === 'blocked'))
162
163/** The band's "also": the entries off the band, counted by kind, as `✓ 12 · ◇ 2 · ✎ 1`. */
164export const alsoLine = (rows: Row[]) => {
165  const off = rows.filter(r => liveRows([r]).length === 0)
166  return (['done', 'review', 'running', 'manual', 'waiting'] as const)
167    .map(k => [k, off.filter(r => r.kind === k).length] as const)
168    .filter(([, n]) => n > 0)
169    .map(([k, n]) => `${KIND[k].mark} ${n}`)
170    .join(' · ')
171}
172
173/** The band's live rows within the rows it may take, and how many did not fit. */
174export const bandRows = (rows: Row[], maxRows: number) => {
175  const live = liveRows(rows)
176  // One row for the header, and one for "+n more" when it is needed.
177  const room = Math.max(1, maxRows - 1)
178  return live.length <= room ? { shown: live, more: 0 } : { shown: live.slice(0, room - 1), more: live.length - (room - 1) }
179}
180
types/index.d.ts 39 lines
1/** One entry of the plan, as get_plan_items answered it and transition moved it since. */
2export type PlanEntry = {
3  id: string
4  n: number
5  title: string
6  type: string
7  status: string
8}
9
10export type Plan = { tag: string; entries: PlanEntry[] }
11
12export type WorkerState = 'running' | 'gate' | 'pr' | 'blocked' | 'done-before' | 'stopped'
13
14/** One execute-plan sub-agent and the delivery-flow run it reported. */
15export type Worker = {
16  agentId: string
17  entryId: string
18  n: number
19  title: string
20  state: WorkerState
21  note: string
22  flow: string
23  runId: string
24  stages: string[]
25  stageStatus: string[]
26  runStatus: string
27}
28
29declare module 'claude-code' {
30  interface PluginState {
31    'backlog-tools': {
32      plan: Plan | null
33      workers: Worker[]
34      /** A nested agent (a flow-runner, a phase runner) → the agent that spawned it. */
35      parents: Record<string, string>
36    }
37  }
38}
39