SLOPSHOPPER

decision-log

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

newpaneguardcommandprompttool
v0.1.1MITupdated 2026-10-24NickyBoi-Su/decision-log
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · decision-log
│ ┃ Decisions ✕ › fix the failing auth test and add an audit log call │ ┃ 0 logged · DECISIONS.md off │ ┃ No decisions yet. ⏺ 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 │ ⎿ decision-log: Decisions pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Decisions
0 logged · DECISIONS.md off No decisions yet.
README

decision-log

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.

The Decisions pane filling up as Claude works in auto mode

Install

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.

Use

  • The pane docks on the right in a terminal at least 144 columns wide; /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.

Source 2 files
hooks/register.tsx 249 lines
1import { 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}
249
types/index.d.ts 23 lines
1export 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