SLOPSHOPPER

roadmap

Roadmaps as a dependency graph: JSON source of truth, mechanical status recompute, projected task lists, Mermaid diagrams, an HTML dashboard, the interview…

newpaneguardcommandtoastprocess
★ 5v2.3.0no licenseupdated 2026-10-08JasonWarrenUK/goblin-mode/marketplace/roadmap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · roadmap
│ ┃ ready ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ⏺ Read(src/auth.ts) │ ┃ │ ✗ The roadmap would not load ⎿ Read 6 lines │ ┃ │ ⏺ Update(src/auth.ts) │ ┃ │ the CLI could not run ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(bun test) │ ┃ │ 1 Check it /roadmap:maintain ⎿ 3 pass, 1 fail │ ┃ │ 2 Then r or refresh │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────────────────────── │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ refresh ] esc or /ready closes │ › /ready │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ready
╭─────────────────────────────────────────────────────╮ │ │ │ ✗ The roadmap would not load │ │ │ │ the CLI could not run │ │ │ │ 1 Check it /roadmap:maintain │ │ 2 Then r or refresh │ │ │ ╰─────────────────────────────────────────────────────╯ [ refresh ] esc or /ready closes
README

Roadmap Plugin

1. Install

claude plugin marketplace add JasonWarrenUK/goblin-mode
claude plugin install roadmap@goblin-mode

Requires python3 (3.8+, stdlib only) on PATH. The /ready pane needs Claude Code 2.1.287 or later and draws in the terminal; the skills work on any version.


2. Skills

2a. Working from the Roadmap

SituationSkill
Choose the next task (one pick)/roadmap:next-suggest
See the whole ready-set/roadmap:next-group

2b. Updating the Roadmap

SituationSkill
Add a task, a chain of tasks or a milestone/roadmap:update-tasks
Tasks need owners, or a dev's load needs handing over/roadmap:update-devs
You are starting a task, or dropping one`/roadmap:claim [<task id>] [<assignee>\release]`

2c. Maintaining the Roadmap

SituationSkill
Work landed / statuses drifted (add reconcile to check against code)/roadmap:maintain
Priorities, freshness or dependency-graph review/roadmap:review
Render the HTML dashboard/roadmap:dashboard

2d. One-Off Utilities

SituationSkill
No roadmap yet/roadmap:create
Half-formed ideas to explore into tasks/roadmap:create-interview
Old single-file format detected/roadmap:migrate

2e. /ready: The Ready Set as a Pane

Opens a pane with the ready set in leverage order, the claims in play and each milestone's progress by release tier, read from the .claude/roadmaps.json above the session's working directory. Running /ready again closes it. It runs inside Claude Code with no turn, so it works while Claude is busy.

KeyDoes
1 to 9Picks a row.
cClaims the picked task: asks who is doing it (a name is never inferred or pre-filled), then runs claim <id> --assignee=<name>. The change is in roadmaps.json; commit it when you are ready.
rRefreshes from the CLI.
Esc, or /readyCloses the pane.

The pane refreshes itself every two minutes while open and whenever a tool call touches roadmaps.json or runs the CLI. It is a tab beside any other pane, /diff included: Ctrl+X Tab moves the keyboard between panes, and only one shows at a time. In a fullscreen terminal of 110 columns or more it docks beside the transcript; narrower, it sits above the prompt. Without a roadmap it says so and names /roadmap:create.


3. Concepts

3a. Claims

A claim says someone has started a task. The task gains a started date, and views show it as in progress while it is todo or blocked (a paused, deferred or finished status wins); its status stays computed, so a claim never changes one.

You rarely claim by hand. When a branch appears (from a git command or a worktree, or one you made just before) or a session starts on a branch that claims nothing, the plugin has Claude ask one question: which ready task this is, who is doing it and whether to push the branch so the team sees the claim. Nothing is written until you answer. To claim or drop a task yourself, run /roadmap:claim: it lists the ready tasks when you give no ID, asks who is doing it and commits the change.

