Experimental mod: the tickets mentioned in a session, with whether the session took each into account, its board status, and what the agent says remains to do.

Experimental. A Claude Code mod — function hooks, not a skill module — that answers one question during a long session: which tickets have we talked about, and where does each stand?
Specified in features/business/session-mods/ (section scrumia-issues); where it lives is ADR-0026.
A session names many tickets, some worked on, some only mentioned. Reconstructing which is which means rereading the conversation. This mod keeps the table as the session goes and asks the agent to say, for each ticket, what precisely remains.
| Column | Meaning |
|---|---|
| Ticket | The number, as named by the person or by the agent. |
| In session | ✓ yes when the session worked on or covered it, no when it was only named, ? when nobody has assessed it yet. |
| Status | A column of the tracker (see below), or Unknown. |
| Remaining | Free text from the agent: exactly what is left to do, or nothing. |
| Piece | Role |
|---|---|
track_issue tool | The agent records a ticket: number, isTakenIntoAccount, status, remaining, and an optional title. Its description tells the agent to read the status from the board first (for example with scrumia-board when it is available) and report it. |
| Status vocabulary | At session start the mod runs scrumia-extends --modules --json to find the tracker module (scrumia-github-project), then scrumia-extends --settings <its key> and takes its columns (or, failing that, the keys of board.options), plus Unknown. The tool's schema lists them. A failed read is not kept: the defaults serve that call and the next call reads again. The last column, or the one board.flow.done names, closes a ticket. |
| Prompt detector | Every ticket number written with a leading hash in a prompt the person typed is added at once as not assessed; the next prompt carries a reminder, once. |
| Turn detector | The final text of each completed turn is read the same way, so a ticket the agent names alone reaches the table without the agent calling the tool. |
/issues and its pane | One row per ticket — ticket, in session, status, remaining — open tickets first, a summary line, and a line saying where the statuses come from. |
$.store | One entry per session id, ledger:<session id>. The thirty most recent sessions are kept. |
? / Unknown, and the agent is asked to assess it.remaining must be non-empty text, nothing when it is done.isTakenIntoAccount is not a boolean, the status is not one of the tracker's columns (the reason lists them), remaining is empty, or a title is not text. Nothing is stored.[scrumia-issues] label are left out of every scan, so its reminder, pasted or quoted, names nothing. Code spans, fenced code, a number glued to a word, & or / (entities, link fragments) and a number with a leading zero (#0012, #000) are not tickets either.hooks/hooks.json naming modules). Older versions ignore the plugin without saying so.scrumia-extends on the PATH and a project with scrumia-github-project declared in .scrumia/config.yaml. With neither, or when the command fails, the mod falls back to the six default columns (Backlog, Ready for dev, To dev, In progress, In review, Done) plus Unknown, and the pane says so with the reason. It never stops working for want of a tracker.The table belongs to the session id ($.session.id()).
| Case | What the table holds |
|---|---|
| The same session id comes back (a resume under its own id) | The same table. |
After /clear | The id changes: the table starts empty, the previous one stays stored under its id. |
| A resume that goes on under a new id (a fork) | An empty table. |
| Another session | Never the tickets of this one. |
Tests change the id the mocked session answers to; whether a real resume keeps its id is not provable with the kit and is to be tried by a person (AC-47 of the feature).
| What | Role |
|---|---|
hooks/register.tsx | The tool, the detectors, the command and the pane, all in one module. |
hooks/register.test.ts | Sixty-two tests: recording and updating in place, parallel writes, eleven malformed calls, the statuses from the tracker (its done column, a failed read read again) and the defaults in seven unreadable cases, the detector (once per ticket, the origins it refuses, its own note, code, colours and leading zeros), the agent's final text, the scope of a session (same id, /clear, another session, the thirty least recently touched dropped), and the pane on two surfaces, its sort included. |
What a later version must keep reading or announce in the changelog: the plugin name scrumia-issues; the tool name mcp__scrumia-issues__track_issue and its inputSchema (the status enum follows the tracker); the /issues command; the shape stored under ledger:<session id> in $.store (sessionId, touchedAt, issues); the label [scrumia-issues] that marks the mod's own text.
/plugin install scrumia-issues --marketplace tibs245/scrumia
While it is experimental, from a checkout:
claude --plugin-dir plugins/scrumia-issues
#1 priority) is listed as a ticket, not assessed.#111) outside code is read as a ticket.scrumia-questions mod writes its numbers with a hash too (#1): an agent citing "question #2" adds a ticket that does not exist to the table, as not assessed. The mod cannot tell the two apart.claude plugin validate plugins/scrumia-issues
claude plugin test plugins/scrumia-issueshooks/register.tsx 393 lines1import type { EngineInterface, PromptOrigin, Register } from 'claude-code'
2
3type Issue = {
4 number: number
5 title?: string
6 isTakenIntoAccount: boolean
7 isAssessed: boolean
8 isReminded: boolean
9 status: string
10 remaining: string
11}
12
13type Ledger = { sessionId: string; touchedAt: number; issues: Issue[] }
14
15type Vocabulary = { statuses: string[]; done: string; isFromTracker: boolean; note?: string }
16
17const PANE = 'issues'
18const TRACK_TOOL = 'mcp__scrumia-issues__track_issue'
19const TRACKER_MODULE = 'scrumia-github-project'
20const UNKNOWN = 'Unknown'
21const DEFAULT_COLUMNS = ['Backlog', 'Ready for dev', 'To dev', 'In progress', 'In review', 'Done']
22const LEDGER_PREFIX = 'ledger:'
23const KEEP_SESSIONS = 30
24const NOTE_MARK = '[scrumia-issues]'
25const TYPED_BY_PERSON: PromptOrigin['kind'][] = ['composer', 'bridge']
26const MENTION = /(?<![\w&/])#([1-9]\d{0,5})\b/g
27const CODE = /```[\s\S]*?```|`[^`\n]*`/g
28const READ_TIMEOUT_MS = 10_000
29
30let queue: Promise<unknown> = Promise.resolve()
31let vocabulary: Promise<Vocabulary> | undefined
32let projectDirectory: string | undefined
33
34const defaults = (note?: string): Vocabulary => ({
35 statuses: [...DEFAULT_COLUMNS, UNKNOWN],
36 done: 'Done',
37 isFromTracker: false,
38 note,
39})
40
41const isText = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''
42
43async function readSettings($: EngineInterface, cwd: string | undefined, args: string[]) {
44 const ran = await $.process.run(['scrumia-extends', ...args], { cwd, timeoutMs: READ_TIMEOUT_MS })
45 if (ran.exitCode !== 0) {
46 throw new Error(`scrumia-extends ${args[0]} exited with ${ran.exitCode}`)
47 }
48
49 return JSON.parse(ran.stdout) as unknown
50}
51
52async function readVocabulary($: EngineInterface, cwd: string | undefined): Promise<Vocabulary> {
53 try {
54 const modules = (await readSettings($, cwd, ['--modules', '--json'])) as { key: string; module: string; state?: string }[]
55 const tracker = modules.find(one => one.module === TRACKER_MODULE && one.state !== 'absent')
56 if (tracker === undefined) {
57 return defaults(`no ${TRACKER_MODULE} module is declared`)
58 }
59 const settings = (await readSettings($, cwd, ['--settings', tracker.key])) as {
60 columns?: unknown
61 board?: { options?: Record<string, unknown>; flow?: { done?: unknown } }
62 }
63 const columns = Array.isArray(settings.columns) ? settings.columns : Object.keys(settings.board?.options ?? {})
64 if (columns.length === 0 || !columns.every(isText)) {
65 return defaults('the tracker declares no columns')
66 }
67 const named = columns as string[]
68 const done = settings.board?.flow?.done
69
70 return {
71 statuses: named.includes(UNKNOWN) ? named : [...named, UNKNOWN],
72 done: isText(done) ? done : named[named.length - 1]!,
73 isFromTracker: true,
74 }
75 } catch (failure) {
76 return defaults(failure instanceof Error ? failure.message : 'the tracker settings could not be read')
77 }
78}
79
80function vocabularyOf($: EngineInterface, cwd?: string): Promise<Vocabulary> {
81 if (vocabulary === undefined) {
82 const reading = readVocabulary($, cwd ?? projectDirectory)
83 vocabulary = reading
84 // a failed read serves the defaults for this call only, so the next call reads again
85 void reading.then(known => {
86 if (!known.isFromTracker && vocabulary === reading) {
87 vocabulary = undefined
88 }
89 })
90 }
91
92 return vocabulary
93}
94
95const statusOf = (given: string, known: Vocabulary) =>
96 known.statuses.find(one => one.toLowerCase() === given.trim().toLowerCase())
97
98function problemWith(input: Record<string, unknown>, known: Vocabulary): string | undefined {
99 const { number, title, isTakenIntoAccount, status, remaining } = input
100 if (typeof number !== 'number' || !Number.isInteger(number) || number < 1) {
101 return 'number must be an integer from 1, without #'
102 }
103 if (title !== undefined && typeof title !== 'string') {
104 return 'title must be text'
105 }
106 if (typeof isTakenIntoAccount !== 'boolean') {
107 return 'isTakenIntoAccount must be true or false'
108 }
109 if (typeof status !== 'string' || statusOf(status, known) === undefined) {
110 return `status must be one of: ${known.statuses.join(', ')}`
111 }
112 if (!isText(remaining)) {
113 return 'remaining must be non-empty text: say what is left to do, or "nothing"'
114 }
115
116 return undefined
117}
118
119const isTypedByPerson = (origin: PromptOrigin | undefined) =>
120 origin !== undefined && (TYPED_BY_PERSON.includes(origin.kind) || (origin.kind === 'plugin' && origin.asUser === true))
121
122function mentionsIn(text: string): number[] {
123 const own = (line: string) => line.includes(NOTE_MARK)
124 const kept = text
125 .split('\n')
126 .filter(line => !own(line))
127 .join('\n')
128 .replace(CODE, ' ')
129 const numbers = [...kept.matchAll(MENTION)].map(match => Number(match[1]))
130
131 return [...new Set(numbers)]
132}
133
134const withMentions = (issues: Issue[], numbers: number[]): Issue[] => {
135 const known = new Set(issues.map(one => one.number))
136 const fresh = numbers.filter(number => !known.has(number))
137
138 return [
139 ...issues,
140 ...fresh.map(
141 (number): Issue => ({
142 number,
143 isTakenIntoAccount: false,
144 isAssessed: false,
145 isReminded: false,
146 status: UNKNOWN,
147 remaining: 'Not assessed yet',
148 }),
149 ),
150 ]
151}
152
153const reminderNote = (numbers: number[]) =>
154 `${NOTE_MARK} Tickets named and not yet recorded: ${numbers.map(number => `#${number}`).join(', ')}. Read each one's status on the board, then record it with track_issue with what remains.`
155
156const keyOf = (sessionId: string) => `${LEDGER_PREFIX}${sessionId}`
157
158async function load($: EngineInterface): Promise<Ledger> {
159 const sessionId = await $.session.id()
160 const stored = (await $.store.get(keyOf(sessionId))) as Ledger | undefined
161
162 return stored ?? { sessionId, touchedAt: 0, issues: [] }
163}
164
165async function forget($: EngineInterface, current: string) {
166 const keys = (await $.store.keys()).filter(key => key.startsWith(LEDGER_PREFIX))
167 if (keys.length <= KEEP_SESSIONS) {
168 return
169 }
170 const dated = await Promise.all(
171 keys.map(async key => ({ key, touchedAt: ((await $.store.get(key)) as Ledger | undefined)?.touchedAt ?? 0 })),
172 )
173 const oldest = dated.filter(one => one.key !== keyOf(current)).sort((first, second) => first.touchedAt - second.touchedAt)
174 for (const one of oldest.slice(0, keys.length - KEEP_SESSIONS)) {
175 await $.store.delete(one.key)
176 }
177}
178
179function change($: EngineInterface, edit: (issues: Issue[]) => Issue[], isRedrawn = true): Promise<Ledger> {
180 const run = queue.then(async () => {
181 const ledger = await load($)
182 const next = { ...ledger, touchedAt: await $.clock.now(), issues: edit(ledger.issues) }
183 await $.store.set(keyOf(next.sessionId), next)
184 await forget($, next.sessionId)
185 if (isRedrawn) {
186 $.ui.invalidate('ui.render')
187 }
188
189 return next
190 })
191 queue = run.catch(() => undefined)
192
193 return run
194}
195
196const sameStatus = (first: string, second: string) => first.trim().toLowerCase() === second.trim().toLowerCase()
197
198const statusColor = (status: string, known: Vocabulary) => {
199 if (sameStatus(status, known.done)) {
200 return 'success'
201 }
202 if (sameStatus(status, UNKNOWN)) {
203 return 'error'
204 }
205 const columns = known.statuses.filter(column => column !== UNKNOWN)
206 const place = columns.findIndex(column => sameStatus(column, status))
207 const closing = columns.findIndex(column => sameStatus(column, known.done))
208
209 return place > 0 && place < closing ? 'warning' : 'subtle'
210}
211
212export const register: Register = on => {
213 queue = Promise.resolve()
214 vocabulary = undefined
215 projectDirectory = undefined
216
217 on('session.start', async ($, e, next) => {
218 projectDirectory = e.cwd
219 const known = await vocabularyOf($, e.cwd)
220 await $.command.register({
221 name: 'issues',
222 description: 'Show the tickets mentioned in this session and what remains on each',
223 })
224 await $.tool.register({
225 name: 'track_issue',
226 isDeferred: false,
227 description:
228 'Record or update a ticket this session has mentioned. Call it whenever you mention, start, advance or finish work on a ticket by its number, and whenever a ticket is listed as named but not yet recorded. Read the ticket\'s status from the board first (for example with scrumia-board when it is available) and report that status: this tool does not check the board. Say whether this session took the ticket into account (worked on or covered it, not merely named it), and precisely what remains to do, so the person can tell its state from that sentence alone. The call is refused when a field is missing or malformed: read the reason and call again.',
229 inputSchema: {
230 type: 'object',
231 properties: {
232 number: { type: 'integer', minimum: 1, description: 'Ticket number, without #' },
233 title: { type: 'string', description: 'Short title, optional' },
234 isTakenIntoAccount: {
235 type: 'boolean',
236 description: 'true when this session worked on or covered the ticket, false when it was only mentioned',
237 },
238 status: { type: 'string', enum: known.statuses, description: 'Its column on the board, or Unknown' },
239 remaining: {
240 type: 'string',
241 description: 'Exactly what remains to do for this ticket; write "nothing" when it is done',
242 },
243 },
244 required: ['number', 'isTakenIntoAccount', 'status', 'remaining'],
245 },
246 })
247
248 return next(e)
249 })
250
251 on('tool.call', { tool: TRACK_TOOL }, async ($, e) => {
252 const input = e as unknown as Record<string, unknown>
253 const known = await vocabularyOf($)
254 const problem = problemWith(input, known)
255 if (problem !== undefined) {
256 return { deny: `scrumia-issues: ${problem}` }
257 }
258 const number = input.number as number
259 const status = statusOf(input.status as string, known)!
260 const given = {
261 title: input.title as string | undefined,
262 isTakenIntoAccount: input.isTakenIntoAccount as boolean,
263 isAssessed: true,
264 status,
265 remaining: (input.remaining as string).trim(),
266 }
267 const ledger = await change($, issues => {
268 const rest = issues.some(one => one.number === number)
269 ? issues
270 : withMentions(issues, [number])
271
272 return rest.map(one =>
273 one.number === number ? { ...one, ...given, title: given.title ?? one.title, isReminded: true } : one,
274 )
275 })
276 const open = ledger.issues.filter(one => one.status !== known.done).length
277
278 return { result: `#${number} recorded as ${status}. ${open} open of ${ledger.issues.length} in this session.` }
279 })
280
281 on('prompt.submit', async ($, e, next) => {
282 const typed = isTypedByPerson(e.origin) ? mentionsIn(e.text) : []
283 const ledger =
284 typed.length === 0 ? await load($) : await change($, issues => withMentions(issues, typed))
285 const waiting = ledger.issues.filter(one => !one.isAssessed && !one.isReminded).map(one => one.number)
286 if (waiting.length === 0 || e.origin === undefined || !isTypedByPerson(e.origin)) {
287 return next(e)
288 }
289 const ran = await next({ ...e, context: [...(e.context ?? []), reminderNote(waiting)] })
290 if (ran.drop === undefined) {
291 await change($, issues => issues.map(one => (waiting.includes(one.number) ? { ...one, isReminded: true } : one)), false)
292 }
293
294 return ran
295 })
296
297 on('turn.complete', async ($, e, next) => {
298 const named = e.reason === 'answer' ? mentionsIn(e.answer) : []
299 if (named.length > 0) {
300 await change($, issues => withMentions(issues, named))
301 }
302
303 return next(e)
304 })
305
306 on('command.run', { command: 'issues' }, async $ => {
307 await $.ui.open({ id: PANE, title: 'Issues' })
308
309 return { text: 'Issues pane opened.' }
310 })
311
312 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
313 const { Box, Text } = $.ui.resolve(e)
314 const known = await vocabularyOf($)
315 const { issues } = await load($)
316 const isOpen = (one: Issue) => one.status !== known.done
317 const sorted = [...issues].sort(
318 (first, second) => Number(isOpen(second)) - Number(isOpen(first)) || first.number - second.number,
319 )
320 const taken = issues.filter(one => one.isTakenIntoAccount).length
321 const open = issues.filter(isOpen).length
322 const vocabularyLine = known.isFromTracker
323 ? `Statuses: the tracker's columns (${known.statuses.join(', ')})`
324 : `Statuses: the default columns, the tracker's could not be read (${known.note ?? 'unknown reason'})`
325
326 if (issues.length === 0) {
327 return (
328 <Box flexDirection="column">
329 <Text dimColor>No ticket mentioned in this session yet.</Text>
330 <Text dimColor>{vocabularyLine}</Text>
331 </Box>
332 )
333 }
334
335 return (
336 <Box flexDirection="column" gap={1}>
337 <Box gap={2}>
338 <Text bold>Issues</Text>
339 <Text dimColor>
340 {issues.length} mentioned · {taken} taken into account · {open} open
341 </Text>
342 </Box>
343 <Text dimColor>{vocabularyLine}</Text>
344 <Box gap={2}>
345 <Box width={8}>
346 <Text dimColor bold>
347 TICKET
348 </Text>
349 </Box>
350 <Box width={11}>
351 <Text dimColor bold>
352 IN SESSION
353 </Text>
354 </Box>
355 <Box width={14}>
356 <Text dimColor bold>
357 STATUS
358 </Text>
359 </Box>
360 <Box flexGrow={1}>
361 <Text dimColor bold>
362 REMAINING
363 </Text>
364 </Box>
365 </Box>
366 {sorted.map(one => (
367 <Box key={`issue-${one.number}`} gap={2}>
368 <Box width={8}>
369 <Text bold>#{one.number}</Text>
370 </Box>
371 <Box width={11}>
372 <Text color={one.isTakenIntoAccount ? 'success' : undefined} dimColor={!one.isTakenIntoAccount}>
373 {one.isAssessed ? (one.isTakenIntoAccount ? '✓ yes' : 'no') : '?'}
374 </Text>
375 </Box>
376 <Box width={14}>
377 <Text color={statusColor(one.status, known)} bold>
378 {one.status}
379 </Text>
380 </Box>
381 <Box flexGrow={1} flexDirection="column">
382 {one.title && <Text dimColor>{one.title}</Text>}
383 <Text wrap="wrap" dimColor={!one.isAssessed}>
384 {one.remaining}
385 </Text>
386 </Box>
387 </Box>
388 ))}
389 </Box>
390 )
391 })
392}
393