SLOPSHOPPER

decision-log

/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…

newpanecommand
★ 1v0.1.0MITupdated 2026-10-13samuelbfernandes/moddeck/mods/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 decisions [ Refresh ] │ ┃ No docs/DECISIONS.md yet. /decision <text> ⏺ Read(src/auth.ts) │ ┃ -- <why> creates it. ⎿ 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 │ │ › /decision │ ⎿ decision-log: decision-log · say what was decided. │ ⎿ decision-log: Use /decision <what was decided> -- <why>. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Decisions
0 decisions [ Refresh ] No docs/DECISIONS.md yet. /decision <text> -- <why> creates it.
README

decision-log

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.

What it does

  • /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:
IDDateDecisionRationaleStatus
0042026-10-13Use renv for package versionsthe lockfile makes installs reproducibleaccepted

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.

  • The row goes at the end of the decisions table (so text below the table stays below it). The file's line endings (LF or CRLF) are kept. If the file has no such table, the table header and the row are added at the end; if there is no file, it is created with the template header (# 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.
  • It writes one file, 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.
  • The command's one-line answer (added 004 to docs/DECISIONS.md) is what the model sees.

Use

/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

Turn off

/plugin disable decision-log. Nothing runs until you call it.

State for the moddeck side bar

{ 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.

Limits

  • One person at a time: two sessions adding a decision in the same second could pick the same ID.
  • Rows whose first cell is not a number (the template's header) are ignored when counting.
  • It does not commit the file; that stays your call.
  • The date comes from the machine's clock and time zone.
  • Tested with a mocked engine and file system (claude plugin test), on the terminal and desktop element tables; not yet checked by hand in a live session.

Credit

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.

Source 3 files
hooks/register.tsx 122 lines
1// 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}
122
hooks/lib.ts 117 lines
1// 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}
117
types/index.d.ts 26 lines
1/** 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