Tracks open decisions Claude raises in a side pane so you can answer them against a reference while work continues

A Claude Code mod that stops open decisions getting lost in the chat.
When Claude needs you to choose something (an approach, an assumption, a missing fact), it logs the question as a decision with a reference: D1, D2, and so on. The decisions sit in a side pane while work carries on, and you answer them whenever you are ready.

In a Claude Code terminal session:
/plugin install decisions --marketplace cga-management/claude-decisions
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
▸ D1 to expand or collapse a decision and read its background./decide D2 <your answer>, or /decide D2 drop to discard one./decisions opens the pane; /decisions clear removes resolved ones. Under Resolved, click ▸ D1 (or "expand all") to see a decision in full: question, background, options and answer.close_decision tool), and the Resolved list marks it "settled in chat".How answers reach Claude:
Decisions are kept per project folder, so a new or resumed chat in the same folder still shows the open ones (the last 30 resolved are kept too).
Notes: the pane opens by itself on terminals 144 columns or wider; run /decisions on narrower ones. Claude decides when to log a decision; you can also ask it to "log that as a decision".
claude plugin validate .
claude plugin test .
To run a working copy: claude --plugin-dir /path/to/claude-decisions.
MIT. See LICENSE.
hooks/register.tsx 485 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Decision } from '../types'
5
6const PANE = 'decisions'
7const TOOL = 'raise_decision'
8const CLOSE_TOOL = 'close_decision'
9const RESOLVED_KEPT = 30
10const CONTINUE_DELAY_MS = 1000
11const list = atom({ plugin: 'decisions', key: 'list' } as const, [])
12const isResolvedShown = atom({ plugin: 'decisions', key: 'isResolvedShown' } as const, false)
13// Survive a hot reload, unlike module variables: which session this is, and
14// the store key of the project folder it runs in.
15const sessionTag = atom({ plugin: 'decisions', key: 'sessionTag' } as const, '')
16const storeKey = atom({ plugin: 'decisions', key: 'storeKey' } as const, '')
17
18const TOOL_DESCRIPTION = [
19 'Record a decision the user needs to make, so it is tracked in their Decisions pane instead of getting lost in the chat.',
20 'Use it whenever you need the user to choose between approaches, confirm an assumption, or supply information you cannot infer, and you can keep working meanwhile (on other parts, or on a stated default).',
21 'Returns a reference like D3. Mention that reference in your reply. The user answers in the pane; the answer arrives as a note in a later tool result or prompt, naming the reference.',
22 'Once every decision raised in one turn is answered and you are idle, you are prompted to carry on.',
23 'Do not raise the same question twice; check the open list this tool returns.',
24 `If the user settles a decision in the chat instead of the pane, call ${CLOSE_TOOL} so it leaves their open list.`,
25].join(' ')
26
27const CLOSE_DESCRIPTION = [
28 'Close an open decision that was settled outside the Decisions pane: the user answered it in the chat, or it no longer matters.',
29 'Give its reference (e.g. D3) and the outcome as the user put it, or why it was withdrawn.',
30 'Do not use it to answer a decision yourself; only the user decides.',
31].join(' ')
32
33let isBusy = false
34let turnId = ''
35
36function nextId(decisions: readonly Decision[]): string {
37 const max = decisions.reduce((top, d) => Math.max(top, Number(d.id.slice(1)) || 0), 0)
38
39 return `D${max + 1}`
40}
41
42function openOnes(decisions: readonly Decision[]): Decision[] {
43 return decisions.filter(d => d.answer === undefined)
44}
45
46// Every change goes through here: the pane redraws and the project's copy is saved.
47async function setList(
48 $: EngineInterface,
49 fn: (all: Decision[]) => Decision[],
50): Promise<Decision[]> {
51 const kept = await update($, list, items => {
52 const all = fn(items)
53 const resolved = all.filter(d => d.answer !== undefined).slice(-RESOLVED_KEPT)
54
55 return all.filter(d => d.answer === undefined || resolved.includes(d))
56 })
57 const open = openOnes(kept)
58 const key = await read($, storeKey)
59
60 if (key !== '') {
61 await $.store.set(key, kept)
62 }
63
64 $.ui.status(open.length === 0 ? undefined : `${open.length} open decision${open.length === 1 ? '' : 's'}`)
65
66 return kept
67}
68
69// `isDeferred`: a slash command holds a turn of its own, so it cannot submit a
70// prompt; the continue check runs once that turn is over.
71async function answer(
72 $: EngineInterface,
73 id: string,
74 text: string,
75 isDeferred = false,
76): Promise<Decision | undefined> {
77 const ref = id.trim().toUpperCase()
78 const value = text.trim()
79 const found = (await read($, list)).find(d => d.id === ref)
80
81 if (!found || value === '') {
82 return undefined
83 }
84
85 await setList($, all =>
86 all.map(d => (d.id === ref ? { ...d, answer: value, isDelivered: false, isExpanded: false } : d)),
87 )
88 await continueNowOrLater($, isDeferred)
89
90 return { ...found, answer: value }
91}
92
93async function dismiss($: EngineInterface, id: string, isDeferred = false): Promise<boolean> {
94 const ref = id.trim().toUpperCase()
95 const isFound = (await read($, list)).some(d => d.id === ref)
96
97 if (isFound) {
98 await setList($, all => all.filter(d => d.id !== ref))
99 await continueNowOrLater($, isDeferred)
100 }
101
102 return isFound
103}
104
105function note(d: Decision): string {
106 return (
107 `The user recorded decision ${d.id} (question: "${d.question}"): ${d.answer}\n` +
108 `Take this into account in the current work, and say briefly how you applied ${d.id}.`
109 )
110}
111
112// Answers not yet passed to Claude become notes, and are marked delivered.
113async function drainAnswers($: EngineInterface): Promise<string[]> {
114 const pending = (await read($, list)).filter(d => d.answer !== undefined && !d.isDelivered)
115
116 if (pending.length === 0) {
117 return []
118 }
119
120 await setList($, all =>
121 all.map(d => (pending.some(p => p.id === d.id) ? { ...d, isDelivered: true } : d)),
122 )
123
124 return pending.map(note)
125}
126
127// While idle, once the work a set of answers was blocking has nothing left
128// open in its group, prompt Claude to carry on with those answers.
129async function maybeContinue($: EngineInterface): Promise<void> {
130 if (isBusy) {
131 return
132 }
133
134 const all = await read($, list)
135 const tag = `${await read($, sessionTag)}:`
136 const pending = all.filter(
137 d => d.answer !== undefined && !d.isDelivered && d.group?.startsWith(tag) === true,
138 )
139 const isBlocked = openOnes(all).some(d => pending.some(p => p.group === d.group))
140
141 if (pending.length === 0 || isBlocked) {
142 return
143 }
144
145 const notes = await drainAnswers($)
146 isBusy = true
147 void $.prompt.submit({
148 text: [
149 `Decisions answered: ${pending.map(d => d.id).join(', ')}. Carry on with the work they were blocking.`,
150 ...notes,
151 ].join('\n\n'),
152 })
153}
154
155async function continueNowOrLater($: EngineInterface, isDeferred: boolean): Promise<void> {
156 if (isDeferred) {
157 $.clock.after(CONTINUE_DELAY_MS, () => void maybeContinue($).catch(() => undefined))
158 } else {
159 await maybeContinue($)
160 }
161}
162
163export const register: Register = on => {
164 on('session.start', async ($, e, next) => {
165 if ((await read($, sessionTag)) === '') {
166 await update($, sessionTag, () => crypto.randomUUID())
167 }
168
169 const key = `project:${e.cwd}`
170
171 if ((await read($, storeKey)) !== key) {
172 await update($, storeKey, () => key)
173 const saved = await $.store.get(key)
174 // A session that already holds decisions (from before this was saved) keeps them.
175 await update($, list, current =>
176 current.length > 0 || !Array.isArray(saved) ? current : (saved as Decision[]),
177 )
178 }
179
180 await $.tool.register({
181 name: TOOL,
182 description: TOOL_DESCRIPTION,
183 isDeferred: false,
184 inputSchema: {
185 type: 'object',
186 properties: {
187 question: {
188 type: 'string',
189 description: 'The decision, phrased as a short, self-contained question.',
190 },
191 options: {
192 type: 'array',
193 items: { type: 'string' },
194 description: 'Concrete choices, if any (2 to 4, short labels). Put a recommended one first.',
195 },
196 detail: {
197 type: 'string',
198 description: 'One or two lines of context: trade-offs, your default if unanswered.',
199 },
200 },
201 required: ['question'],
202 },
203 })
204 await $.tool.register({
205 name: CLOSE_TOOL,
206 description: CLOSE_DESCRIPTION,
207 isDeferred: false,
208 inputSchema: {
209 type: 'object',
210 properties: {
211 id: { type: 'string', description: 'The reference, e.g. D3.' },
212 outcome: {
213 type: 'string',
214 description: 'What the user decided, in their words, or "withdrawn: <why>".',
215 },
216 },
217 required: ['id', 'outcome'],
218 },
219 })
220 await $.command.register({
221 name: 'decide',
222 description: 'Record a decision: /decide D2 <your answer>, or /decide D2 drop',
223 })
224 await $.command.register({
225 name: 'decisions',
226 description: 'Open the Decisions pane; /decisions clear removes resolved ones',
227 })
228 await setList($, all => all)
229
230 if (openOnes(await read($, list)).length > 0) {
231 void $.ui.open({ id: PANE, title: 'Decisions' })
232 }
233
234 return next(e)
235 })
236
237 on('turn.start', ($, e, next) => {
238 // A subagent's turn starts inside the main one: the group stays the main turn's.
239 if (!isBusy) {
240 turnId = e.turnId
241 }
242
243 isBusy = true
244
245 return next(e)
246 })
247
248 on('turn.complete', async ($, e, next) => {
249 const done = await next(e)
250
251 if (e.agentId === undefined) {
252 isBusy = false
253 // Answers given mid-turn that no later tool call carried.
254 await maybeContinue($).catch(() => undefined)
255 }
256
257 return done
258 })
259
260 on('tool.call', { tool: 'mcp__decisions__close_decision' }, async ($, e) => {
261 const input = e as { id?: unknown; outcome?: unknown }
262 const ref = typeof input.id === 'string' ? input.id.trim().toUpperCase() : ''
263 const outcome = typeof input.outcome === 'string' ? input.outcome.trim() : ''
264 const open = openOnes(await read($, list))
265
266 if (!open.some(d => d.id === ref) || outcome === '') {
267 const listed = open.map(d => `${d.id}: ${d.question}`).join('\n') || 'none'
268
269 return { deny: `${ref || 'That'} is not an open decision, or the outcome is empty. Open decisions:\n${listed}` }
270 }
271
272 // Claude already knows the outcome, so there is nothing to deliver.
273 await setList($, all =>
274 all.map(d =>
275 d.id === ref
276 ? { ...d, answer: outcome, isDelivered: true, isExpanded: false, isClosedInChat: true }
277 : d,
278 ),
279 )
280 $.ui.toast(`Decision ${ref} closed: ${outcome}`)
281
282 return { result: `${ref} closed with outcome: ${outcome}` }
283 })
284
285 on('tool.call', { tool: 'mcp__decisions__raise_decision' }, async ($, e) => {
286 const input = e as { question?: unknown; options?: unknown; detail?: unknown }
287 const question = typeof input.question === 'string' ? input.question.trim() : ''
288
289 if (question === '') {
290 return { deny: 'question is required.' }
291 }
292
293 const options = Array.isArray(input.options)
294 ? input.options.filter((o): o is string => typeof o === 'string' && o.trim() !== '')
295 : []
296 const detail = typeof input.detail === 'string' && input.detail.trim() !== '' ? input.detail.trim() : undefined
297 const before = await read($, list)
298 const id = nextId(before)
299 const group = `${await read($, sessionTag)}:${turnId}`
300 const decision: Decision = { id, question, options, detail, isDelivered: false, isExpanded: true, group }
301 await setList($, all => [...all, decision])
302 $.ui.toast(`Decision ${id} raised: ${question}`)
303 void $.ui.open({ id: PANE, title: 'Decisions' })
304
305 const open = openOnes(before).map(d => `${d.id}: ${d.question}`)
306
307 return {
308 result: [
309 `Recorded as ${id}. Refer to it as ${id}; the user answers in their Decisions pane.`,
310 open.length > 0 ? `Other open decisions:\n${open.join('\n')}` : '',
311 ]
312 .filter(Boolean)
313 .join('\n\n'),
314 }
315 })
316
317 // Mid-task delivery: answers ride along with Claude's next main-loop tool result.
318 on('tool.call', async ($, e, next) => {
319 const ran = await next(e)
320
321 if (e.agentId !== undefined || ('deny' in ran && ran.deny !== undefined)) {
322 return ran
323 }
324
325 const notes = await drainAnswers($).catch(() => [])
326
327 return notes.length === 0 ? ran : { ...ran, context: [...(ran.context ?? []), ...notes] }
328 })
329
330 // Between turns: answers ride along with the next prompt.
331 on('prompt.submit', async ($, e, next) => {
332 if (e.text.trimStart().startsWith('/')) {
333 return next(e)
334 }
335
336 const notes = await drainAnswers($).catch(() => [])
337
338 return next(notes.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...notes] })
339 })
340
341 on('command.run', { command: 'decisions' }, async ($, e) => {
342 if (e.args.trim().toLowerCase() === 'clear') {
343 const kept = await setList($, all => openOnes(all))
344
345 return { text: `Resolved decisions cleared; ${kept.length} open.` }
346 }
347
348 await $.ui.open({ id: PANE, title: 'Decisions' })
349
350 return { text: 'Decisions pane opened.' }
351 })
352
353 on('command.run', { command: 'decide' }, async ($, e) => {
354 const match = e.args.trim().match(/^(D\d+)\s+([\s\S]+)$/i)
355 const [, ref = '', text = ''] = match ?? []
356
357 if (!match) {
358 return { text: 'Usage: /decide D2 <your answer>, or /decide D2 drop' }
359 }
360
361 if (text.trim().toLowerCase() === 'drop') {
362 return (await dismiss($, ref, true))
363 ? { text: `${ref.toUpperCase()} dropped.` }
364 : { text: `No decision ${ref.toUpperCase()} found.` }
365 }
366
367 const done = await answer($, ref, text, true)
368
369 if (!done) {
370 return { text: `No decision ${ref.toUpperCase()} found.` }
371 }
372
373 return { text: `${done.id} recorded: ${done.answer}. Claude gets it on its next step.` }
374 })
375
376 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
377 const elements = $.ui.resolve(e)
378 const { Box, Button, Text } = elements
379 // Mobile draws no text field; there the options and /decide still work.
380 const Input = 'Input' in elements ? elements.Input : undefined
381 const all = await read($, list)
382 const open = openOnes(all)
383 const resolved = all.filter(d => d.answer !== undefined)
384 const isShown = await read($, isResolvedShown)
385 const toggle = (id: string) =>
386 setList($, items => items.map(d => (d.id === id ? { ...d, isExpanded: !d.isExpanded } : d)))
387
388 return (
389 <Box flexDirection="column">
390 <Text bold>Open ({open.length})</Text>
391 {open.length === 0 && <Text dimColor>No open decisions.</Text>}
392 {open.map(d => (
393 <Box key={d.id} flexDirection="column" marginBottom={d.isExpanded ? 1 : 0}>
394 <Box>
395 <Button key={`toggle-${d.id}`} plain onPress={() => toggle(d.id)}>
396 {d.isExpanded ? '▾' : '▸'} {d.id}
397 </Button>
398 <Text wrap={d.isExpanded ? 'wrap' : 'truncate-end'}> {d.question}</Text>
399 </Box>
400 {d.isExpanded && (
401 <Box flexDirection="column" paddingLeft={2}>
402 {d.detail !== undefined && <Text italic>{d.detail}</Text>}
403 {d.options.length > 0 && (
404 <Box flexWrap="wrap" gap={1}>
405 {d.options.map((option, i) => (
406 <Button
407 key={`option-${d.id}-${i}`}
408 label={option}
409 onPress={() => answer($, d.id, option)}
410 />
411 ))}
412 </Box>
413 )}
414 {Input !== undefined && (
415 <Input
416 key={`answer-${d.id}`}
417 label="Decision: "
418 placeholder={d.options.length > 0 ? 'pick above or type your own' : 'type your answer'}
419 submitLabel="record"
420 onSubmit={(value: string) => answer($, d.id, value)}
421 />
422 )}
423 <Button key={`drop-${d.id}`} plain dimColor label="drop" onPress={() => dismiss($, d.id)} />
424 </Box>
425 )}
426 </Box>
427 ))}
428 {resolved.length > 0 && (
429 <Box flexDirection="column" marginTop={1}>
430 <Box gap={2}>
431 <Button key="toggle-resolved" plain onPress={() => update($, isResolvedShown, v => !v)}>
432 {isShown ? '▾' : '▸'} Resolved ({resolved.length})
433 </Button>
434 {isShown && (
435 <Button
436 key="expand-resolved"
437 plain
438 dimColor
439 label={resolved.every(d => d.isExpanded) ? 'collapse all' : 'expand all'}
440 onPress={() => {
441 const isOpening = !resolved.every(d => d.isExpanded)
442
443 return setList($, items =>
444 items.map(d => (d.answer !== undefined ? { ...d, isExpanded: isOpening } : d)),
445 )
446 }}
447 />
448 )}
449 </Box>
450 {isShown &&
451 resolved.map(d => (
452 <Box key={d.id} flexDirection="column" paddingLeft={2} marginBottom={d.isExpanded ? 1 : 0}>
453 <Box>
454 <Button key={`toggle-${d.id}`} plain dimColor onPress={() => toggle(d.id)}>
455 {d.isExpanded ? '▾' : '▸'} {d.id}
456 </Button>
457 <Text dimColor wrap={d.isExpanded ? 'wrap' : 'truncate-end'}> {d.question}</Text>
458 </Box>
459 {d.isExpanded && d.detail !== undefined && (
460 <Box paddingLeft={4}>
461 <Text italic dimColor wrap="wrap">{d.detail}</Text>
462 </Box>
463 )}
464 {d.isExpanded && d.options.length > 0 && (
465 <Box paddingLeft={4}>
466 <Text dimColor wrap="wrap">Options: {d.options.join(' | ')}</Text>
467 </Box>
468 )}
469 <Box paddingLeft={4}>
470 <Text wrap={d.isExpanded ? 'wrap' : 'truncate-end'}>
471 → {d.answer}
472 <Text dimColor>
473 {d.isClosedInChat === true ? ' (settled in chat)' : d.isDelivered ? '' : ' (not yet sent)'}
474 </Text>
475 </Text>
476 </Box>
477 </Box>
478 ))}
479 </Box>
480 )}
481 </Box>
482 )
483 })
484}
485types/index.d.ts 21 lines1export type Decision = {
2 id: string
3 question: string
4 options: string[]
5 detail?: string
6 answer?: string
7 isDelivered: boolean
8 isExpanded: boolean
9 // Closed by Claude after the user settled it in the chat.
10 isClosedInChat?: boolean
11 // `<session tag>:<turn id>` of the main turn that raised it: decisions sharing
12 // a group block the same piece of work.
13 group?: string
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 decisions: { list: Decision[]; isResolvedShown: boolean; sessionTag: string; storeKey: string }
19 }
20}
21