Keeps the decisions and actions an assistant is waiting on you for in the session task list: numbered D1, A1, ..., checked for a recommendation and a link…

A queue for everything your assistant is waiting on you for. Questions an assistant asks get buried in long messages and scroll away; this plugin pins each one as a numbered card (D1 for a decision, A1 for something only you can do) in the transcript, a sidebar and the footer, with a recommendation you can accept in one click.

<sub>A real session, sped up while the assistant works. Asked to plan a SQLite to Postgres move without writing code, it queues its decisions and actions. Accept all puts an answer line for each recommendation in the prompt; one Enter settles them all, and only the actions stay open.</sub>
claude plugin marketplace add lokkju/skills
claude plugin install session-decisions@lokkju
Restart Claude Code. There's nothing to configure; the skill tells the assistant when to queue something, and the hook keeps it honest.
The assistant creates an ordinary task with a subject in one of two forms:
| Kind | Subject | Becomes |
|---|---|---|
| Decision | DECIDE: Postgres driver? (recommend psycopg 3) [#12] | D3 DECIDE: Postgres driver? (recommend psycopg 3) [#12] |
| Action | ACTION: Provision the target database [no link] | A2 ACTION: Provision the target database [no link] |
A hook on TaskCreate assigns the label and refuses a decision with no recommendation, or any item with no link, telling the assistant what's missing. The link is an issue (#123, owner/repo#123), a merge or pull request (!45), a URL, or no link. An assistant that can't write a recommendation isn't ready to ask yet, which is the point.
✓ D3 · <ruling> once it's settled.● 5 decisions sits beside the mode labels. Click it to open the queue; Esc closes it.DECIDE or ACTION chip, the label, a recommends <choice> tag, the question with any qualifier under it, the link, and the item's age, which turns red after a day.Accept puts D3: go with <the recommendation> in your prompt, Done puts A2: done, and Answer puts D3: for you to finish. Each press adds or replaces that item's line, so one message can answer several items; Accept all adds a line for every open recommendation. You edit and send it like any other message, and the assistant marks each item completed with a dated Ruling (2026-10-08): ... line. Completed items stay in the task list as the session's record; Settled (n) in the sidebar shows them.
| Command | What it does |
|---|---|
/decisions | Lists the open items |
/decisions all | Adds the settled ones, with their rulings |
/decisions pane | Opens the sidebar |
/decisions wrap | Has the assistant settle every open item before the session ends: escalate it to the tracker, carry it into the handoff, or drop it |
When a session is compacted or resumed, the assistant is reminded of what's still open, so a question asked two hours ago doesn't silently fall out of context.
Labels are session-scoped. Anything recorded outside the session (commits, issues, pull requests) carries the ruling and its date, never D3, which means nothing anywhere else.
Claude Code with function hooks, which interactive sessions and background jobs load. A plain claude -p run doesn't load them unless CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 is set; there the skill tells the assistant to number items itself.
Not every session gets TaskCreate, and Claude Code doesn't document which ones do. Some of my test sessions listed only TaskStop (#4). Without it, the assistant uses the plugin's decision_add and decision_close tools instead. The items show the same way in the cards, the sidebar, the footer and /decisions, with a ledger tag on each card, but they don't appear in ctrl+t.
With NO_COLOR set or TERM=dumb, chips and glyphs fall back to ASCII ([DECIDE], +, x). Otherwise the cards use Claude Code's theme colors and follow your theme.
The plugin never polls. It keeps the items in the session's plugin state and updates them from TaskCreate and TaskUpdate calls as they finish; the footer, sidebar and cards redraw from that state. It reads the task files once, at session start and after compaction or resume, to pick up items it didn't see created. Label counters and the fallback ledger are kept per session in the plugin's store, so a resumed session continues its numbering.
On VS Code and mobile there's no footer count (Claude Code only raises the footer on the terminal and desktop); the transcript cards, the sidebar and /decisions work everywhere.
~/.claude/tasks/<session id>/, which Claude Code doesn't document. If that changes, items created before a resume or compaction stop showing until they're updated; labelling is unaffected.CLAUDE_CODE_TASK_LIST_ID) don't see each other's changes until their next session start.claude plugin validate --strict plugins/session-decisions
claude plugin test plugins/session-decisions
For an editor or tsc, run /plugin-types plugins/session-decisions/.claude/types in Claude Code to write the engine's declarations for your build (git ignores them), then npx -p typescript@5 tsc -p plugins/session-decisions/tsconfig.json.
docs/record-demo.sh drives a real session in a 150x42 tmux pane: it types the prompt, waits for the assistant, clicks Accept all, sends the answers, and renders the capture with agg. It needs tmux, jq, claude and agg on PATH and spends one short Sonnet conversation.
bash plugins/session-decisions/docs/record-demo.sh # writes docs/demo.gif
The committed recording ran in a session without TaskCreate, so its cards show the ledger tag. DEMO_MODEL picks the model the recorded session runs on.
hooks/register.tsx 542 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Counters, Item, Kind, SessionRecord } from '../types'
5import {
6 applyUpdate,
7 age,
8 coverLabels,
9 isOpen,
10 itemFromTask,
11 listing,
12 mergeAnswers,
13 nextNumber,
14 ordered,
15 reminder,
16 subjectOf,
17 upsert,
18} from './queue'
19import { LETTER, formatSubject, parseDraft, problems, recommendation, rulingIn, shortRecommendation, splitBody } from './subject'
20
21type $ = EngineInterface
22
23const PANE = 'session-decisions'
24const ADD_TOOL = 'mcp__session-decisions__decision_add'
25const CLOSE_TOOL = 'mcp__session-decisions__decision_close'
26const SAFE_ID = /^[A-Za-z0-9._-]{1,128}$/
27const KEEP_SESSIONS = 100
28
29const items = atom({ plugin: 'session-decisions', key: 'items' } as const, [] as Item[])
30const counters = atom({ plugin: 'session-decisions', key: 'counters' } as const, { D: 0, A: 0 } as Counters)
31const created = atom({ plugin: 'session-decisions', key: 'created' } as const, {} as Record<string, number>)
32const showSettled = atom({ plugin: 'session-decisions', key: 'showSettled' } as const, false)
33const paneDismissed = atom({ plugin: 'session-decisions', key: 'paneDismissed' } as const, false)
34const autoOpened = atom({ plugin: 'session-decisions', key: 'autoOpened' } as const, false)
35// Whether this surface docks panes beside the transcript: fixed per session on the terminal and
36// learnt from the footer's drawing. A module variable, since a drawing may not write state.
37let docks = false
38const ascii = atom({ plugin: 'session-decisions', key: 'ascii' } as const, false)
39
40const DAY = 24 * 60 * 60 * 1000
41// Claude Code's own theme keys, so the cards follow whichever theme the person runs.
42const ACCENT = 'claude'
43const KIND_COLOR = { DECIDE: 'warning', ACTION: 'suggestion' } as const
44
45async function sessionId($: $): Promise<string | null> {
46 const sid = await $.session.id()
47 return SAFE_ID.test(sid) && sid !== '.' && sid !== '..' ? sid : null
48}
49
50/** The task list Claude Code keeps for this session (read only; the format is undocumented). */
51async function tasksDir($: $, sid: string): Promise<string> {
52 const listId = await $.env.get('CLAUDE_CODE_TASK_LIST_ID')
53 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`
54 return `${config}/tasks/${listId && SAFE_ID.test(listId) ? listId : sid}`
55}
56
57/** The decision items in the task files, or null when the directory can't be read. */
58async function readTaskFiles($: $, dir: string): Promise<Item[] | null> {
59 let entries
60 try {
61 entries = await $.fs.list(dir)
62 } catch {
63 return (await $.fs.exists(dir).catch(() => true)) ? null : []
64 }
65 const found: Item[] = []
66 for (const entry of entries) {
67 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
68 try {
69 const data = JSON.parse(await $.fs.read(`${dir}/${entry.name}`))
70 if (data && typeof data === 'object' && typeof data.subject === 'string') {
71 const item = itemFromTask({ ...data, id: String(data.id ?? entry.name.replace(/\.json$/, '')) })
72 if (item) found.push(item)
73 }
74 } catch {
75 // unreadable or half-written: skip it, as the task list itself would
76 }
77 }
78 return found
79}
80
81async function loadRecord($: $, sid: string): Promise<SessionRecord> {
82 const raw = (await $.store.get(`session:${sid}`)) as Partial<SessionRecord> | undefined
83 return {
84 counters: { D: Number(raw?.counters?.D) || 0, A: Number(raw?.counters?.A) || 0 },
85 created: raw?.created && typeof raw.created === 'object' ? raw.created : {},
86 ledger: Array.isArray(raw?.ledger) ? raw.ledger : [],
87 touched: Number(raw?.touched) || 0,
88 }
89}
90
91async function persist($: $): Promise<void> {
92 const sid = await sessionId($)
93 if (!sid) return
94 const record: SessionRecord = {
95 counters: await read($, counters),
96 created: await read($, created),
97 ledger: (await read($, items)).filter(item => item.source === 'ledger'),
98 touched: await $.clock.now(),
99 }
100 await $.store.set(`session:${sid}`, record)
101}
102
103/** Drop the records of all but the most recently used sessions, to stay under the store's cap. */
104async function prune($: $): Promise<void> {
105 const keys = (await $.store.keys()).filter(key => key.startsWith('session:'))
106 if (keys.length <= KEEP_SESSIONS) return
107 const touched = await Promise.all(
108 keys.map(async key => [key, Number(((await $.store.get(key)) as SessionRecord)?.touched) || 0] as const),
109 )
110 touched.sort((a, b) => b[1] - a[1])
111 await Promise.all(touched.slice(KEEP_SESSIONS).map(([key]) => $.store.delete(key)))
112}
113
114/** Rebuild the items from the task files and the stored ledger; the one read this plugin makes. */
115async function hydrate($: $): Promise<void> {
116 const sid = await sessionId($)
117 if (!sid) return
118 const record = await loadRecord($, sid)
119 const fromFiles = await readTaskFiles($, await tasksDir($, sid))
120 const current = await read($, items)
121 const tasks = fromFiles ?? current.filter(item => item.source === 'tasks')
122 const known = { ...record.created, ...(await read($, created)) }
123 const merged = [...tasks, ...record.ledger].map(item => ({
124 ...item,
125 created: item.created ?? current.find(one => one.id === item.id && one.source === item.source)?.created ?? known[item.label],
126 }))
127 await update($, items, () => merged)
128 await update($, created, () => known)
129 await update($, counters, now =>
130 coverLabels({ D: Math.max(now.D, record.counters.D), A: Math.max(now.A, record.counters.A) }, merged),
131 )
132 await publish($, merged)
133}
134
135/** After the queue changes: close a sidebar this plugin opened once nothing is left open. */
136async function publish($: $, list: readonly Item[]): Promise<void> {
137 if (list.some(isOpen)) return
138 await update($, paneDismissed, () => false)
139 if (await read($, autoOpened)) {
140 await update($, autoOpened, () => false)
141 await $.ui.close({ id: PANE }).catch(() => undefined)
142 }
143}
144
145/** Open the queue as a sidebar, unasked, where the surface docks panes and the person hasn't closed it. */
146async function offerSidebar($: $): Promise<void> {
147 if (!docks || (await read($, paneDismissed))) return
148 const opened = await $.ui.open({ id: PANE, title: 'Decisions', columns: 52 }).catch(() => null)
149 if (opened) await update($, autoOpened, () => true)
150}
151
152/** Open the queue because the person asked: it seats at any width and takes the keys. */
153async function openPane($: $): Promise<void> {
154 await update($, paneDismissed, () => false)
155 await update($, autoOpened, () => false)
156 await $.ui.open({ id: PANE, title: 'Decisions', focus: true, closeOnEscape: true, columns: 52 }).catch(() => undefined)
157}
158
159async function change($: $, fn: (list: Item[]) => Item[]): Promise<Item[]> {
160 let after: Item[] = []
161 await update($, items, list => (after = fn(list)))
162 await publish($, after)
163 return after
164}
165
166/** Assign the next label for `kind`; the versioned update keeps parallel creates apart. */
167async function assign($: $, kind: Kind): Promise<string> {
168 const list = await read($, items)
169 let number = 0
170 await update($, counters, now => {
171 number = nextNumber(now, list, kind)
172 return { ...now, [LETTER[kind]]: number }
173 })
174 const label = `${LETTER[kind]}${number}`
175 const at = await $.clock.now()
176 await update($, created, map => ({ ...map, [label]: at }))
177 return label
178}
179
180async function announce($: $, item: Item): Promise<void> {
181 $.ui.toast(`${item.label} added: ${splitBody(item.body).question}`)
182 await offerSidebar($)
183}
184
185const WRAP = `Wrap up this session's decision queue, as the session-decisions skill describes: for every open item below, ask me in one message whether to escalate it (create it in the project's tracker as a question for the right person, and link it), carry it forward (it stays open and goes in the handoff), or drop it. Then apply my answers, completing each settled item with a "Ruling (<YYYY-MM-DD>): ..." line, and report what changed.`
186
187export const register: Register = on => {
188 on('session.start', async ($, e, next) => {
189 // Each registration stands alone: a host that refuses one still gets the rest and the queue.
190 const quietly = (work: Promise<unknown>) => work.catch(() => undefined)
191 await quietly($.command.register({
192 name: 'decisions',
193 description: "Show this session's open decisions (D) and actions (A); 'all' adds settled ones, 'pane' opens the panel, 'wrap' settles each before the session ends.",
194 argumentHint: '[all | pane | wrap]',
195 immediate: true,
196 }))
197 await quietly($.tool.register({
198 name: 'decision_add',
199 description:
200 'Fallback for the session-decisions queue, only when TaskCreate is not available: adds a DECIDE or ACTION item and returns its labelled subject.',
201 inputSchema: {
202 type: 'object',
203 properties: {
204 kind: { type: 'string', enum: ['DECIDE', 'ACTION'] },
205 text: { type: 'string', description: '<question> (recommend <choice>) [<link>], or <what the user has to do> [<link>]' },
206 description: { type: 'string', description: 'The context the user needs to answer.' },
207 },
208 required: ['kind', 'text'],
209 },
210 }))
211 await quietly($.tool.register({
212 name: 'decision_close',
213 description: 'Settles a fallback-ledger item from decision_add with the user\'s ruling. Task-list items are settled with TaskUpdate instead.',
214 inputSchema: {
215 type: 'object',
216 properties: {
217 label: { type: 'string', description: 'The item label, such as D3.' },
218 ruling: { type: 'string', description: 'The answer, as it should be recorded.' },
219 },
220 required: ['label', 'ruling'],
221 },
222 }))
223 const term = await $.env.get('TERM')
224 const noColor = await $.env.get('NO_COLOR')
225 await update($, ascii, () => Boolean(noColor) || term === 'dumb')
226 // 1.0 pinned the open count as a status notice; this version shows it in the footer instead.
227 $.ui.status(undefined)
228 await hydrate($)
229 await quietly(prune($))
230 return next(e)
231 })
232
233 // After compaction or resume, remind the model of what is still waiting on the user.
234 on('classic.SessionStart', async ($, e, next) => {
235 const result = await next(e)
236 if (e.source !== 'compact' && e.source !== 'resume') return result
237 await hydrate($)
238 const text = reminder(await read($, items))
239 if (!text) return result
240 return { ...result, additionalContext: [...(result.additionalContext ?? []), text] }
241 })
242
243 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
244 const draft = parseDraft(String(e.subject ?? ''))
245 if (!draft) return next(e)
246 const found = problems(draft)
247 if (found.length) return { deny: `session-decisions: ${found.join('; ')}` }
248 const label = await assign($, draft.kind)
249 const ran = await next({ ...e, subject: formatSubject(label, draft) })
250 if (ran.deny !== undefined || ran.isError) return ran
251 const task = ran.result.task
252 const item = itemFromTask({ id: task.id, subject: task.subject, description: String(e.description ?? '') })
253 if (item) {
254 const at = (await read($, created))[item.label] ?? (await $.clock.now())
255 await change($, list => upsert(list, { ...item, created: at }))
256 await update($, counters, now => coverLabels(now, [item]))
257 await persist($)
258 await announce($, item)
259 }
260 return ran
261 })
262
263 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
264 const ran = await next(e)
265 if (ran.deny !== undefined || ran.isError) return ran
266 const before = await read($, items)
267 const after = await change($, list =>
268 applyUpdate(list, { taskId: e.taskId, subject: e.subject, description: e.description, status: e.status }),
269 )
270 if (after !== before) await persist($)
271 return ran
272 })
273
274 on('tool.call', { tool: ADD_TOOL }, async ($, e) => {
275 const kind = e.kind === 'ACTION' ? 'ACTION' : e.kind === 'DECIDE' ? 'DECIDE' : null
276 if (!kind) return { deny: 'session-decisions: kind must be DECIDE or ACTION' }
277 const draft = { kind, body: String(e.text ?? '').trim() } as const
278 const found = problems(draft)
279 if (found.length) return { deny: `session-decisions: ${found.join('; ')}` }
280 const label = await assign($, kind)
281 const item: Item = {
282 id: label,
283 source: 'ledger',
284 label,
285 kind,
286 body: draft.body,
287 status: 'pending',
288 description: String(e.description ?? ''),
289 created: (await read($, created))[label],
290 }
291 await change($, list => [...list, item])
292 await persist($)
293 await announce($, item)
294 return { result: subjectOf(item) }
295 })
296
297 on('tool.call', { tool: CLOSE_TOOL }, async ($, e) => {
298 const label = String(e.label ?? '').trim().toUpperCase()
299 const ruling = String(e.ruling ?? '').trim()
300 const list = await read($, items)
301 if (!list.some(item => item.source === 'ledger' && item.label === label)) {
302 return {
303 deny: `session-decisions: ${label} isn't in the fallback ledger. If it's a task-list item, mark it completed with TaskUpdate and add the ruling to its description.`,
304 }
305 }
306 const date = new Date(await $.clock.now()).toISOString().slice(0, 10)
307 await change($, now =>
308 now.map(item =>
309 item.source === 'ledger' && item.label === label
310 ? {
311 ...item,
312 status: 'completed',
313 description: [item.description, `Ruling (${date}): ${ruling}`].filter(Boolean).join('\n'),
314 }
315 : item,
316 ),
317 )
318 await persist($)
319 return { result: `${label} closed` }
320 })
321
322 on('command.run', { command: 'decisions' }, async ($, e) => {
323 const arg = e.args.trim().toLowerCase()
324 const list = await read($, items)
325 const now = await $.clock.now()
326 if (arg === 'pane') {
327 await update($, paneDismissed, () => false)
328 const opened = await $.ui
329 .open({ id: PANE, title: 'Decisions', focus: true, closeOnEscape: true, columns: 52 })
330 .catch((err: unknown) => ({ isPlaced: false as const, reason: String(err) }))
331 return { text: opened.isPlaced ? 'Decisions pane opened.' : `${listing(list, now)}\n\n(The pane can't be shown here: ${opened.reason})` }
332 }
333 if (arg === 'wrap') {
334 const open = list.filter(isOpen)
335 if (open.length === 0) return { text: 'No open decisions or actions; nothing to wrap up.' }
336 void $.prompt.submit({ text: `${WRAP}\n\n${listing(list, now)}` }).catch(() => undefined)
337 return { text: listing(list, now) }
338 }
339 return { text: listing(list, now, arg === 'all') }
340 })
341
342 // A sidebar the person closes stays closed until the queue empties.
343 on('ui.close', async ($, e, next) => {
344 if (e.id === PANE && e.origin.kind === 'person') {
345 await update($, paneDismissed, () => true)
346 await update($, autoOpened, () => false)
347 }
348 return next(e)
349 })
350
351 // The footer: a count beside the mode labels that opens the queue. It also learns whether
352 // this surface docks panes, which decides whether the sidebar may open unasked.
353 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
354 docks = e.viewport?.isFullscreen === true
355 const open = (await read($, items)).filter(isOpen).length
356 if (open === 0) return next(e)
357
358 const { Box, Button, Text } = $.ui.resolve(e)
359 const plain = await read($, ascii)
360 const below = await next(e)
361 return (
362 <Box flexDirection="row" gap={1}>
363 {below}
364 <Box key="decisions-footer" flexDirection="row">
365 <Text color="warning">{plain ? '*' : '●'} </Text>
366 <Button
367 key="decisions-count"
368 plain
369 label={`${open} ${open === 1 ? 'decision' : 'decisions'}`}
370 onPress={() => void openPane($)}
371 />
372 </Box>
373 </Box>
374 )
375 })
376
377 // In the transcript, a finished TaskCreate or decision_add row draws as its item's card.
378 on('ui.render', { component: 'ToolUse', props: { tool: 'TaskCreate' } }, async ($, e, next) => {
379 if (e.props.isRunning || e.props.isErrored || e.props.isInterrupted) return next(e)
380 const id = String((e.props.output as { task?: { id?: unknown } } | undefined)?.task?.id ?? '')
381 const item = (await read($, items)).find(one => one.source === 'tasks' && one.id === id)
382 if (!item) return next(e)
383 return drawCard($, e, item, { bordered: true, isNext: isOpen(item), compact: !isOpen(item) })
384 })
385 on('ui.render', { component: 'ToolUse', props: { tool: ADD_TOOL } }, async ($, e, next) => {
386 if (e.props.isRunning || e.props.isErrored || e.props.isInterrupted) return next(e)
387 const label = String(e.props.output ?? '').split(' ')[0]
388 const item = (await read($, items)).find(one => one.source === 'ledger' && one.label === label)
389 if (!item) return next(e)
390 return drawCard($, e, item, { bordered: true, isNext: isOpen(item), compact: !isOpen(item) })
391 })
392
393 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
394 const { Box, Button, Text } = $.ui.resolve(e)
395 const list = ordered(await read($, items))
396 const settledOpen = await read($, showSettled)
397 const plain = await read($, ascii)
398 const docked = e.props.placement === 'dock'
399 const open = list.filter(isOpen)
400 const settled = list.filter(item => !isOpen(item))
401 const accepts = open.flatMap(item => {
402 const choice = item.kind === 'DECIDE' ? recommendation(item.body) : null
403 return choice ? [`${item.label}: go with ${choice}`] : []
404 })
405
406 return (
407 <Box flexDirection="column">
408 <Box key="head" flexDirection="row" gap={1} marginBottom={1}>
409 <Text bold>Decisions</Text>
410 {open.length > 0 ? chip(Text, plain, `${open.length} open`, ACCENT) : <Text dimColor>Nothing waiting on you</Text>}
411 <Box flexGrow={1} />
412 {accepts.length > 1 && <Button key="accept-all" label="Accept all" variant="primary" onPress={answer($, accepts)} />}
413 </Box>
414 {await Promise.all(
415 open.map(async (item, i) => (
416 <Box key={`slot-${item.source}-${item.id}`} flexDirection="column" marginBottom={docked ? 1 : 0}>
417 {await drawCard($, e, item, { bordered: !docked, isNext: i === 0, compact: false })}
418 </Box>
419 )),
420 )}
421 {settled.length > 0 && (
422 <Button
423 key="settled"
424 plain
425 dimColor
426 label={`${settledOpen ? (plain ? 'v' : '⌄') : plain ? '>' : '›'} Settled (${settled.length})`}
427 onPress={() => update($, showSettled, value => !value)}
428 />
429 )}
430 {settledOpen &&
431 (await Promise.all(
432 settled.map(async item => (
433 <Box key={`slot-${item.source}-${item.id}`} flexDirection="column">
434 {await drawCard($, e, item, { bordered: false, isNext: false, compact: true })}
435 </Box>
436 )),
437 ))}
438 </Box>
439 )
440 })
441}
442
443/** Each press adds or replaces its item's line in the draft, so one message can answer several. */
444function answer($: $, lines: string[]) {
445 return () =>
446 void $.prompt
447 .read()
448 .then(box => $.prompt.fill({ text: mergeAnswers(box.text, lines) }))
449 .catch(() => undefined)
450}
451
452type Elements = ReturnType<$['ui']['resolve']>
453
454function chip(Text: Elements['Text'], plain: boolean, text: string, color: string, dim = false) {
455 return (
456 <Text bold backgroundColor={dim ? undefined : color} color={dim ? undefined : 'inverseText'} dimColor={dim}>
457 {plain ? `[${text}]` : ` ${text} `}
458 </Text>
459 )
460}
461
462/**
463 * One item. Open: kind chip, label, recommendation tag, age, the question, the qualifier, and its
464 * buttons; `bordered` draws a round frame (the accent one for `isNext`). Settled, or `compact`: one
465 * dim line with the ruling.
466 */
467async function drawCard(
468 $: $,
469 site: Parameters<$['ui']['resolve']>[0],
470 item: Item,
471 opts: { bordered: boolean; isNext: boolean; compact: boolean },
472) {
473 const { Box, Button, Text } = $.ui.resolve(site) as Elements
474 const plain = await read($, ascii)
475 const now = await $.clock.now()
476 const parts = splitBody(item.body)
477
478 if (!isOpen(item) || opts.compact) {
479 const ruling = (rulingIn(item.description) ?? '').replace(/^Ruling \(([^)]*)\):\s*/, '$1 · ')
480 const moot = /·\s*moot\b/i.test(ruling)
481 return (
482 <Box key={`card-${item.source}-${item.id}`} flexDirection="row" gap={1}>
483 <Text color={moot ? undefined : 'success'} dimColor={moot}>
484 {moot ? (plain ? 'x' : '✗') : plain ? '+' : '✓'}
485 </Text>
486 <Text bold dimColor>
487 {item.label}
488 </Text>
489 <Box flexShrink={1}>
490 <Text dimColor wrap="truncate-end">
491 {ruling || parts.question}
492 </Text>
493 </Box>
494 </Box>
495 )
496 }
497
498 const rec = parts.recommendation ? shortRecommendation(parts.recommendation) : null
499 const stale = item.created !== undefined && now - item.created > DAY
500 const frame = opts.bordered
501 ? { borderStyle: 'round', borderColor: opts.isNext ? ACCENT : 'inactive', hover: { borderColor: ACCENT }, paddingX: 1 }
502 : {}
503 return (
504 <Box key={`card-${item.source}-${item.id}`} flexDirection="column" {...frame}>
505 <Box key="top" flexDirection="row" gap={1}>
506 {chip(Text, plain, item.kind, KIND_COLOR[item.kind])}
507 <Text bold color={opts.isNext && !opts.bordered ? ACCENT : undefined}>
508 {item.label}
509 </Text>
510 {rec && chip(Text, plain, `recommends ${rec.choice}`, 'success')}
511 {item.source === 'ledger' && <Text dimColor>ledger</Text>}
512 <Box flexGrow={1} />
513 {item.created !== undefined && (
514 <Text color={stale ? 'error' : undefined} dimColor={!stale}>
515 {age(item.created, now).replace('waiting ', '')}
516 </Text>
517 )}
518 </Box>
519 <Text>{parts.question}</Text>
520 {rec?.qualifier && <Text dimColor>{rec.qualifier}</Text>}
521 <Box key="actions" flexDirection="row" gap={1}>
522 {rec && item.kind === 'DECIDE' && (
523 <Button
524 key={`accept-${item.label}`}
525 label="Accept"
526 variant="primary"
527 onPress={answer($, [`${item.label}: go with ${parts.recommendation}`])}
528 />
529 )}
530 {item.kind === 'ACTION' && (
531 <Button key={`done-${item.label}`} label="Done" variant="primary" onPress={answer($, [`${item.label}: done`])} />
532 )}
533 <Button key={`answer-${item.label}`} label="Answer" dimColor onPress={answer($, [`${item.label}: `])} />
534 <Box flexGrow={1} />
535 <Text dimColor wrap="truncate-start">
536 {parts.link ?? 'no link'}
537 </Text>
538 </Box>
539 </Box>
540 )
541}
542hooks/queue.ts 162 lines1// The queue as plain data: labelling, applying task-list changes, and the text views.
2// Nothing here touches `$`, so it's tested directly.
3
4import type { Counters, Item, Kind, Status } from '../types'
5import { LETTER, parseDraft, parseLabel, rulingIn } from './subject'
6
7const OPEN: ReadonlySet<string> = new Set(['pending', 'in_progress'])
8
9export const isOpen = (item: Item): boolean => OPEN.has(item.status)
10
11/** The number part of a label; 0 for an item the hook didn't get to label ("D?"). */
12export function labelNumber(label: string): number {
13 const digits = label.slice(1)
14 return /^\d+$/.test(digits) ? Number(digits) : 0
15}
16
17/** Next number for this kind: one past both the counter and any label already in use. */
18export function nextNumber(counters: Counters, items: readonly Item[], kind: Kind): number {
19 const letter = LETTER[kind]
20 const inUse = items.filter(item => item.label[0] === letter).map(item => labelNumber(item.label))
21 return Math.max(counters[letter], ...inUse) + 1
22}
23
24/** Counters raised to cover every label in `items`, so numbering never repeats one. */
25export function coverLabels(counters: Counters, items: readonly Item[]): Counters {
26 const out = { ...counters }
27 for (const item of items) {
28 const letter = item.label[0] as 'D' | 'A'
29 if (letter === 'D' || letter === 'A') out[letter] = Math.max(out[letter], labelNumber(item.label))
30 }
31 return out
32}
33
34function asStatus(value: unknown): Status {
35 return value === 'in_progress' || value === 'completed' ? value : 'pending'
36}
37
38/** A task-list task as an item, or null when it isn't a decision item. */
39export function itemFromTask(task: {
40 id: string
41 subject: string
42 description?: string
43 status?: unknown
44}): Item | null {
45 const label = parseLabel(task.subject)
46 const common = {
47 id: String(task.id),
48 source: 'tasks' as const,
49 status: asStatus(task.status),
50 description: task.description ?? '',
51 }
52 if (label) {
53 return { ...common, label: `${label.letter}${label.number}`, kind: label.kind, body: label.body }
54 }
55 const draft = parseDraft(task.subject) // created while the hook wasn't running: unlabelled
56 if (!draft) return null
57 return { ...common, label: `${LETTER[draft.kind]}?`, kind: draft.kind, body: draft.body }
58}
59
60/** Items with `item` replacing the one of the same source and id, or appended. */
61export function upsert(items: readonly Item[], item: Item): Item[] {
62 const at = items.findIndex(one => one.source === item.source && one.id === item.id)
63 if (at < 0) return [...items, item]
64 return items.map((one, i) => (i === at ? item : one))
65}
66
67export type TaskChange = {
68 taskId: string
69 subject?: string
70 description?: string
71 status?: Status | 'deleted'
72}
73
74/** Apply a TaskUpdate that succeeded to the items; `items` itself when it touches none of them. */
75export function applyUpdate(items: readonly Item[], change: TaskChange): Item[] {
76 const id = String(change.taskId)
77 const current = items.find(one => one.source === 'tasks' && one.id === id)
78 if (change.status === 'deleted') return current ? items.filter(one => one !== current) : (items as Item[])
79 if (!current) {
80 if (change.subject === undefined) return items as Item[]
81 const added = itemFromTask({ id, subject: change.subject, description: change.description, status: change.status })
82 return added ? [...items, added] : (items as Item[])
83 }
84 let next: Item | null = { ...current }
85 if (change.subject !== undefined) {
86 const parsed = itemFromTask({ id, subject: change.subject })
87 next = parsed && { ...parsed, created: current.created }
88 }
89 if (!next) return items.filter(one => one !== current)
90 if (change.description !== undefined) next.description = change.description
91 else next.description = current.description
92 next.status = change.status ? asStatus(change.status) : current.status
93 return items.map(one => (one === current ? (next as Item) : one))
94}
95
96/** Items in the order they were asked, oldest first; unknown times sort first, by label. */
97export function ordered(items: readonly Item[]): Item[] {
98 return [...items].sort(
99 (a, b) =>
100 (a.created ?? 0) - (b.created ?? 0) ||
101 a.label[0]!.localeCompare(b.label[0]!) ||
102 labelNumber(a.label) - labelNumber(b.label),
103 )
104}
105
106export const subjectOf = (item: Item): string => `${item.label} ${item.kind}: ${item.body}`
107
108export function age(created: number | undefined, now: number): string {
109 if (!created) return ''
110 let minutes = Math.max(0, Math.floor((now - created) / 60_000))
111 if (minutes < 60) return `waiting ${minutes}m`
112 let hours = Math.floor(minutes / 60)
113 minutes %= 60
114 if (hours < 24) return `waiting ${hours}h ${minutes}m`
115 const days = Math.floor(hours / 24)
116 hours %= 24
117 return `waiting ${days}d ${hours}h`
118}
119
120/** The `/decisions` listing: open items, or with `all` every item and its ruling. */
121export function listing(items: readonly Item[], now: number, all = false): string {
122 const shown = ordered(items).filter(item => all || isOpen(item))
123 if (shown.length === 0) return all ? 'No decisions or actions this session.' : 'No open decisions or actions.'
124 const lines: string[] = []
125 for (const item of shown) {
126 const tags = item.source === 'ledger' ? ['ledger'] : []
127 const tag = isOpen(item) ? age(item.created, now) : item.status
128 if (tag) tags.push(tag)
129 lines.push(subjectOf(item) + (tags.length ? ` (${tags.join(', ')})` : ''))
130 const ruling = rulingIn(item.description)
131 if (ruling) lines.push(` ${ruling}`)
132 }
133 return lines.join('\n')
134}
135
136/** What the model is told after compaction or resume; null when nothing is open. */
137export function reminder(items: readonly Item[]): string | null {
138 const open = ordered(items).filter(isOpen)
139 if (open.length === 0) return null
140 const lines = ["Open items in this session's decision queue, still waiting on the user:"]
141 for (const item of open) {
142 lines.push(`- ${subjectOf(item)}${item.source === 'ledger' ? ' (fallback ledger)' : ''}`)
143 }
144 return lines.join('\n')
145}
146
147/**
148 * The prompt draft with one answer line per label: `lines` replace any line already answering
149 * the same label and are appended otherwise, so several presses build one message of answers.
150 */
151export function mergeAnswers(draft: string, lines: readonly string[]): string {
152 const out = draft.replace(/\s+$/, '').split('\n').filter((line, i, all) => line !== '' || i < all.length - 1)
153 if (out.length === 1 && out[0] === '') out.pop()
154 for (const line of lines) {
155 const label = line.slice(0, line.indexOf(':') + 1)
156 const at = out.findIndex(one => one.startsWith(label))
157 if (at < 0) out.push(line)
158 else out[at] = line
159 }
160 return out.join('\n')
161}
162hooks/subject.ts 102 lines1// Parse, validate and format the subjects of decision items.
2//
3// A labelled subject is "<label> <KIND>: <body>", for example
4// "D3 DECIDE: retire the old deploy script? (recommend yes) [#812]".
5// The assistant writes "DECIDE: ..." or "ACTION: ..."; the TaskCreate hook adds the label.
6
7import type { Kind, Letter } from '../types'
8
9export const LETTER: Record<Kind, Letter> = { DECIDE: 'D', ACTION: 'A' }
10
11// An optional existing label ("D12 "), the kind keyword, a colon, then the body. The keyword is
12// case-sensitive, so an ordinary task titled "Action: refactor the parser" isn't a decision item.
13const DRAFT = /^\s*(?:[DA]\d+\s+)?(DECIDE|ACTION)\s*:\s*([\s\S]*?)\s*$/
14const LABELLED = /^([DA])(\d+) (DECIDE|ACTION): ([\s\S]*)$/
15// The link is the last bracketed token, at the very end of the body.
16const LINK = /\[[^[\]]*\S[^[\]]*\]$/
17const RECOMMEND = /\(recommend\s+\S/i
18// One level of nested parentheses, so "(recommend B (cheaper))" keeps "B (cheaper)" whole.
19const CHOICE = /\(recommend\s+((?:[^()]|\([^()]*\))*?)\s*\)/i
20
21export type Draft = { kind: Kind; body: string }
22export type Label = { letter: Letter; number: number; kind: Kind; body: string }
23
24/** The draft when the subject is a decision item; null for any other task. */
25export function parseDraft(subject: string): Draft | null {
26 const match = DRAFT.exec(subject ?? '')
27 if (!match) return null
28 return { kind: match[1] as Kind, body: match[2] ?? '' }
29}
30
31/** What's wrong with a draft, as short instructions; empty when it's well formed. */
32export function problems(draft: Draft): string[] {
33 if (!draft.body) return ['write the question or action after the colon']
34 const found: string[] = []
35 if (!LINK.test(draft.body)) {
36 found.push(
37 'end the subject with one link token: [#123], [!45], [owner/repo#123], [<url>] or [no link]',
38 )
39 }
40 if (draft.kind === 'DECIDE' && !RECOMMEND.test(draft.body)) {
41 found.push('a DECIDE item needs a recommendation, written as (recommend <choice>)')
42 }
43 return found
44}
45
46export function formatSubject(label: string, draft: Draft): string {
47 return `${label} ${draft.kind}: ${draft.body}`
48}
49
50/** A subject the hook has already labelled; null for anything else. */
51export function parseLabel(subject: string): Label | null {
52 const match = LABELLED.exec(subject ?? '')
53 if (!match) return null
54 return {
55 letter: match[1] as Letter,
56 number: Number(match[2]),
57 kind: match[3] as Kind,
58 body: match[4] ?? '',
59 }
60}
61
62/** The recommended choice of a DECIDE body, for the Accept button; null when there isn't one. */
63export function recommendation(body: string): string | null {
64 const match = CHOICE.exec(body)
65 return match?.[1] ? match[1] : null
66}
67
68/** The last "Ruling (...)" line of a description, which records how the item was settled. */
69export function rulingIn(description: string): string | null {
70 const lines = description.split('\n').reverse()
71 return lines.find(line => line.startsWith('Ruling (')) ?? null
72}
73
74export type BodyParts = { question: string; recommendation: string | null; link: string | null }
75
76/** A body split for display: the question alone, the recommended choice, and the link token. */
77export function splitBody(body: string): BodyParts {
78 let rest = body.trim()
79 let link: string | null = null
80 const linked = LINK.exec(rest)
81 if (linked) {
82 const token = linked[0].slice(1, -1).trim()
83 link = token.toLowerCase() === 'no link' ? null : token
84 rest = rest.slice(0, linked.index).trim()
85 }
86 const choice = recommendation(rest)
87 if (choice) rest = rest.replace(CHOICE, '').replace(/\s{2,}/g, ' ').trim()
88 return { question: rest, recommendation: choice, link }
89}
90
91/**
92 * A recommendation as a card shows it: the choice for the tag beside the label (up to the first
93 * comma or semicolon, at most `max` characters) and the qualifier after it, if any.
94 */
95export function shortRecommendation(text: string, max = 24): { choice: string; qualifier: string | null } {
96 const cut = text.search(/[,;]/)
97 const head = (cut < 0 ? text : text.slice(0, cut)).trim()
98 const qualifier = cut < 0 ? null : text.slice(cut + 1).trim() || null
99 const choice = head.length > max ? `${head.slice(0, max - 1).trimEnd()}…` : head
100 return { choice, qualifier: head.length > max ? text.trim() : qualifier }
101}
102types/index.d.ts 44 lines1export type Kind = 'DECIDE' | 'ACTION'
2export type Letter = 'D' | 'A'
3export type Status = 'pending' | 'in_progress' | 'completed'
4
5/**
6 * One decision or action. `id` is the task id for a task-list item and the label for a ledger
7 * item. `created` is milliseconds since the epoch, absent for items made before this plugin saw
8 * them.
9 */
10export type Item = {
11 id: string
12 source: 'tasks' | 'ledger'
13 label: string
14 kind: Kind
15 body: string
16 status: Status
17 description: string
18 created?: number
19}
20
21export type Counters = { D: number; A: number }
22
23/** What `$.store` keeps per session, so a resumed session continues its numbering and ledger. */
24export type SessionRecord = {
25 counters: Counters
26 created: Record<string, number>
27 ledger: Item[]
28 touched: number
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 'session-decisions': {
34 items: Item[]
35 counters: Counters
36 created: Record<string, number>
37 showSettled: boolean
38 paneDismissed: boolean
39 autoOpened: boolean
40 ascii: boolean
41 }
42 }
43}
44