SLOPSHOPPER

session-decisions

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…

newpanespinnerrowsguardcommand
v2.0.1PolyForm-Shield-1.0.0updated 2026-10-09lokkju/skills/plugins/session-decisions
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-decisions
│ ┃ session-decisions ✕ › fix the failing auth test and add an audit log call │ ┃ Decisions Nothing waiting on you │ ┃ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /decisions │ ⎿ session-decisions: No open decisions or actions. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · session-decisions
Decisions Nothing waiting on you
README

session-decisions

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.

A Claude Code session queues its decisions and two actions while planning a SQLite to Postgres move; Accept all answers the decisions in one message, and the sidebar drops to the two open actions

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

Install

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.

How it works

Asking

The assistant creates an ordinary task with a subject in one of two forms:

KindSubjectBecomes
DecisionDECIDE: Postgres driver? (recommend psycopg 3) [#12]D3 DECIDE: Postgres driver? (recommend psycopg 3) [#12]
ActionACTION: 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.

Seeing

  • Transcript: the row where the assistant asked becomes the item's card, and shrinks to ✓ D3 · <ruling> once it's settled.
  • Sidebar: in fullscreen, the first new item opens the queue beside the transcript (Claude Code only seats a pane it wasn't asked for from 144 columns). Close it and it stays closed until the queue empties, and then closes itself.
  • Footer: while anything is open, ● 5 decisions sits beside the mode labels. Click it to open the queue; Esc closes it.
  • Cards: a 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.
  • Task list: where the session has TaskCreate, the items are tasks, so ctrl+t lists them too.

Answering

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.

Commands

CommandWhat it does
/decisionsLists the open items
/decisions allAdds the settled ones, with their rulings
/decisions paneOpens the sidebar
/decisions wrapHas the assistant settle every open item before the session ends: escalate it to the tracker, carry it into the handoff, or drop it

After compaction

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.

Requirements

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.

How it stays in sync

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.

Limits

  • The task files are read from ~/.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.
  • Two sessions sharing a task list (CLAUDE_CODE_TASK_LIST_ID) don't see each other's changes until their next session start.
  • Function hooks are early access and their API can change between Claude Code releases.

Development

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.

Re-recording the demo

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.

Source 4 files
hooks/register.tsx 542 lines
1import { 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}
542
hooks/queue.ts 162 lines
1// 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}
162
hooks/subject.ts 102 lines
1// 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}
102
types/index.d.ts 44 lines
1export 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