Experimental mod: a roadmap. The agent records the sprints it planned from the lots the person asked it to take into account, with their tickets and what will…

Experimental. A Claude Code mod — function hooks, not a skill module — that keeps the roadmap of the sprints the agent planned, with what will really be testable at the end of each.
Specified in features/business/session-mods/ (section scrumia-roadmap); where it lives is ADR-0026.
A person steering several lots of tickets wants to know what they will be able to try, and when, without rebuilding it from the board and the conversation. A lot is whatever the person asks the session to take into account: a milestone, a list of ticket numbers, a column, or something else. The agent resolves it, plans it as sprints and records them here with their source; the person reads them in a pane.
| Piece | Role |
|---|---|
plan_sprint tool | The agent records or updates one sprint by name: order (integer), source (kind milestone, tickets, column or other, and a reference), objective, testableAtEnd (texts a person can try), nothingTestable (true when that list is empty), state (planned, running, done) and tickets (number, title, description, status, a column of the tracker or Unknown). The mod stamps resolvedAt and the session id itself. |
/roadmap and its pane | An All / Planned / Running / Done filter with counts, then the sprints in order, each a bordered card: a coloured state badge, its order and name, progress done/total, its source, resolved <when>, a possibly stale mark and a state mark when needed, the objective, TESTABLE AT THE END, its tickets (number, title, status in colour, description as Markdown) and a Remove button. |
$.store | One entry per project root, project:<root>: the sprints. |
The statuses are the columns of scrumia-github-project, read through scrumia-extends when the session starts (the six default columns plus Unknown, and a line in the pane saying so, when they cannot be read; a failed read is read again). Progress counts the tickets in the done column: board.flow.done, else the last column; any case. Status colours: the done column green, Unknown red, the columns after the first and before the done column yellow, the others dim.
order of another is refused too: the order is unique. Nothing is stored, so the pane never holds a sprint it cannot draw.testableAtEnd needs nothingTestable: true, which the pane shows in words; nothingTestable: true beside a non-empty list is refused as contradictory.possibly stale.hooks/hooks.json naming modules). Older versions ignore the plugin without saying so.scrumia-extends. The module scrumia-github-project is optional: without it the default columns apply. Ticket statuses shown are the ones last recorded.| What | Role |
|---|---|
hooks/register.tsx | The tool, the command and the pane, all in one module. |
hooks/register.test.ts | Tests: upsert in place, each refusal case, the nothing-testable statement, parallel calls, the clock stamp, the stale mark under a changing session id, the tracker's columns and the done column, the unique order, the state mark, isolation between two project roots, a sprint stored in an older shape, the order, and on two surfaces the cards, the filter and Remove. |
What a later version must keep reading or announce in the changelog: the plugin name scrumia-roadmap; the tool name mcp__scrumia-roadmap__plan_sprint and its inputSchema; the /roadmap command; the shape stored under project:<root> in $.store (sprints, each with name, order, source, objective, testableAtEnd, nothingTestable, state, tickets, resolvedAt).
/plugin install scrumia-roadmap --marketplace tibs245/scrumia
For development, from a checkout of the repository:
claude --plugin-dir plugins/scrumia-roadmap
possibly stale only says the sprint was not recorded under this session id. The filter is per process and starts again after a reload.testableAtEnd, not its truth: it cannot judge whether a line is something a person can really try. That stays with the agent and the person who reads the card.Remove.claude plugin validate plugins/scrumia-roadmap
claude plugin test plugins/scrumia-roadmaphooks/register.tsx 454 lines1import type { EngineInterface, Register } from 'claude-code'
2
3type SourceKind = 'milestone' | 'tickets' | 'column' | 'other'
4
5type SprintState = 'planned' | 'running' | 'done'
6
7type Ticket = { number: number; title: string; description: string; status: string }
8
9type Sprint = {
10 name: string
11 order: number
12 source?: { kind: SourceKind; reference: string }
13 objective: string
14 testableAtEnd?: string[]
15 nothingTestable?: boolean
16 state?: SprintState
17 tickets?: Ticket[]
18 resolvedAt?: number
19 recordedIn?: string
20}
21
22type Project = { sprints: Sprint[] }
23
24type Filter = 'all' | SprintState
25
26type Vocabulary = { statuses: string[]; done: string; isFromTracker: boolean; note?: string }
27
28const PANE = 'roadmap'
29const PLAN_TOOL = 'mcp__scrumia-roadmap__plan_sprint'
30const KINDS: SourceKind[] = ['milestone', 'tickets', 'column', 'other']
31const STATES: SprintState[] = ['planned', 'running', 'done']
32const TRACKER_MODULE = 'scrumia-github-project'
33const UNKNOWN = 'Unknown'
34const DEFAULT_COLUMNS = ['Backlog', 'Ready for dev', 'To dev', 'In progress', 'In review', 'Done']
35const READ_TIMEOUT_MS = 10_000
36
37let queue: Promise<unknown> = Promise.resolve()
38let vocabulary: Promise<Vocabulary> | undefined
39let projectDirectory: string | undefined
40const view: { filter: Filter } = { filter: 'all' }
41
42const defaults = (note?: string): Vocabulary => ({
43 statuses: [...DEFAULT_COLUMNS, UNKNOWN],
44 done: 'Done',
45 isFromTracker: false,
46 note,
47})
48
49const isText = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''
50const isRecord = (value: unknown): value is Record<string, unknown> =>
51 typeof value === 'object' && value !== null && !Array.isArray(value)
52
53const stateOf = (one: Sprint): SprintState => (one.state !== undefined && STATES.includes(one.state) ? one.state : 'planned')
54const ticketsOf = (one: Sprint): Ticket[] => (Array.isArray(one.tickets) ? one.tickets : [])
55const same = (first: string, second: string) => String(first).trim().toLowerCase() === String(second).trim().toLowerCase()
56const isTicketDone = (one: Ticket, known: Vocabulary) => same(one.status, known.done)
57const statusColor = (one: Ticket, known: Vocabulary) => {
58 if (isTicketDone(one, known)) {
59 return 'success'
60 }
61 if (same(one.status, UNKNOWN)) {
62 return 'error'
63 }
64 const columns = known.statuses.filter(column => column !== UNKNOWN)
65 const place = columns.findIndex(column => same(column, one.status))
66 const closing = columns.findIndex(column => same(column, known.done))
67
68 return place > 0 && place < closing ? 'warning' : 'subtle'
69}
70const statusOf = (given: string, known: Vocabulary) => known.statuses.find(one => same(one, given))
71
72async function readSettings($: EngineInterface, cwd: string | undefined, args: string[]) {
73 const ran = await $.process.run(['scrumia-extends', ...args], { cwd, timeoutMs: READ_TIMEOUT_MS })
74 if (ran.exitCode !== 0) {
75 throw new Error(`scrumia-extends ${args[0]} exited with ${ran.exitCode}`)
76 }
77
78 return JSON.parse(ran.stdout) as unknown
79}
80
81async function readVocabulary($: EngineInterface, cwd: string | undefined): Promise<Vocabulary> {
82 try {
83 const modules = (await readSettings($, cwd, ['--modules', '--json'])) as { key: string; module: string; state?: string }[]
84 const tracker = modules.find(one => one.module === TRACKER_MODULE && one.state !== 'absent')
85 if (tracker === undefined) {
86 return defaults(`no ${TRACKER_MODULE} module is declared`)
87 }
88 const settings = (await readSettings($, cwd, ['--settings', tracker.key])) as {
89 columns?: unknown
90 board?: { options?: Record<string, unknown>; flow?: { done?: unknown } }
91 }
92 const columns = Array.isArray(settings.columns) ? settings.columns : Object.keys(settings.board?.options ?? {})
93 if (columns.length === 0 || !columns.every(isText)) {
94 return defaults('the tracker declares no columns')
95 }
96 const named = columns as string[]
97 const done = settings.board?.flow?.done
98
99 return {
100 statuses: named.includes(UNKNOWN) ? named : [...named, UNKNOWN],
101 done: isText(done) ? done : named[named.length - 1]!,
102 isFromTracker: true,
103 }
104 } catch (failure) {
105 return defaults(failure instanceof Error ? failure.message : 'the tracker settings could not be read')
106 }
107}
108
109function vocabularyOf($: EngineInterface, cwd?: string): Promise<Vocabulary> {
110 if (vocabulary === undefined) {
111 const reading = readVocabulary($, cwd ?? projectDirectory)
112 vocabulary = reading
113 // a failed read serves the defaults for this call only, so the next call reads again
114 void reading.then(known => {
115 if (!known.isFromTracker && vocabulary === reading) {
116 vocabulary = undefined
117 }
118 })
119 }
120
121 return vocabulary
122}
123
124const notDoneCount = (one: Sprint, known: Vocabulary) => ticketsOf(one).filter(ticket => !isTicketDone(ticket, known)).length
125function mismatchOf(one: Sprint, known: Vocabulary): string | undefined {
126 const total = ticketsOf(one).length
127 const open = notDoneCount(one, known)
128 if (stateOf(one) === 'done' && open > 0) {
129 return `state done, ${open} ticket(s) not done`
130 }
131 if (stateOf(one) === 'planned' && total > 0 && open === 0) {
132 return `state planned, all ${total} ticket(s) done`
133 }
134
135 return undefined
136}
137const stateColor = (state: SprintState) => (state === 'done' ? 'success' : state === 'running' ? 'warning' : 'suggestion')
138const byOrder = (first: Sprint, second: Sprint) => first.order - second.order || first.name.localeCompare(second.name)
139const whenText = (at: number | undefined) =>
140 at === undefined ? 'at an unknown time' : `${new Date(at).toISOString().slice(0, 16).replace('T', ' ')} UTC`
141
142function problemWith(input: Record<string, unknown>, known: Vocabulary): string | undefined {
143 const { name, order, source, objective, testableAtEnd, nothingTestable, state, tickets } = input
144 if (!isText(name)) {
145 return 'name must be non-empty text'
146 }
147 if (typeof order !== 'number' || !Number.isInteger(order)) {
148 return 'order must be an integer'
149 }
150 if (!isRecord(source) || !KINDS.includes(source.kind as SourceKind) || !isText(source.reference)) {
151 return `source must be { kind: ${KINDS.join('|')}, reference: non-empty text }`
152 }
153 if (!isText(objective)) {
154 return 'objective must be non-empty text'
155 }
156 if (!Array.isArray(testableAtEnd) || !testableAtEnd.every(isText)) {
157 return 'testableAtEnd must be an array of non-empty texts a person can try'
158 }
159 if (nothingTestable !== undefined && typeof nothingTestable !== 'boolean') {
160 return 'nothingTestable must be true or false'
161 }
162 if (testableAtEnd.length === 0 && nothingTestable !== true) {
163 return 'testableAtEnd is empty: list what a person can try at the end, or say nothingTestable: true'
164 }
165 if (testableAtEnd.length > 0 && nothingTestable === true) {
166 return 'nothingTestable: true contradicts a non-empty testableAtEnd'
167 }
168 if (!STATES.includes(state as SprintState)) {
169 return `state must be one of ${STATES.join(', ')}`
170 }
171 if (!Array.isArray(tickets) || tickets.length === 0) {
172 return 'tickets must be a non-empty array of { number, title, description, status }'
173 }
174 const numbers = new Set<number>()
175 for (const ticket of tickets) {
176 if (
177 !isRecord(ticket) ||
178 typeof ticket.number !== 'number' ||
179 !Number.isInteger(ticket.number) ||
180 ticket.number <= 0 ||
181 !isText(ticket.title) ||
182 !isText(ticket.description) ||
183 !isText(ticket.status)
184 ) {
185 return 'every ticket needs a positive integer number and non-empty title, description and status'
186 }
187 if (statusOf(ticket.status as string, known) === undefined) {
188 return `status of ticket #${ticket.number} must be one of: ${known.statuses.join(', ')}`
189 }
190 if (numbers.has(ticket.number)) {
191 return `ticket #${ticket.number} appears twice in the sprint`
192 }
193 numbers.add(ticket.number)
194 }
195
196 return undefined
197}
198
199const keyOf = (root: string) => `project:${root}`
200
201async function load($: EngineInterface): Promise<Project> {
202 const stored = (await $.store.get(keyOf(await $.session.root()))) as Project | undefined
203
204 return { sprints: Array.isArray(stored?.sprints) ? stored!.sprints : [] }
205}
206
207function change($: EngineInterface, edit: (project: Project) => Project): Promise<Project> {
208 const run = queue.then(async () => {
209 const key = keyOf(await $.session.root())
210 const stored = (await $.store.get(key)) as Project | undefined
211 const next = edit({ sprints: Array.isArray(stored?.sprints) ? stored!.sprints : [] })
212 await $.store.set(key, next)
213 $.ui.invalidate('ui.render')
214
215 return next
216 })
217 queue = run.catch(() => undefined)
218
219 return run
220}
221
222export const register: Register = on => {
223 queue = Promise.resolve()
224 vocabulary = undefined
225 projectDirectory = undefined
226
227 on('session.start', async ($, e, next) => {
228 projectDirectory = e.cwd
229 const known = await vocabularyOf($, e.cwd)
230 await $.command.register({
231 name: 'roadmap',
232 description: 'Open the roadmap of sprints the agent planned',
233 })
234 await $.tool.register({
235 name: 'plan_sprint',
236 isDeferred: false,
237 description:
238 'Record or update, by name, one sprint of the roadmap. A lot is whatever the person asked you to take into account (a milestone, a list of ticket numbers, a column, something else): resolve it yourself and give its source. Give the sprint order, objective, what a person can really try at its end (testableAtEnd; if nothing, an empty list with nothingTestable: true), its state (planned, running, done) and its tickets with number, title, description and board status. The call is refused when a field is missing or malformed: read the reason and call again. The mod reads no ticket state, only the tracker\'s list of columns: it shows only what you record.',
239 inputSchema: {
240 type: 'object',
241 properties: {
242 name: { type: 'string', description: 'Unique name of the sprint; recording the same name replaces it' },
243 order: { type: 'integer', description: 'Position in the roadmap, ascending' },
244 source: {
245 type: 'object',
246 properties: {
247 kind: { type: 'string', enum: KINDS },
248 reference: { type: 'string', description: 'The milestone, ticket numbers, column or other reference' },
249 },
250 required: ['kind', 'reference'],
251 },
252 objective: { type: 'string' },
253 testableAtEnd: {
254 type: 'array',
255 items: { type: 'string' },
256 description: 'What a person can really try at the end of the sprint, not what is merely implemented',
257 },
258 nothingTestable: {
259 type: 'boolean',
260 description: 'true, with an empty testableAtEnd, when nothing can be tried at the end',
261 },
262 state: { type: 'string', enum: STATES },
263 tickets: {
264 type: 'array',
265 items: {
266 type: 'object',
267 properties: {
268 number: { type: 'integer' },
269 title: { type: 'string' },
270 description: { type: 'string' },
271 status: { type: 'string', enum: known.statuses, description: 'Its column on the board, or Unknown' },
272 },
273 required: ['number', 'title', 'description', 'status'],
274 },
275 },
276 },
277 required: ['name', 'order', 'source', 'objective', 'testableAtEnd', 'state', 'tickets'],
278 },
279 })
280
281 return next(e)
282 })
283
284 on('tool.call', { tool: PLAN_TOOL }, async ($, e) => {
285 const input = e as unknown as Record<string, unknown>
286 const known = await vocabularyOf($)
287 const problem = problemWith(input, known)
288 if (problem !== undefined) {
289 return { deny: `scrumia-roadmap: ${problem}` }
290 }
291 const planned = input as unknown as Sprint
292 const resolvedAt = await $.clock.now()
293 const recordedIn = await $.session.id()
294 let isReplaced = false
295 let clash: string | undefined
296 await change($, project => {
297 clash = project.sprints.find(one => one.order === planned.order && one.name !== planned.name)?.name
298 if (clash !== undefined) {
299 return project
300 }
301 isReplaced = project.sprints.some(one => one.name === planned.name)
302 const sprint: Sprint = {
303 name: planned.name,
304 order: planned.order,
305 source: planned.source,
306 objective: planned.objective,
307 testableAtEnd: planned.testableAtEnd,
308 nothingTestable: planned.nothingTestable === true,
309 state: planned.state,
310 tickets: planned.tickets.map(ticket => ({ ...ticket, status: statusOf(ticket.status, known)! })),
311 resolvedAt,
312 recordedIn,
313 }
314
315 return {
316 sprints: isReplaced
317 ? project.sprints.map(one => (one.name === planned.name ? sprint : one))
318 : [...project.sprints, sprint],
319 }
320 })
321 if (clash !== undefined) {
322 return { deny: `scrumia-roadmap: order ${planned.order} is already taken by the sprint "${clash}"; the order is unique per project, choose another or record under that name` }
323 }
324 $.ui.toast(`Sprint ${planned.name} ${isReplaced ? 'updated' : 'recorded'}`)
325
326 return {
327 result: `Sprint "${planned.name}" ${isReplaced ? 'updated' : 'recorded'} at order ${planned.order} (${planned.tickets.length} tickets, ${planned.state}).`,
328 }
329 })
330
331 on('command.run', { command: 'roadmap' }, async $ => {
332 await $.ui.open({ id: PANE, title: 'Roadmap' })
333
334 return { text: 'Roadmap pane opened.' }
335 })
336
337 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
338 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
339 const known = await vocabularyOf($)
340 const sessionId = await $.session.id()
341 const everything = (await load($)).sprints.slice().sort(byOrder)
342 const filter = view.filter
343 const shown = everything.filter(one => filter === 'all' || stateOf(one) === filter)
344 const show = () => $.ui.invalidate('ui.render')
345 const filters: { value: Filter; label: string; count: number }[] = [
346 { value: 'all', label: 'All', count: everything.length },
347 ...STATES.map(state => ({
348 value: state,
349 label: state[0]!.toUpperCase() + state.slice(1),
350 count: everything.filter(one => stateOf(one) === state).length,
351 })),
352 ]
353
354 return (
355 <Box flexDirection="column" gap={1}>
356 <Box gap={2}>
357 <Text bold>Roadmap</Text>
358 {filters.map(one => (
359 <Button
360 key={`filter-${one.value}`}
361 onPress={() => {
362 view.filter = one.value
363 show()
364 }}
365 >
366 <Text bold={filter === one.value} inverse={filter === one.value} dimColor={filter !== one.value}>
367 {' '}
368 {one.label} {one.count}{' '}
369 </Text>
370 </Button>
371 ))}
372 </Box>
373 {!known.isFromTracker && <Text dimColor>Default columns in use: {known.note ?? 'the tracker could not be read'}.</Text>}
374 {shown.length === 0 && <Text dimColor>No sprint here. The agent records them with plan_sprint.</Text>}
375 {shown.map(sprint => {
376 const state = stateOf(sprint)
377 const tickets = ticketsOf(sprint)
378 const doneCount = tickets.filter(one => isTicketDone(one, known)).length
379 const testable = Array.isArray(sprint.testableAtEnd) ? sprint.testableAtEnd : []
380 const isStale = sprint.recordedIn !== sessionId
381 const mismatch = mismatchOf(sprint, known)
382
383 return (
384 <Box
385 key={`sprint-${sprint.name}`}
386 flexDirection="column"
387 gap={1}
388 padding={1}
389 borderStyle="round"
390 borderColor={stateColor(state)}
391 >
392 <Box gap={2}>
393 <Text color={stateColor(state)} bold>
394 {state === 'done' ? '✓ DONE' : state === 'running' ? '● RUNNING' : '○ PLANNED'}
395 </Text>
396 <Text bold>
397 {sprint.order}. {sprint.name}
398 </Text>
399 <Text dimColor>
400 {doneCount}/{tickets.length} done
401 </Text>
402 <Button key={`remove-${sprint.name}`} label="Remove" onPress={() => change($, project => ({
403 sprints: project.sprints.filter(one => one.name !== sprint.name),
404 }))} />
405 </Box>
406 <Box gap={2}>
407 <Text dimColor>
408 {sprint.source ? `${sprint.source.kind}: ${sprint.source.reference}` : 'source not recorded'}
409 </Text>
410 <Text dimColor>resolved {whenText(sprint.resolvedAt)}</Text>
411 {isStale && <Text color="warning">possibly stale</Text>}
412 {mismatch !== undefined && <Text color="warning">{mismatch}</Text>}
413 </Box>
414 <Box flexDirection="column">
415 <Text color="subtle" bold>
416 OBJECTIVE
417 </Text>
418 <Markdown text={sprint.objective} />
419 </Box>
420 <Box flexDirection="column">
421 <Text color="subtle" bold>
422 TESTABLE AT THE END
423 </Text>
424 {testable.length > 0 ? (
425 testable.map((item, index) => <Text key={`testable-${sprint.name}-${index}`}>- {item}</Text>)
426 ) : (
427 <Text dimColor>{sprint.nothingTestable === true ? 'Nothing testable at the end of this sprint.' : 'Not recorded.'}</Text>
428 )}
429 </Box>
430 <Box flexDirection="column" gap={1}>
431 <Text color="subtle" bold>
432 TICKETS
433 </Text>
434 {tickets.map(ticket => (
435 <Box key={`ticket-${sprint.name}-${ticket.number}`} flexDirection="column">
436 <Box gap={2}>
437 <Text bold>#{ticket.number}</Text>
438 <Text>{ticket.title}</Text>
439 <Text color={statusColor(ticket, known)} bold>
440 {ticket.status}
441 </Text>
442 </Box>
443 <Markdown text={ticket.description} />
444 </Box>
445 ))}
446 </Box>
447 </Box>
448 )
449 })}
450 </Box>
451 )
452 })
453}
454