A pane beside the chat listing the implicit decisions Claude makes while it works, with an optional DECISIONS.md

A Claude Code mod that shows, in a pane beside the chat, the implicit decisions Claude makes while it works: the defaults it picks, what it assumes about you, the repo conventions it follows, and what it leaves out. Handy in auto mode, where you can catch a wrong assumption before more work builds on it.

At the prompt of a Claude Code terminal session:
/plugin install decision-log --marketplace NickyBoi-Su/decision-log
From a shell:
claude plugin install decision-log --marketplace NickyBoi-Su/decision-log
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with NickyBoi-Su/decision-log, then Add plugin.
/decisions opens it at any width./decisions file on keeps one DECISIONS.md in the project: a section per session with a summary of what you asked and every decision, updated at the end of each turn. Off by default./decisions write writes it once; /decisions clear empties the session's list.Decisions are self-reported by Claude through a note_decision tool, so it logs what it notices deciding, not everything.
hooks/register.tsx 249 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Decision, DecisionKind } from '../types'
5
6const PANE = 'decisions'
7const COMMAND = 'decisions'
8const TOOL = 'note_decision'
9const TOOL_ID = 'mcp__decision-log__note_decision'
10const FILE_OPTION = 'decision-log.writeDecisionsFile'
11const FILE = 'DECISIONS.md'
12const FILE_HEADER =
13 '# Decisions\n\nImplicit decisions Claude made while working, logged by the decision-log mod. One section per session.\n'
14
15const KINDS: readonly DecisionKind[] = ['default', 'assumption', 'convention', 'scope']
16const KIND_COLOR: Record<DecisionKind, string> = {
17 default: 'suggestion',
18 assumption: 'warning',
19 convention: 'success',
20 scope: 'error',
21}
22
23// Change a tag when its value's fields change: an earlier version's value
24// outlives a reload and is read as the initial one instead.
25const decisions = atom({ plugin: 'decision-log', key: 'decisions' } as const, [], {
26 shape: 'decisions-1',
27})
28const asks = atom({ plugin: 'decision-log', key: 'asks' } as const, [], {
29 shape: 'asks-1',
30})
31
32// A managed plugin passes prompt.compose on past this one, so the rules ride
33// in the tool's description and a reminder beside each prompt instead.
34const RULES = `Record an implicit decision so the user can review it in their Decisions pane.
35
36While you work, call this tool each time you make a choice the user did not explicitly ask for and might have made differently. Log it as you make it, one decision per call, in one sentence. The kinds:
37- default: a value or tool you picked (library, flag, file location, naming, format)
38- assumption: something you assumed about the user (intent, skill, preferences, environment)
39- convention: a repo convention you inferred and followed
40- scope: something you chose not to do, deferred, or cut
41
42Skip choices the request, the code or a tool's output already settled. Do not mention the log in your replies.`
43
44const REMINDER = `Decision log: while doing this, call ${TOOL_ID} for each implicit choice (default, assumption about the user, repo convention, scope cut) as you make it.`
45
46const clip = (text: string, max: number) =>
47 text.length <= max ? text : `${text.slice(0, max - 1)}…`
48
49const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
50
51// Module variables start over on a reload; both only save work.
52let writtenCount = -1
53let summary: { asks: number; text: string } | undefined
54
55async function summarize($: EngineInterface, list: readonly string[]) {
56 if (list.length === 0) return '(no prompt recorded)'
57 if (summary?.asks === list.length) return summary.text
58
59 const numbered = list.map((ask, i) => `${i + 1}. ${clip(oneLine(ask), 600)}`).join('\n')
60 const done = await $.model.complete({
61 model: 'haiku',
62 prompt: `Summarize in one or two sentences what the user asked for across this coding session. Their prompts, in order:\n${numbered}\n\nReply with the summary only.`,
63 })
64 const text = done.isAnswered ? oneLine(done.text) : clip(oneLine(list[0] ?? ''), 300)
65 summary = { asks: list.length, text }
66
67 return text
68}
69
70async function writeFile($: EngineInterface) {
71 const list = await read($, decisions)
72 if (list.length === 0) return false
73
74 const sessionId = await $.session.id()
75 const asked = await summarize($, await read($, asks))
76 const open = `<!-- decision-log:${sessionId} -->`
77 const close = `<!-- /decision-log:${sessionId} -->`
78 const rows = list.map(d => {
79 const why = d.why ? ` _Why:_ ${d.why}` : ''
80 const instead = d.alternatives ? ` _Instead of:_ ${d.alternatives}` : ''
81 const where = d.agentId ? `turn ${d.turn}, subagent` : `turn ${d.turn}`
82 return `- **${d.kind}**: ${d.decision}${why}${instead} (${where})`
83 })
84 const block = [
85 open,
86 `## ${list[0]?.at.slice(0, 10) ?? ''} · session ${sessionId.slice(0, 8)}`,
87 '',
88 `**Asked:** ${asked}`,
89 '',
90 ...rows,
91 close,
92 ].join('\n')
93
94 const existing = (await $.fs.exists(FILE)) ? await $.fs.read(FILE) : FILE_HEADER
95 const start = existing.indexOf(open)
96 const end = existing.indexOf(close)
97 const text =
98 start >= 0 && end > start
99 ? existing.slice(0, start) + block + existing.slice(end + close.length)
100 : `${existing.trimEnd()}\n\n${block}\n`
101
102 await $.fs.write(FILE, text)
103 writtenCount = list.length
104
105 return true
106}
107
108export const register: Register = (on, options) => {
109 const isFileOn = options.writeDecisionsFile === true
110
111 on('session.start', async ($, e, next) => {
112 await $.tool.register({
113 name: TOOL,
114 description: RULES,
115 inputSchema: {
116 type: 'object',
117 properties: {
118 kind: { type: 'string', enum: KINDS },
119 decision: { type: 'string', description: 'What you chose, in one sentence.' },
120 why: { type: 'string', description: 'What the choice rests on.' },
121 alternatives: { type: 'string', description: 'What you could have done instead.' },
122 },
123 required: ['kind', 'decision', 'why'],
124 },
125 isDeferred: false,
126 })
127 await $.command.register({
128 name: COMMAND,
129 description: 'Decision log: open the pane, or `file on|off`, `write`, `clear`',
130 })
131 void $.ui.open({ id: PANE, title: 'Decisions' })
132
133 return next(e)
134 })
135
136 on('prompt.submit', async ($, e, next) => {
137 if (e.command !== undefined || e.isMeta !== undefined || e.text.trim() === '') return next(e)
138
139 await update($, asks, list => [...list, clip(e.text.trim(), 2000)].slice(-50))
140
141 return next({ ...e, context: [...(e.context ?? []), REMINDER] })
142 })
143
144 on('tool.call', { tool: TOOL_ID }, async ($, e) => {
145 const input = e as unknown as Record<string, unknown>
146 const decision = oneLine(String(input.decision ?? ''))
147 if (decision === '') return { deny: `${TOOL} needs a decision.` }
148
149 const kind = KINDS.find(k => k === input.kind) ?? 'default'
150 const alternatives = oneLine(String(input.alternatives ?? ''))
151 const entry: Decision = {
152 id: e.tool_use_id,
153 kind,
154 decision,
155 why: oneLine(String(input.why ?? '')),
156 ...(alternatives === '' ? {} : { alternatives }),
157 turn: (await read($, asks)).length,
158 at: new Date().toISOString(),
159 ...(e.agentId === undefined ? {} : { agentId: e.agentId }),
160 }
161 await update($, decisions, list => [...list, entry].slice(-500))
162
163 return { result: 'Logged.' }
164 })
165
166 on('turn.complete', async ($, e, next) => {
167 const done = await next(e)
168 if (isFileOn && e.agentId === undefined) {
169 const count = (await read($, decisions)).length
170 if (count !== writtenCount) await writeFile($)
171 }
172
173 return done
174 })
175
176 on('session.end', async ($, e, next) => {
177 if (isFileOn) await writeFile($)
178 if (e.reason === 'clear') {
179 // A /clear starts a new session id with no session.start.
180 await update($, decisions, () => [])
181 await update($, asks, () => [])
182 writtenCount = -1
183 summary = undefined
184 }
185
186 return next(e)
187 })
188
189 on('command.run', { command: COMMAND }, async ($, e) => {
190 const arg = e.args.trim().toLowerCase()
191
192 if (arg === 'file on' || arg === 'file off') {
193 const { deny } = await $.config.set({ key: FILE_OPTION, value: arg === 'file on' })
194 if (deny !== undefined) return { text: `Could not change the setting: ${deny}` }
195 return {
196 text: arg === 'file on' ? `${FILE} is on: it updates at the end of each turn.` : `${FILE} is off.`,
197 }
198 }
199 if (arg === 'write') {
200 return { text: (await writeFile($)) ? `Wrote this session to ${FILE}.` : 'No decisions logged yet.' }
201 }
202 if (arg === 'clear') {
203 await update($, decisions, () => [])
204 return { text: 'Cleared this session’s decisions.' }
205 }
206
207 await $.ui.open({ id: PANE, title: 'Decisions' })
208 return { text: 'Decisions pane opened.' }
209 })
210
211 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
212 const { Box, Text } = $.ui.resolve(e)
213 const list = await read($, decisions)
214 const prompts = await read($, asks)
215 const width = Math.max(20, e.props.bodyColumns)
216 const room = Math.max(1, Math.floor(((e.viewport?.rows ?? 24) - 4) / 3))
217 const shown = list.slice(-room)
218 const status = `${list.length} logged · ${FILE} ${isFileOn ? 'on' : 'off'}`
219
220 return (
221 <Box flexDirection="column">
222 <Text dimColor>{status}</Text>
223 {list.length === 0 && <Text dimColor>No decisions yet.</Text>}
224 {shown.map((d, i) => {
225 const isNewTurn = i === 0 || shown[i - 1]?.turn !== d.turn
226 const ask = prompts[d.turn - 1]
227 return (
228 <Box flexDirection="column" marginTop={isNewTurn ? 1 : 0}>
229 {isNewTurn && (
230 <Text bold>{clip(`Turn ${d.turn}${ask ? `: ${oneLine(ask)}` : ''}`, width)}</Text>
231 )}
232 <Box flexDirection="row">
233 <Text color={KIND_COLOR[d.kind]}>{d.kind.padEnd(11)}</Text>
234 <Text>{d.decision}</Text>
235 </Box>
236 {d.why !== '' && (
237 <Box flexDirection="row">
238 <Text>{' '.repeat(11)}</Text>
239 <Text dimColor>{`why: ${d.why}`}</Text>
240 </Box>
241 )}
242 </Box>
243 )
244 })}
245 </Box>
246 )
247 })
248}
249types/index.d.ts 23 lines1export type DecisionKind = 'default' | 'assumption' | 'convention' | 'scope'
2
3export type Decision = {
4 id: string
5 kind: DecisionKind
6 decision: string
7 why: string
8 alternatives?: string
9 // How many prompts the person had sent when it was logged.
10 turn: number
11 at: string
12 agentId?: string
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'decision-log': {
18 decisions: Shaped<Decision[]>
19 asks: Shaped<string[]>
20 }
21 }
22}
23