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…

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.
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:
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./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 shows | Read from |
|---|---|
| The entries and their statuses | get_plan_items and transition, on any MCP server |
| Which sub-agent runs which entry | The 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 stages | start_run, update_stage and finish_run on any delivery surface, from the sub-agent's loop or one it spawned |
| How the entry ended | The 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 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:
| Route | Stable id per task | Completed flag | List name | Who sees the data |
|---|---|---|---|---|
Microsoft Graph, read with Microsoft's Microsoft.Graph.Authentication PowerShell module (chosen) | yes: the todoTask id | yes: status | yes: the enclosing list | only Microsoft and the person's machine; the scope is read-only Tasks.Read |
| Graph Explorer, copying responses by hand | yes | yes | yes, one list per query | only 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 not | yes, with --completed | yes | the 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 documented | not documented | yes | a third-party site that reads every task with Tasks.Read |
| To Do Vo Do (browser app) | yes: its CSV carries id | yes | not documented for its CSV | a 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) | no | yes | the Outlook folder | only 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.
Claude Code, inside a session started at the repository root:
/plugin marketplace add .
/plugin install backlog-tools@jsdotnet-backloghooks/register.tsx 321 lines1import { 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}
321hooks/parse.ts 129 lines1import 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}
129hooks/view.ts 180 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
180types/index.d.ts 39 lines1/** 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