/decision <text> [-- rationale] adds a dated row to the project's docs/DECISIONS.md (the agents-md-scaffold table); /decision-log shows the last 20. Writes…

Install: /plugin install decision-log --marketplace samuelbfernandes/moddeck
Record a project decision in one line, in the project's own docs/DECISIONS.md, where people and agents will find it.
/decision <what was decided> -- <why> adds one row to docs/DECISIONS.md under the session's folder, in the table the agents-md-scaffold skill writes:| ID | Date | Decision | Rationale | Status |
|---|---|---|---|---|
| 004 | 2026-10-13 | Use renv for package versions | the lockfile makes installs reproducible | accepted |
The ID is the highest numeric ID in the table plus one, zero-padded to three digits; the date is today's local date; the status is accepted. A | in your text is escaped and a line break becomes <br>, so the table stays intact. The rationale is optional. The decision is limited to 500 characters and the rationale to 1,000.
# Decisions log and the two table lines, without the template's TODO row)./decision-log opens a pane with the last 20 rows, newest first, and a Refresh button.docs/DECISIONS.md, and only when you type /decision. Nothing else on disk is touched; /decision-log only reads. It hooks no tool call and keeps nothing in $.store.added 004 to docs/DECISIONS.md) is what the model sees./decision Drop Python 3.9 support -- end of life, and it blocks the new typing syntax /decision Keep the raw data read-only /decision-log
/plugin disable decision-log. Nothing runs until you call it.
{ plugin: 'decision-log', key: 'view' }: the last reading of the file (path, rows (the last 20), total, isMissing, error), or null before the first /decision or /decision-log.
claude plugin test), on the terminal and desktop element tables; not yet checked by hand in a live session.Adapted from decision-log in awesome-claude-code-mods (MIT, © 2026 Yash Thakker and contributors). Rewritten for moddeck: upstream kept entries in the plugin's own store; this version writes the project's own docs/DECISIONS.md in the agents-md-scaffold table format (numbered IDs, local date, rationale, status), adds /decision for one-line entries, lists the last 20 in a pane, and has tests. The upstream licence is in LICENSE.
hooks/register.tsx 122 lines1// decision-log: `/decision <text> [-- rationale]` adds one row to docs/DECISIONS.md under the session's folder, in the
2// table the agents-md-scaffold skill writes (| ID | Date | Decision | Rationale | Status |): the next ID, today's local
3// date, status "accepted". The file is created with the template header when missing. `/decision-log` shows the last
4// 20 rows in a pane. It writes that one file only, and only on an explicit /decision.
5// FAIL OPEN: it hooks no tool call and refuses nothing; when the file cannot be read it writes nothing and says why.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { DecisionLogView } from '../types'
10import { RELATIVE_PATH, decisionsPath, localDate, nextId, parseArgs, parseRows, rowLine, withRow } from './lib'
11
12type Dollar = EngineInterface
13
14const ADD = 'decision'
15const SHOW = 'decision-log'
16const PANE = 'decision-log'
17const SHOWN = 20
18
19const view = atom({ plugin: 'decision-log', key: 'view' } as const, null)
20
21/** The file's text, undefined when it does not exist; throws when it exists and cannot be read. */
22async function readFile($: Dollar, path: string): Promise<string | undefined> {
23 if (!(await $.fs.exists(path))) return undefined
24
25 return $.fs.read(path)
26}
27
28async function refresh($: Dollar): Promise<DecisionLogView> {
29 const path = decisionsPath(await $.session.cwd())
30 let next: DecisionLogView
31 try {
32 const text = await readFile($, path)
33 const rows = text === undefined ? [] : parseRows(text)
34 next = { path, rows: rows.slice(-SHOWN), total: rows.length, isMissing: text === undefined, error: '' }
35 } catch (error) {
36 next = { path, rows: [], total: 0, isMissing: false, error: error instanceof Error ? error.message.slice(0, 300) : 'could not read it' }
37 }
38 await update($, view, () => next)
39
40 return next
41}
42
43async function add($: Dollar, args: string): Promise<string> {
44 const parsed = parseArgs(args)
45 if ('error' in parsed) return `decision-log · ${parsed.error}.\nUse /decision <what was decided> -- <why>.`
46 const path = decisionsPath(await $.session.cwd())
47 let text: string | undefined
48 try {
49 text = await readFile($, path)
50 } catch {
51 return `decision-log · could not read ${RELATIVE_PATH}; nothing was written.`
52 }
53 const id = nextId(text ?? '')
54 const line = rowLine(id, localDate(await $.clock.now()), parsed.decision, parsed.rationale)
55 await $.fs.write(path, withRow(text, line))
56 await refresh($)
57
58 return `decision-log · added ${id} to ${RELATIVE_PATH}${text === undefined ? ' (new file)' : ''}`
59}
60
61async function show($: Dollar): Promise<string> {
62 const v = await refresh($)
63 await $.ui.open({ id: PANE, title: 'Decisions', columns: 80 })
64 if (v.error !== '') return `decision-log · could not read ${RELATIVE_PATH}`
65 if (v.isMissing) return `decision-log · no ${RELATIVE_PATH} yet\n/decision <text> -- <why> creates it.`
66
67 return `decision-log · ${v.total} decision${v.total === 1 ? '' : 's'} in ${RELATIVE_PATH}`
68}
69
70const clip = (text: string, n: number) => (text.length > n ? `${text.slice(0, n - 1)}…` : text)
71
72export const register: Register = on => {
73 on('session.start', async ($, e, next) => {
74 try {
75 await $.command.register({ name: ADD, description: 'Add a decision to docs/DECISIONS.md', argumentHint: '<decision> [-- rationale]' })
76 await $.command.register({ name: SHOW, description: 'Show the last 20 decisions in docs/DECISIONS.md' })
77 } catch {
78 // fail-open: the session goes on without the commands
79 }
80
81 return next(e)
82 })
83
84 on('command.run', { command: ADD }, async ($, e) => ({ text: await add($, e.args) })).catch(() => ({
85 text: `decision-log · could not write ${RELATIVE_PATH}`,
86 }))
87
88 on('command.run', { command: SHOW }, async $ => ({ text: await show($) })).catch(() => ({
89 text: `decision-log · could not read ${RELATIVE_PATH}`,
90 }))
91
92 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
93 const { Box, Button, Text } = $.ui.resolve(e)
94 const v = await read($, view)
95 const reload = <Button key="refresh" label="Refresh" hotkey="r" onPress={async () => void (await refresh($))} />
96 if (v === null) return reload
97 const rows = [...v.rows].reverse()
98
99 return (
100 <Box flexDirection="column">
101 <Box>
102 <Text bold>
103 {v.total} decision{v.total === 1 ? '' : 's'}
104 {v.total > v.rows.length ? ` · last ${v.rows.length}` : ''}{' '}
105 </Text>
106 {reload}
107 </Box>
108 {v.error !== '' && <Text color="yellow">Could not read {RELATIVE_PATH}: {v.error}</Text>}
109 {v.isMissing && <Text dimColor>{`No ${RELATIVE_PATH} yet. /decision <text> -- <why> creates it.`}</Text>}
110 {rows.map(row => (
111 <Box flexDirection="column">
112 <Text>
113 <Text bold>{row.id}</Text> <Text dimColor>{row.date} · {row.status}</Text> {clip(row.decision, 200)}
114 </Text>
115 {row.rationale !== '' && <Text dimColor> {clip(row.rationale, 200)}</Text>}
116 </Box>
117 ))}
118 </Box>
119 )
120 })
121}
122hooks/lib.ts 117 lines1// decision-log: pure helpers (no engine access) for the docs/DECISIONS.md table the agents-md-scaffold skill writes:
2// # Decisions log
3// | ID | Date | Decision | Rationale | Status |
4// |----|------|----------|-----------|--------|
5import type { DecisionLogRow } from '../types'
6
7export const RELATIVE_PATH = 'docs/DECISIONS.md'
8export const HEADER_LINES = ['# Decisions log', '| ID | Date | Decision | Rationale | Status |', '|----|------|----------|-----------|--------|']
9export const MAX_DECISION = 500
10export const MAX_RATIONALE = 1000
11
12export const isAbsolute = (path: string): boolean => /^(?:[\\/]|[A-Za-z]:[\\/])/.test(path)
13
14/** `docs/DECISIONS.md` under the session's folder, with that folder's own separator (Windows or POSIX). */
15export function decisionsPath(cwd: string): string {
16 const sep = cwd.includes('\\') && !cwd.includes('/') ? '\\' : '/'
17
18 return `${cwd.replace(/[\\/]+$/, '')}${sep}docs${sep}DECISIONS.md`
19}
20
21/** `text [-- rationale]`: the decision and the rationale, or why the input is refused. */
22export function parseArgs(args: string): { decision: string; rationale: string } | { error: string } {
23 const text = args.trim()
24 const m = /(?:^|\s)--(?:\s|$)/.exec(text)
25 const decision = (m ? text.slice(0, m.index) : text).trim()
26 const rationale = m ? text.slice(m.index + m[0].length).trim() : ''
27 if (decision === '') return { error: 'say what was decided' }
28 if (decision.length > MAX_DECISION) return { error: `keep the decision under ${MAX_DECISION} characters (put the detail in the rationale)` }
29 if (rationale.length > MAX_RATIONALE) return { error: `keep the rationale under ${MAX_RATIONALE} characters` }
30
31 return { decision, rationale }
32}
33
34/** A table cell: `|` escaped, line breaks as <br>, control characters dropped. */
35export function escapeCell(text: string): string {
36 return text
37 .replace(/\r\n?|\n/g, '<br>')
38 .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
39 .replace(/\|/g, '\\|')
40 .trim()
41}
42
43export function unescapeCell(cell: string): string {
44 return cell.replace(/\\\|/g, '|').replace(/<br>/g, ' ↵ ').trim()
45}
46
47/** The cells of a table line, split at `|` not escaped by a backslash. */
48export function cellsOf(line: string): string[] {
49 const inner = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
50
51 return inner.split(/(?<!\\)\|/).map(cell => cell.trim())
52}
53
54const isTableLine = (line: string): boolean => line.trim().startsWith('|')
55const isRule = (line: string): boolean => /^\|[\s:|-]+$/.test(line.trim())
56
57/** The rows of the decisions table whose ID is a number, in file order. */
58export function parseRows(text: string): DecisionLogRow[] {
59 const rows: DecisionLogRow[] = []
60 for (const line of text.split(/\r?\n/)) {
61 if (!isTableLine(line) || isRule(line)) continue
62 const cells = cellsOf(line)
63 if (!/^\d+$/.test(cells[0] ?? '')) continue
64 rows.push({
65 id: cells[0]!,
66 date: unescapeCell(cells[1] ?? ''),
67 decision: unescapeCell(cells[2] ?? ''),
68 rationale: unescapeCell(cells[3] ?? ''),
69 status: unescapeCell(cells[4] ?? ''),
70 })
71 }
72
73 return rows
74}
75
76/** The highest numeric ID + 1, zero-padded to 3 digits. */
77export function nextId(text: string): string {
78 const most = parseRows(text).reduce((max, row) => Math.max(max, Number(row.id)), 0)
79
80 return String(most + 1).padStart(3, '0')
81}
82
83export function rowLine(id: string, date: string, decision: string, rationale: string): string {
84 const why = escapeCell(rationale)
85
86 return `| ${id} | ${date} | ${escapeCell(decision)} |${why === '' ? ' ' : ` ${why} `}| accepted |`
87}
88
89/**
90 * The file with `line` added as the last row of the decisions table (the table whose header starts `| ID |`). With
91 * no such table, the header and the row are added at the end; with no file at all (`undefined`), the template header.
92 */
93export function withRow(text: string | undefined, line: string): string {
94 if (text === undefined || text.trim() === '') return [...HEADER_LINES, line, ''].join('\n')
95 const eol = text.includes('\r\n') ? '\r\n' : '\n'
96 const lines = text.split(/\r?\n/)
97 const header = lines.findIndex(l => /^\|\s*ID\s*\|/i.test(l.trim()))
98 if (header < 0) {
99 const body = text.replace(/(\r?\n)*$/, '')
100
101 return [body, '', ...HEADER_LINES.slice(1), line, ''].join(eol)
102 }
103 let last = header
104 while (last + 1 < lines.length && isTableLine(lines[last + 1]!)) last++
105 lines.splice(last + 1, 0, line)
106 const joined = lines.join(eol)
107
108 return joined.endsWith(eol) ? joined : joined + eol
109}
110
111/** The local date, YYYY-MM-DD. */
112export function localDate(ms: number): string {
113 const d = new Date(ms)
114
115 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
116}
117types/index.d.ts 26 lines1/** One row of docs/DECISIONS.md, cells unescaped for display. */
2export type DecisionLogRow = { id: string; date: string; decision: string; rationale: string; status: string }
3
4/** What `/decision-log` (or the last `/decision`) read from docs/DECISIONS.md. */
5export type DecisionLogView = {
6 /** The file's full path. */
7 path: string
8 /** The last 20 rows, in file order. */
9 rows: DecisionLogRow[]
10 /** How many rows the table has in all. */
11 total: number
12 /** True when the file does not exist yet. */
13 isMissing: boolean
14 /** Why it could not be read ('' when it could). */
15 error: string
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'decision-log': {
21 /** The last reading of docs/DECISIONS.md, or null before the first. */
22 view: DecisionLogView | null
23 }
24 }
25}
26