ToRun
Claim by hand (no skill)python3 <plugin-root>/scripts/roadmap.py claim <ID> [--assignee NAME]
Drop a claim (no skill)python3 <plugin-root>/scripts/roadmap.py release <ID> [--unassign]
Stop the question on one branchgit config branch.<name>.roadmapClaim none

The hooks need python3 too; without it they stay silent.


4. Where Do I Find...?

4a. Within a Roadmap Project

FilePathNotes
Roadmap Source of Truth<project>/.claude/roadmaps.jsonevery skill finds it by walking up from the working directory

4b. Within the Plugin

FilePath
Roadmap Conventionsreferences/roadmap-conventions.md
The CLI every skill and the pane runscripts/roadmap.py; python3 -m unittest test_roadmap in that directory runs its tests
The pane's codehooks/register.tsx; claude plugin test <plugin-root> runs its tests
Source 2 files
hooks/register.tsx 460 lines
1// The roadmap pane: `/ready` opens the ready set the CLI computes, in
2// leverage order, with the claims in play and the milestones' progress, and
3// runs again to close it. A digit picks a row; `c` claims it after asking
4// who (an assignee is never inferred), `r` refreshes. The CLI is the
5// plugin's own scripts/roadmap.py, the same file the skills run, so the pane
6// needs python3 and nothing else. Nothing here edits roadmaps.json except
7// through `claim`.
8//
9// Colours are the engine's theme keys, never raw values: the pane follows
10// whichever theme the person runs. A release tier takes one hue everywhere it
11// appears (the tier row, its milestone chips, the task ids drawn from it).
12
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register } from 'claude-code'
15
16import type { Candidate, Claimed, Milestone, Problem, Snapshot } from '../types'
17
18const PANE = 'ready'
19const ROWS = 9
20const BAR_CELLS = 5
21const TIER_LABEL_WIDTH = 11
22
23const snapshot = atom({ plugin: 'roadmap', key: 'snapshot' } as const, null as Snapshot | null)
24const selected = atom({ plugin: 'roadmap', key: 'selected' } as const, null as string | null)
25const asking = atom({ plugin: 'roadmap', key: 'asking' } as const, null as string | null)
26
27// Module-level because the validator holds `$` to top-level functions; only
28// the CLI path lives here, which no drawing reads.
29let cli = ''
30
31type Ui = ReturnType<EngineInterface['ui']['resolve']>
32
33const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
34const str = (v: unknown): string => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '')
35const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
36
37// No cwd: the default is the session's working directory read per call, so a
38// move into a worktree takes the pane with it and the CLI finds that branch's
39// roadmaps.json by walking up from there.
40const run = ($: EngineInterface, args: readonly string[]) => $.process.run(['python3', cli, ...args], { timeoutMs: 15_000 })
41
42const firstLine = (text: string): string => text.trim().split('\n')[0] ?? ''
43
44const lastLine = (text: string): string => text.trim().split('\n').pop() ?? ''
45
46/** What the CLI said when it refused: its own `✗` line goes to stdout; a traceback's last line is the error. */
47export const reasonOf = (ran: { stdout: string; stderr: string }, fallback: string): string => {
48	if (/Traceback \(most recent call last\)/.test(ran.stderr)) return lastLine(ran.stderr) || fallback
49	return firstLine(ran.stdout) || firstLine(ran.stderr) || fallback
50}
51
52const isPaneOpen = async ($: EngineInterface): Promise<boolean> => {
53	try {
54		return (await $.ui.panes()).some(p => p.id === PANE)
55	} catch {
56		return false
57	}
58}
59
60const TIER_NAMES = ['Core', 'Secondary', 'Tertiary', 'Quaternary', 'Quinary']
61const TIER_HUES = ['claude', 'suggestion', 'remember', 'permission'] as const
62
63/** One hue per release tier, cycling past the fourth. */
64export const tierHue = (tier: number): string => TIER_HUES[Math.max(0, tier) % TIER_HUES.length] ?? 'subtle'
65
66/** A fixed-width bar: `███░░` for 60% in five cells. */
67export const bar = (pct: number, cells: number): string => {
68	const filled = Math.round((Math.max(0, Math.min(100, pct)) / 100) * cells)
69	return '█'.repeat(filled) + '░'.repeat(cells - filled)
70}
71
72/** A labelled rule that fills `columns`: `── Ready now ─────────`. */
73export const rule = (label: string, columns: number): string => {
74	const head = `── ${label} `
75	return head + '─'.repeat(Math.max(2, columns - head.length))
76}
77
78/** Milestones grouped by release tier, lowest tier first, each group in roadmap order. */
79export const groupByTier = (milestones: readonly Milestone[]): { tier: number; label: string; milestones: Milestone[] }[] => {
80	const tiers = [...new Set(milestones.map(m => m.tier))].sort((a, b) => a - b)
81	return tiers.map(tier => {
82		const members = milestones.filter(m => m.tier === tier)
83		return { tier, label: members[0]?.tierLabel || TIER_NAMES[tier] || `Tier ${tier + 1}`, milestones: members }
84	})
85}
86
87/** Turn the CLI's two JSON documents into what the pane draws. */
88export const parseSnapshot = (readyText: string, statsText: string, at: number): Snapshot => {
89	const ready: unknown = JSON.parse(readyText)
90	const stats: unknown = JSON.parse(statsText)
91	if (!isRecord(ready) || !isRecord(stats)) throw new Error('not an object')
92	const candidates: Candidate[] = (Array.isArray(ready.candidates) ? ready.candidates : []).filter(isRecord).map(c => ({
93		id: str(c.id),
94		description: str(c.description),
95		milestone: str(c.milestone),
96		milestoneName: str(c.milestoneName),
97		milestoneDonePct: num(c.milestoneDonePct),
98		tier: num(c.tier),
99		tierLabel: str(c.tierLabel),
100		transitiveUnblocks: num(c.transitiveUnblocks),
101		isMilestoneSink: c.isMilestoneSink === true,
102		assignee: str(c.assignee),
103	}))
104	const claimed: Claimed[] = (Array.isArray(ready.claimed) ? ready.claimed : []).filter(isRecord).map(c => ({
105		id: str(c.id),
106		description: str(c.description),
107		assignee: str(c.assignee),
108		started: str(c.started),
109	}))
110	const milestones: Milestone[] = (Array.isArray(stats.milestones) ? stats.milestones : []).filter(isRecord).map(m => ({
111		id: str(m.id),
112		name: str(m.name),
113		donePct: num(m.donePct),
114		tier: num(m.tier),
115		tierLabel: str(m.tierLabel) || TIER_NAMES[num(m.tier)] || '',
116		state: str(m.state),
117	}))
118	const byStatus = isRecord(stats.byStatus) ? stats.byStatus : {}
119	return {
120		ok: true,
121		problem: 'none',
122		reason: '',
123		phase: str(ready.phase) || str(stats.phase),
124		donePct: num(stats.donePct),
125		doneCount: num(byStatus.done),
126		inScope: num(stats.inScope),
127		milestones,
128		candidates,
129		claimed,
130		at,
131	}
132}
133
134const refresh = async ($: EngineInterface): Promise<void> => {
135	const at = await $.clock.now()
136	const failed = (problem: Problem, reason: string): Snapshot => ({
137		ok: false,
138		problem,
139		reason,
140		phase: '',
141		donePct: 0,
142		doneCount: 0,
143		inScope: 0,
144		milestones: [],
145		candidates: [],
146		claimed: [],
147		at,
148	})
149	try {
150		const detect = await run($, ['detect'])
151		if (detect.exitCode === 3) return void (await update($, snapshot, () => failed('legacy', 'old single-file roadmap')))
152		if (detect.exitCode === 2) return void (await update($, snapshot, () => failed('missing', reasonOf(detect, 'no roadmap above this directory'))))
153		if (detect.exitCode !== 0) return void (await update($, snapshot, () => failed('broken', reasonOf(detect, 'the CLI refused'))))
154		const [ready, stats] = await Promise.all([run($, ['ready', '--json']), run($, ['stats', '--json'])])
155		if (ready.exitCode !== 0 || stats.exitCode !== 0) {
156			return void (await update($, snapshot, () => failed('broken', reasonOf(ready.exitCode !== 0 ? ready : stats, 'the CLI refused'))))
157		}
158		const next = parseSnapshot(ready.stdout, stats.stdout, at)
159		await update($, snapshot, () => next)
160	} catch (error) {
161		await update($, snapshot, () => failed('broken', error instanceof Error && /ENOENT|not found/i.test(error.message) ? 'python3 is not on PATH' : 'the CLI could not run'))
162	}
163}
164
165const claim = async ($: EngineInterface, id: string, assignee: string): Promise<string> => {
166	const who = assignee.trim()
167	if (!who) return 'a claim needs a name.'
168	try {
169		const ran = await run($, ['claim', id, `--assignee=${who}`])
170		if (ran.exitCode !== 0) return reasonOf(ran, `claim ${id} refused`)
171		return `claimed ${id} for ${who}. commit roadmaps.json when you are ready.`
172	} catch {
173		return 'the CLI could not run'
174	}
175}
176
177/** One step of the empty state: a numbered line, what it does, the command that does it. */
178const step = (ui: Ui, n: number, what: string, pointer: string) => {
179	const { Box, Text } = ui
180	const [command = '', alt = ''] = pointer.split(' or ')
181	return (
182		<Box key={`step-${n}`} flexDirection="row" columnGap={1} flexWrap="wrap">
183			<Text color="suggestion" bold>
184				{n}
185			</Text>
186			<Text>{what.padEnd(12)}</Text>
187			<Text color="claude" bold>
188				{command}
189			</Text>
190			{alt && <Text dimColor>or {alt}</Text>}
191		</Box>
192	)
193}
194
195/** No roadmap to draw: a framed card that says why and what to do about it. */
196const emptyState = ($: EngineInterface, ui: Ui, snap: Snapshot, width: number) => {
197	const { Box, Text, Button } = ui
198	const frame = snap.problem === 'broken' ? 'error' : 'subtle'
199	const content =
200		snap.problem === 'legacy'
201			? {
202					glyph: '◈',
203					title: 'Old roadmap format',
204					lead: 'This roadmap is a single file. The pane reads the phase-array format.',
205					steps: [step(ui, 1, 'Convert it', '/roadmap:migrate'), step(ui, 2, 'Reopen', '/ready or press r')],
206				}
207			: snap.problem === 'broken'
208				? {
209						glyph: '✗',
210						title: 'The roadmap would not load',
211						lead: snap.reason,
212						steps: [step(ui, 1, 'Check it', '/roadmap:maintain'), step(ui, 2, 'Then', 'r or refresh')],
213					}
214				: {
215						glyph: '◇',
216						title: 'No roadmap in this project',
217						lead: 'Nothing at .claude/roadmaps.json above this directory.',
218						steps: [step(ui, 1, 'Start one', '/roadmap:create'), step(ui, 2, 'Reopen', '/ready or press r')],
219					}
220	return (
221		<Box flexDirection="column" width={width} rowGap={1}>
222			<Box flexDirection="column" borderStyle="round" borderColor={frame} paddingX={2} paddingY={1} rowGap={1}>
223				<Box flexDirection="row" columnGap={1}>
224					<Text color={frame === 'error' ? 'error' : 'claude'} bold>
225						{content.glyph}
226					</Text>
227					<Text bold>{content.title}</Text>
228				</Box>
229				<Text color={snap.problem === 'broken' ? 'error' : undefined} dimColor={snap.problem !== 'broken'}>
230					{content.lead}
231				</Text>
232				{snap.problem === 'missing' && <Text dimColor>A roadmap holds your tasks as a dependency graph. This pane then shows what is unblocked, what it unlocks and who has claimed what.</Text>}
233				<Box flexDirection="column">{content.steps}</Box>
234			</Box>
235			<Box flexDirection="row" columnGap={2}>
236				<Button key="refresh" label="refresh" hotkey="r" onPress={() => refresh($)} />
237				<Text dimColor>esc or /ready closes</Text>
238			</Box>
239		</Box>
240	)
241}
242
243/** The phase line and one row per release tier, each milestone a chip of its own progress. */
244const header = (ui: Ui, snap: Snapshot, inner: number) => {
245	const { Box, Text } = ui
246	const tiers = groupByTier(snap.milestones)
247	const barCells = Math.max(10, Math.min(24, inner - 40))
248	return (
249		<Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} width={inner + 4}>
250			<Box flexDirection="row" columnGap={2} flexWrap="wrap">
251				<Text bold>{snap.phase || 'roadmap'}</Text>
252				<Text color={snap.donePct >= 100 ? 'success' : 'claude'}>{bar(snap.donePct, barCells)}</Text>
253				<Text bold>{snap.donePct}%</Text>
254				{snap.inScope > 0 && (
255					<Text dimColor>
256						{snap.doneCount}/{snap.inScope} tasks
257					</Text>
258				)}
259			</Box>
260			{tiers.length > 0 && <Text dimColor>{'─'.repeat(Math.max(2, inner))}</Text>}
261			{tiers.map(group => {
262				const hue = tierHue(group.tier)
263				const deferred = group.milestones.every(m => m.state === 'deferred')
264				return (
265					<Box key={`tier-${group.tier}`} flexDirection="row" columnGap={1}>
266						<Box width={TIER_LABEL_WIDTH}>
267							<Text color={hue} bold dimColor={deferred}>
268								{`${group.tier === 0 ? '●' : '○'} ${group.label}`}
269							</Text>
270						</Box>
271						<Box flexDirection="row" columnGap={2} flexWrap="wrap" flexGrow={1}>
272							{group.milestones.map(m => (
273								<Box key={`ms-${m.id}`} flexDirection="row" columnGap={1}>
274									<Text color={hue} dimColor={m.state === 'deferred'} bold={m.donePct < 100 && m.donePct > 0}>
275										{m.id}
276									</Text>
277									{m.donePct >= 100 ? (
278										<Text color="success">✓</Text>
279									) : (
280										<Text color={m.donePct > 0 ? hue : 'subtle'} dimColor={m.donePct === 0}>
281											{bar(m.donePct, BAR_CELLS)} {m.donePct}%
282										</Text>
283									)}
284								</Box>
285							))}
286						</Box>
287					</Box>
288				)
289			})}
290		</Box>
291	)
292}
293
294/** One ready task: hotkey and id on the first line with the description, the leverage signals beneath. */
295const taskRow = ($: EngineInterface, ui: Ui, c: Candidate, i: number, picked: string | null, inner: number) => {
296	const { Box, Text, Button } = ui
297	const hue = tierHue(c.tier)
298	const isPicked = picked === c.id
299	const lead = 3 + 3 + c.id.length + 1
300	return (
301		<Box key={`task-${c.id}`} flexDirection="column">
302			<Box flexDirection="row" columnGap={1}>
303				<Text color={hue} bold>
304					{isPicked ? '▶' : ' '}
305				</Text>
306				<Button
307					key={`row-${c.id}`}
308					plain
309					hotkey={String(i + 1)}
310					dimColor={picked !== null && !isPicked}
311					label={c.id}
312					onPress={async () => {
313						await update($, selected, () => c.id)
314						await update($, asking, () => null)
315					}}
316				/>
317				<Box width={Math.max(10, inner - lead)}>
318					<Text bold={isPicked} dimColor={picked !== null && !isPicked} wrap={isPicked ? 'wrap' : 'truncate-end'}>
319						{c.description}
320					</Text>
321				</Box>
322			</Box>
323			<Box flexDirection="row" flexWrap="wrap" paddingLeft={5} columnGap={1}>
324				<Text dimColor>↳</Text>
325				<Text color={hue}>unblocks {c.transitiveUnblocks}</Text>
326				<Text dimColor>·</Text>
327				<Text dimColor>
328					{c.milestone} {c.milestoneName ? `${c.milestoneName} ` : ''}
329					{c.milestoneDonePct}%
330				</Text>
331				{c.isMilestoneSink && <Text color="success">completes it</Text>}
332				{c.assignee && <Text color="warning">● {c.assignee}</Text>}
333			</Box>
334		</Box>
335	)
336}
337
338/** Claims in play: who holds which task, and since when. */
339const claimedRows = (ui: Ui, claimed: readonly Claimed[], inner: number) => {
340	const { Box, Text } = ui
341	return claimed.map(c => (
342		<Box key={`claimed-${c.id}`} flexDirection="row" columnGap={1}>
343			<Text color="warning">●</Text>
344			<Text bold>{c.id}</Text>
345			<Text color="warning">{c.assignee || 'someone'}</Text>
346			<Text dimColor>since {c.started || '?'}</Text>
347			<Box width={Math.max(8, inner - c.id.length - (c.assignee || 'someone').length - (c.started || '?').length - 14)}>
348				<Text dimColor wrap="truncate-end">
349					{c.description}
350				</Text>
351			</Box>
352		</Box>
353	))
354}
355
356export const register: Register = on => {
357	on('session.start', async ($, e, next) => {
358		cli = `${$.plugin.root}/scripts/roadmap.py`
359		try {
360			await $.command.register({
361				name: 'ready',
362				description: 'The roadmap ready set in a pane, or closes it: a digit picks, c claims, r refreshes',
363				immediate: true,
364			})
365		} catch {
366			// the name is taken; nothing else to do
367		}
368		// Only while the pane is actually placed: an unplaced or closed pane spawns nothing.
369		$.clock.every(120_000, () => {
370			void isPaneOpen($).then(open => {
371				if (open) void refresh($)
372			})
373		})
374		return next(e)
375	})
376
377	// Open or close: the pane is a tab beside any other, /diff included, and
378	// the same command takes it down again.
379	on('command.run', { command: 'ready' }, async $ => {
380		if (await isPaneOpen($)) {
381			await $.ui.close({ id: PANE })
382			return {}
383		}
384		await refresh($)
385		await $.ui.open({ id: PANE, title: 'ready', focus: true, closeOnEscape: true, rows: 16 })
386		return {}
387	})
388
389	// A change to the roadmap by any route redraws the pane while it is open.
390	on('tool.call', async ($, e, next) => {
391		const ran = await next(e)
392		if (e.agentId) return ran
393		const touched =
394			((e.tool === 'Edit' || e.tool === 'Write') && /roadmaps\.json$/.test(e.file_path)) ||
395			(e.tool === 'Bash' && /roadmap\.py|roadmaps\.json/.test(e.command))
396		if (touched && (await isPaneOpen($))) await refresh($)
397		return ran
398	})
399
400	on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
401		if (e.surface !== 'terminal') return next(e)
402		const ui = $.ui.resolve(e)
403		const { Box, Text, Button, Input } = ui
404		const [snap, picked, ask] = await Promise.all([read($, snapshot), read($, selected), read($, asking)])
405		if (!snap) return <Text dimColor>reading the roadmap…</Text>
406		const width = Math.max(40, e.props.bodyColumns)
407		if (!snap.ok) return emptyState($, ui, snap, width - 1)
408		// the header's frame and padding take four columns; every line inside it shares the measure
409		const inner = width - 5
410		const rows = snap.candidates.slice(0, ROWS)
411		const current = rows.find(c => c.id === picked) ?? null
412		return (
413			<Box flexDirection="column" width={width} rowGap={1}>
414				{header(ui, snap, inner)}
415				<Box flexDirection="column" paddingX={1}>
416					<Text color="subtle">{rule(`Ready now · ${snap.candidates.length}`, inner)}</Text>
417					{rows.length === 0 && <Text dimColor>nothing is ready. a blocker or a gate holds everything; /roadmap:review names it.</Text>}
418					<Box flexDirection="column" rowGap={1}>
419						{rows.map((c, i) => taskRow($, ui, c, i, picked, inner))}
420					</Box>
421					{snap.candidates.length > ROWS && <Text dimColor>+{snap.candidates.length - ROWS} more below the fold</Text>}
422				</Box>
423				{snap.claimed.length > 0 && (
424					<Box flexDirection="column" paddingX={1}>
425						<Text color="subtle">{rule(`In play · ${snap.claimed.length}`, inner)}</Text>
426						{claimedRows(ui, snap.claimed, inner)}
427					</Box>
428				)}
429				<Box flexDirection="column" paddingX={1}>
430					{ask === null && (
431						<Box flexDirection="row" columnGap={2}>
432							{current && <Button key="claim" label="claim" hotkey="c" variant="primary" onPress={() => update($, asking, () => current.id)} />}
433							<Button key="refresh" label="refresh" hotkey="r" onPress={() => refresh($)} />
434						</Box>
435					)}
436					{current && ask === current.id && (
437						<Input
438							key="assignee"
439							label={`who is doing ${current.id}`}
440							placeholder="a name; never inferred, never pre-filled"
441							submitLabel="claim"
442							autoFocus
443							onSubmit={async value => {
444								if (!value.trim()) {
445									$.ui.toast('a claim needs a name.')
446									return
447								}
448								$.ui.toast(await claim($, current.id, value))
449								await update($, asking, () => null)
450								await refresh($)
451							}}
452						/>
453					)}
454					<Text dimColor>1-9 pick · c claim · r refresh · esc or /ready closes</Text>
455				</Box>
456			</Box>
457		)
458	})
459}
460
types/index.d.ts 60 lines
1// The state contract for the roadmap plugin's pane mod: the pane draws from a
2// snapshot the CLI produced, the row the person picked and the task that is
3// waiting for an assignee.
4
5export type Candidate = {
6	id: string
7	description: string
8	milestone: string
9	milestoneName: string
10	milestoneDonePct: number
11	tier: number
12	tierLabel: string
13	transitiveUnblocks: number
14	isMilestoneSink: boolean
15	assignee: string
16}
17
18export type Claimed = {
19	id: string
20	description: string
21	assignee: string
22	started: string
23}
24
25export type Milestone = {
26	id: string
27	name: string
28	donePct: number
29	tier: number
30	tierLabel: string
31	state: string
32}
33
34/** Why the pane has no roadmap to draw: none above the directory, the old single-file format, or a CLI that refused or could not run. */
35export type Problem = 'none' | 'missing' | 'legacy' | 'broken'
36
37export type Snapshot = {
38	ok: boolean
39	problem: Problem
40	reason: string
41	phase: string
42	donePct: number
43	doneCount: number
44	inScope: number
45	milestones: Milestone[]
46	candidates: Candidate[]
47	claimed: Claimed[]
48	at: number
49}
50
51declare module 'claude-code' {
52	interface PluginState {
53		'roadmap': {
54			snapshot: Snapshot | null
55			selected: string | null
56			asking: string | null
57		}
58	}
59}
60