Keeps decisions mentioned in replies in a band above the prompt until they are written to your decision log.

A small plugin ("mod") for Claude Code's terminal UI that keeps the decisions mentioned in Claude's replies in a band above the prompt until they are written to your decision log.
日本語の説明は README.ja.md にあります。
docs/decisions/) and the rule is "record it right away".Not for you if you run Claude non-interactively (claude -p).
After a reply that contains a decision sentence, a band appears above the prompt:
📝 Unrecorded decisions (2): We decided to use pnpm for the m… / Going with option B for the cache …
Press the band to open every decision in full (numbered, wrapped to the width); press it again to fold it back to one line. It stays there, across turns, until an Edit, Write or MultiEdit to a path containing record_path succeeds. /decisions lists the items with numbers, /decisions done 2 removes one, /decisions clear removes all.
claude plugin marketplace add i-noma-ru/claude-decision-tracker
claude plugin install decision-tracker@claude-decision-tracker
Or for one session only, from a clone:
claude --plugin-dir /path/to/claude-decision-tracker
All settings have defaults. Change them with /plugin configure decision-tracker@claude-decision-tracker or in /config.
| Setting | Default | Meaning |
|---|---|---|
keywords | we decided, decided to, decision:, agreed to, going with, and six Japanese phrases (裁定, に決めました, と決めました, に決定, を採用します, を採用しました) | A sentence containing one of these (case-insensitive) is kept. |
record_path | docs/decisions/ | A successful Edit, Write or MultiEdit to a path containing this text clears the band. |
max_items | 20 | Oldest items are dropped beyond this count. |
., 。 and line breaks) with code blocks and inline code removed. Sentences containing a keyword are kept, cut to 80 characters. Questions (ending in ?, ?, ますか, でしょうか) are skipped so "which should we decide on?" is not mistaken for a decision. Duplicates are kept once.The text of each finished reply, and the file_path and result of Edit / Write calls. It reads no files and sends nothing anywhere.
claude plugin validate .
claude plugin test .
MIT. See LICENSE.
hooks/register.tsx 132 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Decision } from '../types'
5import { addDecisions, extractDecisions, formatBand, formatExpanded, formatList, isRecordWrite, parseDecisionArgs, readSettings, USAGE } from './logic'
6
7// The band reads from $.state, not a module variable: it survives a hot reload, and a write redraws only the band that read it.
8const decisions = atom({ plugin: 'decision-tracker', key: 'decisions' } as const, [] as Decision[])
9// Pressing the band toggles between the one-line summary and every decision wrapped in full.
10const isExpanded = atom({ plugin: 'decision-tracker', key: 'isExpanded' } as const, false)
11const BAND_KEY = 'band'
12
13export const register: Register = (on, options) => {
14 const settings = readSettings(options)
15
16 on('session.start', async ($, e, next) => {
17 const done = await next(e)
18
19 try {
20 await $.command.register({
21 name: 'decisions',
22 description: `List or clear unrecorded decisions (cleared automatically when ${settings.recordPath} is written)`,
23 argumentHint: '[clear | done <n>]',
24 })
25 } catch {
26 // A failed registration must not stop the session from starting.
27 }
28
29 return done
30 })
31
32 on('turn.complete', async ($, e, next) => {
33 const done = await next(e)
34
35 // Only the main agent's answers count: not subagents, aborts, errors or refusals.
36 if (e.agentId !== undefined || e.reason !== 'answer') {
37 return done
38 }
39
40 try {
41 const found = extractDecisions(e.answer, settings.keywords)
42
43 if (found.length > 0) {
44 const at = await $.clock.now()
45 await update($, decisions, list => addDecisions(list, found, at, settings.maxItems))
46 }
47 } catch {
48 // Failing to record must not block the reply.
49 }
50
51 return done
52 })
53
54 // MultiEdit is not in this build's built-in tool table, so compare String(e.tool) instead of using a matcher.
55 on('tool.call', async ($, e, next) => {
56 const ran = await next(e)
57 const filePath = 'file_path' in e ? e.file_path : undefined
58
59 if (ran.deny === undefined && ran.isError !== true && isRecordWrite(String(e.tool), filePath, settings.recordPath)) {
60 try {
61 await update($, decisions, () => [])
62 } catch {
63 // Failing to clear must not alter the tool's result.
64 }
65 }
66
67 return ran
68 })
69
70 on('command.run', { command: 'decisions' }, async ($, e) => {
71 const parsed = parseDecisionArgs(e.args)
72
73 if (parsed.kind === 'invalid') {
74 return { text: USAGE }
75 }
76
77 if (parsed.kind === 'clear') {
78 await update($, decisions, () => [])
79
80 return { text: 'Cleared all unrecorded decisions.' }
81 }
82
83 if (parsed.kind === 'done') {
84 const list = await read($, decisions)
85 const target = list[parsed.index - 1]
86
87 if (target === undefined) {
88 return { text: `No decision #${parsed.index} (1–${list.length}).` }
89 }
90
91 await update($, decisions, current => current.filter(item => item.text !== target.text))
92
93 return { text: `Removed: ${target.text}` }
94 }
95
96 return { text: formatList(await read($, decisions), settings.recordPath) }
97 })
98
99 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
100 const list = await read($, decisions)
101 const line = formatBand(list)
102
103 if (line === '' || e.props.hasSurvey) {
104 return next(e)
105 }
106
107 const { Box, Button, Text } = $.ui.resolve(e)
108 const toggle = () => update($, isExpanded, prev => !prev)
109
110 if (!(await read($, isExpanded))) {
111 return (
112 <Box>
113 <Button key={BAND_KEY} plain label={line} onPress={toggle} />
114 </Box>
115 )
116 }
117
118 const [head, ...rows] = formatExpanded(list, e.props.maxRows)
119
120 return (
121 <Box flexDirection="column">
122 <Button key={BAND_KEY} plain label={head ?? ''} onPress={toggle} />
123 {rows.map(row => (
124 <Text key={row} wrap="wrap">
125 {row}
126 </Text>
127 ))}
128 </Box>
129 )
130 })
131}
132hooks/logic.ts 171 lines1import type { Decision } from '../types'
2
3export const DEFAULT_KEYWORDS = [
4 'we decided',
5 'decided to',
6 'decision:',
7 'agreed to',
8 'going with',
9 '裁定',
10 'に決めました',
11 'と決めました',
12 'に決定',
13 'を採用します',
14 'を採用しました',
15]
16export const DEFAULT_RECORD_PATH = 'docs/decisions/'
17export const DEFAULT_MAX_ITEMS = 20
18export const MAX_TEXT = 80
19
20const QUESTION_TAILS = ['?', '?', 'ますか', 'でしょうか']
21const RECORD_TOOLS = ['Edit', 'Write', 'MultiEdit']
22
23/** The settings the hooks read, normalized from the manifest's `options`. */
24export type Settings = { keywords: string[]; recordPath: string; maxItems: number }
25
26/** A `multiple` string field may arrive as an array or as one string; keep only non-empty strings. */
27export function toStringList(value: unknown): string[] {
28 return ([] as unknown[]).concat(value as never).filter((item): item is string => typeof item === 'string' && item.trim() !== '')
29}
30
31export function readSettings(options: Readonly<Record<string, unknown>>): Settings {
32 const keywords = toStringList(options.keywords)
33 const recordPath = typeof options.record_path === 'string' && options.record_path !== '' ? options.record_path : DEFAULT_RECORD_PATH
34 const maxItems = typeof options.max_items === 'number' && options.max_items >= 1 ? Math.floor(options.max_items) : DEFAULT_MAX_ITEMS
35
36 return { keywords: keywords.length > 0 ? keywords : DEFAULT_KEYWORDS, recordPath, maxItems }
37}
38
39/** Remove fenced blocks first (so a backtick inside one is not left over), then inline code. */
40export function stripCode(text: string): string {
41 return text.replace(/```[\s\S]*?(```|$)/g, ' ').replace(/`[^`\n]*`/g, ' ')
42}
43
44/** Count characters by code point so an emoji does not shift the cut by one. */
45export function clip(text: string, max: number = MAX_TEXT): string {
46 const chars = Array.from(text)
47
48 return chars.length > max ? `${chars.slice(0, max - 3).join('')}…` : text
49}
50
51function isQuestion(sentence: string): boolean {
52 const body = sentence.replace(/[.。\s]+$/, '')
53
54 return QUESTION_TAILS.some(tail => body.endsWith(tail))
55}
56
57/** Split on `。`, on `.` followed by whitespace or the end (so `v1.2` stays whole), and on line breaks. */
58function splitSentences(text: string): string[] {
59 return text.split(/(?<=。)|(?<=\.)(?=\s|$)|\n/)
60}
61
62/** Pick the sentences that state a decision, each cut to 80 characters, without duplicates. */
63export function extractDecisions(answer: string, keywords: readonly string[] = DEFAULT_KEYWORDS): string[] {
64 const found: string[] = []
65 const needles = keywords.map(word => word.toLowerCase())
66
67 for (const raw of splitSentences(stripCode(answer))) {
68 const sentence = raw.trim()
69
70 // Skip blank lines and lines of punctuation only (rules, a bare "- " bullet).
71 if (sentence === '' || !/[\p{L}\p{N}]/u.test(sentence)) {
72 continue
73 }
74
75 const lower = sentence.toLowerCase()
76
77 if (!needles.some(word => lower.includes(word)) || isQuestion(sentence)) {
78 continue
79 }
80
81 const text = clip(sentence)
82
83 if (!found.includes(text)) {
84 found.push(text)
85 }
86 }
87
88 return found
89}
90
91/** Append to the list (same text is not added twice; the oldest are dropped past `maxItems`). */
92export function addDecisions(list: readonly Decision[], texts: readonly string[], at: number, maxItems: number = DEFAULT_MAX_ITEMS): Decision[] {
93 const next = [...list]
94
95 for (const text of texts) {
96 if (!next.some(item => item.text === text)) {
97 next.push({ text, at })
98 }
99 }
100
101 return next.slice(Math.max(0, next.length - maxItems))
102}
103
104/** Is this tool call a write to the decision log? */
105export function isRecordWrite(tool: string, filePath: unknown, recordPath: string = DEFAULT_RECORD_PATH): boolean {
106 return RECORD_TOOLS.includes(tool) && typeof filePath === 'string' && filePath.includes(recordPath)
107}
108
109/** The band's one line; '' when there is nothing to show. */
110export function formatBand(list: readonly Decision[]): string {
111 if (list.length === 0) {
112 return ''
113 }
114
115 const heads = list.slice(0, 2).map(item => Array.from(item.text).slice(0, 40).join(''))
116 const rest = list.length > 2 ? ` … (+${list.length - 2} more)` : ''
117
118 return `📝 Unrecorded decisions (${list.length}): ${heads.join(' / ')}${rest}`
119}
120
121/** The band once pressed open: a heading, then every decision numbered; what maxRows cannot hold folds into a count. */
122export function formatExpanded(list: readonly Decision[], maxRows: number): string[] {
123 if (list.length === 0) {
124 return []
125 }
126
127 const head = `📝 Unrecorded decisions (${list.length}) — press to fold, /decisions done <n> to remove one`
128 const room = Math.max(0, maxRows - 1)
129 const items = list.map((item, i) => `${i + 1}. ${item.text}`)
130
131 if (items.length <= room) {
132 return [head, ...items]
133 }
134
135 const shown = items.slice(0, Math.max(0, room - 1))
136
137 return [head, ...shown, `… (+${items.length - shown.length} more, see /decisions)`]
138}
139
140export type DecisionCommand ={ kind: 'list' } | { kind: 'clear' } | { kind: 'done'; index: number } | { kind: 'invalid' }
141
142export function parseDecisionArgs(args: string): DecisionCommand {
143 const words = args.trim().split(/\s+/).filter(word => word !== '')
144
145 if (words.length === 0) {
146 return { kind: 'list' }
147 }
148
149 if (words[0] === 'clear' && words.length === 1) {
150 return { kind: 'clear' }
151 }
152
153 if (words[0] === 'done' && words.length === 2 && /^\d+$/.test(words[1] ?? '')) {
154 return { kind: 'done', index: Number(words[1]) }
155 }
156
157 return { kind: 'invalid' }
158}
159
160export const USAGE = 'Usage: /decisions (list) | /decisions clear (remove all) | /decisions done <n> (remove one)'
161export const EMPTY_MESSAGE = 'No unrecorded decisions.'
162
163/** The numbered list for /decisions (1-based, the same numbers `done` takes). */
164export function formatList(list: readonly Decision[], recordPath: string = DEFAULT_RECORD_PATH): string {
165 if (list.length === 0) {
166 return EMPTY_MESSAGE
167 }
168
169 return [`Unrecorded decisions (${list.length}) — cleared when ${recordPath} is written`, ...list.map((item, i) => `${i + 1}. ${item.text}`)].join('\n')
170}
171types/index.d.ts 8 lines1export type Decision = { text: string; at: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'decision-tracker': { decisions: Decision[]; isExpanded: boolean }
6 }
7}
8