SLOPSHOPPER

scrumia-roadmap

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…

newpaneguardcommandtoasttool
★ 1v0.1.2MITupdated 2026-10-10tibs245/scrumia/plugins/scrumia-roadmap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · scrumia-roadmap
│ ┃ Roadmap ✕ › fix the failing auth test and add an audit log call │ ┃ Roadmap [ filter-all ] [ filter-planned ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Default columns in use: JSON Parse error: ⎿ Read 6 lines │ ┃ Unexpected EOF. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No sprint here. The agent records them with ⏺ Bash(bun test) │ ┃ plan_sprint. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /roadmap │ ⎿ scrumia-roadmap: Roadmap pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Roadmap
Roadmap [ filter-all ] [ filter-planned ] [ filter-runnin Default columns in use: JSON Parse error: Unexpected EOF. No sprint here. The agent records them with plan_sprint.
README

scrumia-roadmap

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.

What it answers

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.

How it works

PieceRole
plan_sprint toolThe 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 paneAn 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.
$.storeOne 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.

What it refuses

  • A malformed sprint: the call is refused with its reason when the name or objective is empty, the order is not an integer, the source has an unknown kind or an empty reference, the state is unknown, the tickets are empty or a ticket lacks a positive number, title, description or status, a ticket status is not a column of the tracker (the list is given), or a ticket number appears twice. A sprint recorded under a new name with the order of another is refused too: the order is unique. Nothing is stored, so the pane never holds a sprint it cannot draw.
  • A silently empty testable list: an empty testableAtEnd needs nothingTestable: true, which the pane shows in words; nothingTestable: true beside a non-empty list is refused as contradictory.
  • A lost sprint: writes go through one queue, so parallel calls keep every sprint.
  • A sprint passing for fresh: one not recorded under the current session id is marked possibly stale.
  • A state that contradicts the tickets is not refused but marked: "state done, N ticket(s) not done" on a done sprint, "state planned, all N ticket(s) done" on a planned one.

What it expects to find

  • A Claude Code that loads plugins made of hooks modules (hooks/hooks.json naming modules). Older versions ignore the plugin without saying so.
  • No ticket state, only the tracker's list of columns: the agent resolves the lots and records them; the mod reads nothing of the board but those columns, through scrumia-extends. The module scrumia-github-project is optional: without it the default columns apply. Ticket statuses shown are the ones last recorded.

What it ships

WhatRole
hooks/register.tsxThe tool, the command and the pane, all in one module.
hooks/register.test.tsTests: 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.

Public contract

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).

Install

/plugin install scrumia-roadmap --marketplace tibs245/scrumia

For development, from a checkout of the repository:

claude --plugin-dir plugins/scrumia-roadmap

Known limits

  • Whether the pane draws depends on the surface the session runs in; it is tested here with mocked terminal and desktop surfaces, and not yet tried by a person on the real ones.
  • Chrome strings are English; sprint texts are whatever language the agent writes in.
  • The roadmap belongs to the machine and to the project root it was recorded in: nothing is written in the repo, and two sessions open on the same project share it. The tracker stays the source of truth; the roadmap is the agent's last reading of it.
  • The mod cannot know that the board moved: possibly stale only says the sprint was not recorded under this session id. The filter is per process and starts again after a reload.
  • The mod checks the form of 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.
  • A sprint has no age limit and is never dropped except by Remove.
  • The queue serialises writes inside one process; two processes writing the same project at the same instant are not protected.

Tests

claude plugin validate plugins/scrumia-roadmap
claude plugin test plugins/scrumia-roadmap
Source 1 files
hooks/register.tsx 454 lines
1import 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