SLOPSHOPPER

decision-log

Architecture decision records you and Claude can go back to: ADR markdown files in the repo (docs/decisions/NNNN-slug.md), a `decision` tool for Claude…

newpanerowsguardcommandprompt
v0.1.0MITupdated 2026-10-06mrjk05/modemon/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 │ ┃ No decisions recorded yet. Add one with │ ┃ /decide <title> — <why>, or ask Claude to ● decision-log: decision-log: could not read the decisions folder (de │ ┃ record one. ⏺ 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 │ │ › /decide │ ⎿ decision-log: Usage: /decide <title> — <why> (also `--` or `:` │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · decisions
No decisions recorded yet. Add one with /decide <title> — <why>, or ask Claude to record one.
README

decision-log

Decisions you can go back to. Architecture decision records (ADRs) are kept as markdown files in your repo, in docs/decisions/NNNN-slug.md. Claude gets a decision tool to record, search and supersede them, you get /decide to add your own, and /decisions to browse them. Browsing uses a pane on the terminal and desktop, and an inline card on the Claude mobile app.

 ╭─ Decisions ─────────────────────────────────────────────╮
 │ 4 decisions                                             │
 │ #4  accepted   Use pnpm workspaces                      │
 │ #3  proposed   Cache API responses in SQLite            │
 │ #2  accepted   Use GraphQL for the public API           │
 │ #1  superseded Use REST for the public API → #2         │   (dimmed)
 ╰─────────────────────────────────────────────────────────╯
   press #2 ↓
 ╭─ Decisions ─────────────────────────────────────────────╮
 │ [ ← All decisions ] [ #1 ]                              │
 │ 2. Use GraphQL for the public API                       │
 │ accepted · 2026-10-06 · deciders: user, claude ·        │
 │ tags: api · supersedes #1                               │
 │ docs/decisions/0002-use-graphql-for-the-public-api.md   │
 │ Context                                                 │
 │ Mobile and web clients need different shapes …          │
 ╰─────────────────────────────────────────────────────────╯

Install

/plugin install decision-log --marketplace mrjk05/modemon

Answer y to add the marketplace, then pick a scope. Built against Claude Code 2.1.290. The mods API is early access and may change between releases.

The files

Each record is one file, <dir>/NNNN-slug.md. It starts with YAML front matter, followed by MADR-style sections. Example docs/decisions/0002-use-graphql-for-the-public-api.md:

---
id: 2
title: Use GraphQL for the public API
status: accepted
date: 2026-10-06
deciders: [user, claude]
supersedes: [1]
superseded-by: []
tags: [api]
files: [src/api/]
---
# 2. Use GraphQL for the public API

## Context

Mobile and web clients need different shapes of the same data, and REST
endpoints were multiplying.

## Decision

Serve the public API over GraphQL behind the existing gateway.

## Alternatives considered

- Keep REST and add BFF endpoints: more code per client.
- gRPC: poor browser support.

## Consequences

Clients fetch what they need. We need query cost limits and a schema review step.
  • Status is one of proposed, accepted, superseded or rejected. Superseding links both records: the old one gets status: superseded and superseded-by: [new], and the new one gets supersedes: [old].
  • Numbering is the highest existing id (from the front matter or a file-name prefix) plus one, zero-padded to four digits. The slug is the title lowercased with diacritics removed, keeping only a-z0-9-, and cut to 60 characters.
  • Hand edits are fine. The parser accepts CRLF, a BOM, missing front matter (the id and title then come from the # N. Title heading or the file name), block or flow lists, quoted values, MADR headings (Context and Problem Statement, Considered Options, Decision Outcome, ...), missing sections, extra sections and unknown front-matter keys. Extra sections and unknown keys are kept when the plugin rewrites a file. The serializer is deterministic: fixed key order, flow lists, LF line endings.
  • The index. <dir>/README.md is regenerated on every change as a table of #, title, status and date. If a README.md already exists there that the plugin did not generate (it has no generated by the decision-log plugin marker), it is left alone.

Cloud sessions. The records are ordinary files in the working copy. The plugin never commits. In a cloud session (claude.ai/code or mobile), commit and push them, or ask Claude to, or they disappear with the container.

Commands

CommandWhat it does
/decide <title> — <why>Records a decision with you as the decider (deciders: [user]) and status accepted. The title goes into Decision and the why into Context. You can also separate them with -- or :. The first em/en dash wins, then --, then :.
/decisionsLists the records newest first, with status coloured and superseded ones dimmed with → #N. On the terminal and desktop this opens the pane. On mobile, or wherever the pane cannot be placed, the list is drawn inline as the command's output. Each row's #N button opens the full record.
/decisions <n>Shows record n in full, inline.
/decisions search <query>Shows ranked matches with snippets, inline. Each one has a button that opens the record.

In a full record, ← All decisions goes back to the list, and a #N button opens a linked (superseding or superseded) record.

The tool Claude uses

mcp__decision-log__decision is registered at session start. It is kept in the prompt's tool list (not deferred), and it is allowed without a permission prompt because it only reads and writes files inside the resolved decisions folder.

ActionInputResult
recordtitle, context, decision, alternatives[], consequences, tags[], files[], status (default accepted), supersedes?, deciders? (default user, claude)Recorded #7 "…" (accepted) at docs/decisions/0007-….md, plus any supersede links and the index path
liststatus?, tag?One line per record, newest first: #7 accepted 2026-10-06 Title [tags] → #9 (path)
searchqueryRanked by title, then tags, then body matches, each with a snippet and the path
getidThe path and the whole file
supersedeid, byMarks #id superseded by #by and links both files
set-statusid, statusChanges the status and rewrites the file

A bad input or an unknown id is refused, and the reason goes back to Claude.

System-prompt note. A short session section, under 120 words, tells Claude the log exists at <dir>/. It asks Claude to record genuine decisions with the why and the alternatives: architecture, dependencies, the data model, API shape, and trade-offs the user agreed to. It should not record trivial edits. It should supersede an accepted record rather than rewrite it, and search the log when it needs context on past choices. The note contains no index, so it doesn't change as records are added and prompt-cache reuse is kept.

Config

The plugin's rows in /config (pluginConfigs.decision-log.options in settings):

OptionDefault
dirdocs/decisionsFolder for the records, relative to the repository root. It cannot be absolute and cannot contain ...
nudgetrueAdd the system-prompt note

Path safety

  • The repository root is the git root that holds the session's project root. In a worktree it is the worktree itself, and outside git it is the project root. That root is resolved with $.fs.stat(…, { resolve: true }).
  • The decisions folder is resolved the same way, through every link. For a folder that does not exist yet, the deepest existing parent is resolved and the missing part is appended to it. If the result is not strictly inside the resolved root, nothing is written. This covers both a dir like ../x and a docs/decisions that is a symlink out of the repo.
  • Every write is checked again just before it happens. The folder must still resolve to the same place, and the target must land at exactly <folder>/<name>, so a symlinked file is refused. A new record never replaces an existing file. A rewrite only replaces a file that still holds the same id, and the index only replaces a README the plugin generated.
  • File names are built from the zero-padded id and the sanitised slug. Symlinks inside the folder are never read.

Surfaces

TerminalDesktop (Code tab)Mobile app
Tool, prompt note, /decideyesyesyes
/decisions paneyes (docked or inline)yesnever placed
/decisions inline card with buttonswhen the pane cannot be placedsamealways
/decisions <n>, /decisions search inlineyesyesyes

The trees use only Box, Text, Button and Markdown, which every surface has, mobile included. Row titles are cut to the width the surface reports, so a phone gets one line per record. The text the model reads from a command is always plain text (the list, the record or the matches). The card is only how that text is drawn.

Limitations

  • The plugin does not commit or push. In cloud sessions, unpushed records are lost with the container.
  • The folder is read from disk on every tool call and command. The pane shows what was last read, so hand edits appear after the next /decisions or tool call.
  • A multi-file change (a record plus the records it supersedes, plus the index) is not atomic. If a later write is refused (for example, a symlinked README), the earlier files stay written and the refusal says why.
  • The front-matter parser is a tolerant subset of YAML: scalars, flow lists and block lists. Anchors, nested maps and multi-document files are not understood. Unknown keys are kept verbatim.
  • /decide takes a single line. For a full record with alternatives and consequences, ask Claude, or edit the file afterwards.
  • On mobile, the inline card shares the record's text with its command row. A long record draws up to the surface's 100,000-character limit.
  • Paths are compared as resolved strings. On case-insensitive file systems, a case alias of the folder is treated as a different path and refused.
Source 3 files
hooks/register.tsx 587 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Decision, DecisionEntry, DecisionStatus } from '../types'
5import {
6  clip,
7  dirSegments,
8  INDEX_MARKER,
9  isDecisionFile,
10  isSafeName,
11  parseDecision,
12  fileName,
13  formatList,
14  formatRecord,
15  formatSearch,
16  guideText,
17  INDEX_FILE,
18  isoDate,
19  newestFirst,
20  nextId,
21  oneLine,
22  parseDecide,
23  parseDecisionsArgs,
24  parseToolInput,
25  recordMarkdown,
26  renderIndex,
27  search,
28  serializeDecision,
29  supersededNote,
30  type SearchHit,
31} from './lib'
32
33const TOOL = 'mcp__decision-log__decision'
34const PANE = 'decisions'
35/** First line of `/decisions` when the pane cannot be placed; the CommandOutput hook draws the card for it. */
36const WAITING_HEAD = 'The decisions pane has no room here, so here is the log:'
37
38/** The elements every surface has, mobile included: the only ones the trees use. */
39type Ui = Pick<Elements['mobile'], 'Box' | 'Text' | 'Button' | 'Markdown'>
40type Dollar = EngineInterface
41
42const records = atom({ plugin: 'decision-log', key: 'records' } as const, [])
43const selected = atom({ plugin: 'decision-log', key: 'selected' } as const, 0)
44
45const TOOL_DESCRIPTION = [
46  "The repository's decision log: architecture decision records (ADRs) kept as markdown files in the repo.",
47  'Record genuine decisions (architecture, dependencies, data model, API shape, trade-offs the user agreed to) with the why and the alternatives; not trivial edits.',
48  'Actions: "record" {title, context, decision, alternatives[], consequences, tags[], files[], status (default accepted), supersedes?};',
49  '"list" {status?, tag?}; "search" {query} (ranked, with snippets); "get" {id}; "supersede" {id, by} (old #id is superseded by #by, linked both ways);',
50  '"set-status" {id, status}. Do not rewrite accepted records: record a new one and supersede. Results are compact text with the file path.',
51].join(' ')
52
53const INPUT_SCHEMA = {
54  type: 'object',
55  properties: {
56    action: { type: 'string', enum: ['record', 'list', 'search', 'get', 'supersede', 'set-status'], description: 'What to do.' },
57    title: { type: 'string', description: 'record: a short imperative title, e.g. "Use SQLite for the local cache".' },
58    context: { type: 'string', description: 'record: the situation and forces that called for a decision (markdown).' },
59    decision: { type: 'string', description: 'record: what was decided and why (markdown).' },
60    alternatives: { type: 'array', items: { type: 'string' }, description: 'record: options considered and why each was not chosen.' },
61    consequences: { type: 'string', description: 'record: what follows, good and bad (markdown).' },
62    tags: { type: 'array', items: { type: 'string' }, description: 'record: short lowercase tags.' },
63    files: { type: 'array', items: { type: 'string' }, description: 'record: repo paths the decision mainly concerns.' },
64    deciders: { type: 'array', items: { type: 'string' }, description: 'record: who decided (default user, claude).' },
65    status: {
66      type: 'string',
67      enum: ['proposed', 'accepted', 'superseded', 'rejected'],
68      description: 'record: proposed, accepted (default) or rejected. set-status: the new status. list: filter.',
69    },
70    supersedes: { type: 'integer', description: 'record: the number of an earlier record this one replaces.' },
71    tag: { type: 'string', description: 'list: only records with this tag.' },
72    query: { type: 'string', description: 'search: words to look for in titles, tags and bodies.' },
73    id: { type: 'integer', description: 'get, supersede, set-status: the record number (#7 -> 7).' },
74    by: { type: 'integer', description: 'supersede: the number of the newer record that replaces #id.' },
75  },
76  required: ['action'],
77  additionalProperties: false,
78} as const
79
80// ---------------------------------------------------------------------------------------------------------------
81// Where the decisions folder is, reading every record, and writes that stay inside the resolved folder.
82/** A refusal with a message meant for the person or the model. */
83class PlaceError extends Error {}
84
85type Place = {
86  /** The repository root, every link resolved, no trailing separator. */
87  rootReal: string
88  /** The decisions folder, resolved the same way (its missing tail appended to the deepest folder that exists). */
89  dirReal: string
90  /** The separator the resolved paths use. */
91  sep: string
92  /** The folder relative to the root, `/`-separated, for messages and `path`s. */
93  dirPath: string
94  /** Whether the folder exists yet. */
95  exists: boolean
96}
97
98const statOf = ($: Dollar, path: string) => $.fs.stat(path, { resolve: true }).catch(() => undefined)
99
100/** The repository root: the git root that holds the session's project root, else the project root itself. */
101async function repoRoot($: Dollar): Promise<string> {
102  const root = await $.session.root()
103  let repoRootPath: string | undefined
104  try {
105    repoRootPath = (await $.session.repo())?.root
106  } catch {
107    repoRootPath = undefined
108  }
109  if (repoRootPath !== undefined) {
110    const base = repoRootPath.replace(/[\\/]+$/, '')
111    // A worktree's project root lies outside the main tree's root: the worktree is where its files go.
112    if (root === base || root.startsWith(`${base}/`) || root.startsWith(`${base}\\`)) return base
113  }
114  return root
115}
116
117function isInside(child: string, parent: string, sep: string): boolean {
118  return child.startsWith(parent + sep) && !child.slice(parent.length + 1).split(sep).includes('..')
119}
120
121/** Resolves the decisions folder under the real repository root, refusing anything that lands outside it. */
122async function resolvePlace($: Dollar, dirOption: string): Promise<Place> {
123  const parsed = dirSegments(dirOption)
124  if ('error' in parsed) throw new PlaceError(`decision-log: ${parsed.error}.`)
125  const { segments } = parsed
126  const root = await repoRoot($)
127  const rootStat = await statOf($, root)
128  if (rootStat?.realPath === undefined || rootStat.kind !== 'dir') throw new PlaceError(`decision-log: cannot resolve the repository root ${root}.`)
129  const sep = rootStat.realPath.includes('/') || !rootStat.realPath.includes('\\') ? '/' : '\\'
130  const rootReal = rootStat.realPath.length > 1 ? rootStat.realPath.replace(/[\\/]+$/, '') : rootStat.realPath
131  const base = rootReal === sep ? '' : rootReal
132
133  for (let i = segments.length; i >= 0; i--) {
134    const spelled = [base, ...segments.slice(0, i)].join(sep) || sep
135    const found = await statOf($, spelled)
136    if (found === undefined) continue
137    if (found.realPath === undefined) throw new PlaceError(`decision-log: cannot resolve ${spelled}.`)
138    if (found.kind !== 'dir') throw new PlaceError(`decision-log: ${spelled} is not a folder.`)
139    const prefix = found.realPath.length > 1 ? found.realPath.replace(/[\\/]+$/, '') : ''
140    const dirReal = [prefix, ...segments.slice(i)].join(sep)
141    if (!isInside(dirReal, base, sep)) {
142      throw new PlaceError(`decision-log: the decisions folder "${dirOption}" resolves to ${dirReal}, outside the repository ${rootReal}; nothing was written.`)
143    }
144    return { rootReal, dirReal, sep, dirPath: segments.join('/'), exists: i === segments.length }
145  }
146  throw new PlaceError(`decision-log: cannot resolve the repository root ${root}.`)
147}
148
149/** Every record in the folder, oldest first. A file that cannot be read is skipped. */
150async function loadAll($: Dollar, place: Place): Promise<{ entries: DecisionEntry[]; names: string[] }> {
151  if (!place.exists) return { entries: [], names: [] }
152  const listing = await $.fs.list(place.dirReal).catch(() => [])
153  // Every NNNN-*.md name counts for numbering; only plain files are read (a link is never followed).
154  const names = listing.filter(e => isDecisionFile(e.name)).map(e => e.name)
155  const files = listing.filter(e => e.kind === 'file' && !e.isLink && isDecisionFile(e.name)).map(e => e.name)
156  const entries: DecisionEntry[] = []
157  for (const name of files.sort()) {
158    try {
159      const text = await $.fs.read(`${place.dirReal}${place.sep}${name}`)
160      const d = parseDecision(text, name)
161      if (d.id > 0) entries.push({ ...d, file: name, path: `${place.dirPath}/${name}` })
162    } catch {
163      // unreadable: left out
164    }
165  }
166  entries.sort((a, b) => a.id - b.id || a.file.localeCompare(b.file))
167  return { entries, names }
168}
169
170/**
171 * What a write may replace: `new` (nothing may be there), a record id (the file there must hold that id), or the
172 * `index` (only a file this plugin generated).
173 */
174type WriteGuard = { kind: 'new' } | { kind: 'record'; id: number } | { kind: 'index' }
175
176/**
177 * Writes `text` to `name` inside the decisions folder. The target is resolved again first: it must land at
178 * exactly `<dirReal>/<name>` (no link out), and the guard decides whether an existing file may be replaced.
179 * Answers false for an index it declined to overwrite.
180 */
181async function safeWrite($: Dollar, place: Place, name: string, text: string, guard: WriteGuard): Promise<boolean> {
182  if (!isSafeName(name)) throw new PlaceError(`decision-log: refusing the file name "${name}".`)
183  const target = `${place.dirReal}${place.sep}${name}`
184  const folder = await statOf($, place.dirReal)
185  if (folder !== undefined && (folder.realPath !== place.dirReal || folder.kind !== 'dir')) {
186    throw new PlaceError(`decision-log: ${place.dirPath} moved or is a link out of the repository; nothing was written.`)
187  }
188  const existing = await statOf($, target)
189  if (existing !== undefined) {
190    if (existing.kind !== 'file' || existing.isLink || existing.realPath !== target) {
191      throw new PlaceError(`decision-log: ${place.dirPath}/${name} is not a plain file inside the folder; nothing was written.`)
192    }
193    const current = await $.fs.read(target)
194    if (guard.kind === 'new') {
195      throw new PlaceError(`decision-log: ${place.dirPath}/${name} already exists; refusing to overwrite it.`)
196    }
197    if (guard.kind === 'index') {
198      if (!current.includes(INDEX_MARKER)) return false
199    } else {
200      const held = parseDecision(current, name).id
201      if (held !== guard.id) throw new PlaceError(`decision-log: ${place.dirPath}/${name} holds #${held}, not #${guard.id}; refusing to overwrite it.`)
202    }
203  }
204  await $.fs.write(target, text)
205  return true
206}
207
208function indexPath(place: Place): string {
209  return `${place.dirPath}/${INDEX_FILE}`
210}
211
212function entryOf(place: Place, d: Decision, file: string): DecisionEntry {
213  return { ...d, file, path: `${place.dirPath}/${file}` }
214}
215
216// ---------------------------------------------------------------------------------------------------------------
217
218let dirOption = 'docs/decisions' // set from the `dir` option on every (re)load of register
219
220/** Reads the folder from disk into $.state (hand edits included) and answers where it is and what it holds. */
221async function refresh($: Dollar): Promise<{ place: Place; entries: DecisionEntry[]; names: string[] }> {
222  const place = await resolvePlace($, dirOption)
223  const { entries, names } = await loadAll($, place)
224  await update($, records, () => entries)
225  return { place, entries, names }
226}
227
228/** Rewrites `<dir>/README.md` from the records; a README this plugin did not generate is left alone. */
229async function writeIndex($: Dollar, place: Place, entries: readonly DecisionEntry[]): Promise<string> {
230  const isWritten = await safeWrite($, place, INDEX_FILE, renderIndex(entries), { kind: 'index' })
231  return isWritten ? `Index: ${indexPath(place)}` : `Index not written: ${indexPath(place)} was not generated by decision-log.`
232}
233
234async function saveRecord($: Dollar, place: Place, d: DecisionEntry): Promise<void> {
235  await safeWrite($, place, d.file, serializeDecision(d), { kind: 'record', id: d.id })
236}
237
238function find(entries: readonly DecisionEntry[], id: number): DecisionEntry | undefined {
239  return entries.find(e => e.id === id)
240}
241
242function addId(list: readonly number[], id: number): number[] {
243  return list.includes(id) ? [...list] : [...list, id].sort((a, b) => a - b)
244}
245
246type RecordInput = {
247  title: string
248  context: string
249  decision: string
250  alternatives: string[]
251  consequences: string
252  tags: string[]
253  files: string[]
254  deciders: string[]
255  status: DecisionStatus
256  supersedes: number[]
257}
258
259/** Writes a new record (and links any it supersedes both ways), then the index. Answers the result text. */
260async function recordDecision($: Dollar, input: RecordInput): Promise<{ text: string; entry: DecisionEntry }> {
261  const { place, entries, names } = await refresh($)
262  const old = input.supersedes.map(id => ({ id, entry: find(entries, id) }))
263  const missing = old.filter(o => o.entry === undefined).map(o => `#${o.id}`)
264  if (missing.length > 0) throw new PlaceError(`No record ${missing.join(', ')} to supersede. ${entries.length} records in ${place.dirPath}/.`)
265
266  const id = nextId(entries, names)
267  const d: Decision = {
268    id,
269    title: oneLine(input.title),
270    status: input.status,
271    date: isoDate(await $.clock.now()),
272    deciders: input.deciders,
273    supersedes: [...input.supersedes].sort((a, b) => a - b),
274    supersededBy: [],
275    tags: input.tags,
276    files: input.files,
277    context: input.context,
278    decision: input.decision,
279    alternatives: input.alternatives,
280    consequences: input.consequences,
281    preamble: '',
282    extraSections: [],
283    extraMeta: [],
284  }
285  const entry = entryOf(place, d, fileName(id, d.title))
286  await safeWrite($, place, entry.file, serializeDecision(entry), { kind: 'new' })
287  const lines = [`Recorded #${id} "${entry.title}" (${entry.status}) at ${entry.path}`]
288  const all = [...entries, entry]
289  for (const { entry: prior } of old) {
290    if (prior === undefined) continue
291    const changed: DecisionEntry = { ...prior, status: 'superseded', supersededBy: addId(prior.supersededBy, id) }
292    await saveRecord($, place, changed)
293    all.splice(all.indexOf(prior), 1, changed)
294    lines.push(`#${prior.id} is now superseded by #${id} (${prior.path})`)
295  }
296  lines.push(await writeIndex($, place, all))
297  await update($, records, () => all)
298  return { text: lines.join('\n'), entry }
299}
300
301async function supersede($: Dollar, id: number, by: number): Promise<string> {
302  const { place, entries } = await refresh($)
303  const prior = find(entries, id)
304  const newer = find(entries, by)
305  if (prior === undefined || newer === undefined) {
306    throw new PlaceError(`No record #${prior === undefined ? id : by}. ${entries.length} records in ${place.dirPath}/.`)
307  }
308  const changedOld: DecisionEntry = { ...prior, status: 'superseded', supersededBy: addId(prior.supersededBy, by) }
309  const changedNew: DecisionEntry = { ...newer, supersedes: addId(newer.supersedes, id) }
310  await saveRecord($, place, changedOld)
311  await saveRecord($, place, changedNew)
312  const all = entries.map(e => (e === prior ? changedOld : e === newer ? changedNew : e))
313  const index = await writeIndex($, place, all)
314  await update($, records, () => all)
315  return [`#${id} "${prior.title}" is superseded by #${by} "${newer.title}".`, `${changedOld.path}: superseded-by ${changedOld.supersededBy.join(', ')}`, `${changedNew.path}: supersedes ${changedNew.supersedes.join(', ')}`, index].join('\n')
316}
317
318async function setStatus($: Dollar, id: number, status: DecisionStatus): Promise<string> {
319  const { place, entries } = await refresh($)
320  const prior = find(entries, id)
321  if (prior === undefined) throw new PlaceError(`No record #${id}. ${entries.length} records in ${place.dirPath}/.`)
322  const changed: DecisionEntry = { ...prior, status }
323  await saveRecord($, place, changed)
324  const all = entries.map(e => (e === prior ? changed : e))
325  const index = await writeIndex($, place, all)
326  await update($, records, () => all)
327  return `#${id} "${prior.title}" is now ${status} (was ${prior.status}) at ${prior.path}\n${index}`
328}
329
330function messageOf(error: unknown): string {
331  return error instanceof PlaceError ? error.message : `decision-log: ${error instanceof Error ? error.message : String(error)}`
332}
333
334/** `/decisions` with no arguments: the pane where it can be placed, the log as text otherwise. */
335async function openPane($: Dollar, entries: readonly DecisionEntry[], dirPath: string): Promise<string> {
336  await update($, memberOf(selected, { requestId: PANE }), () => -1)
337  const opened = await $.ui.open({ id: PANE, title: 'Decisions', rows: Math.min(Math.max(entries.length, 1) + 4, 24) })
338  const list = formatList(entries, dirPath)
339  if (opened.isPlaced) return `Decisions pane opened. ${list}`
340  return `${WAITING_HEAD}\n${list}`
341}
342
343export const register: Register = (on, options) => {
344  dirOption = typeof options.dir === 'string' && options.dir.trim() !== '' ? options.dir : 'docs/decisions'
345  const nudge = options.nudge !== false
346
347  on('session.start', async ($, e, next) => {
348    try {
349      await refresh($)
350    } catch (error) {
351      $.ui.log(`decision-log: could not read the decisions folder (${messageOf(error)})`, { to: 'debug' })
352    }
353    await $.tool.register({ name: 'decision', description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA })
354    await $.command.register({
355      name: 'decide',
356      description: 'Record a decision in the repo\'s decision log (you as the decider, status accepted)',
357      argumentHint: '<title> — <why>',
358    })
359    await $.command.register({
360      name: 'decisions',
361      description: 'Browse the decision log (pane; inline on mobile)',
362      argumentHint: '[<n> | search <query>]',
363    })
364    return next(e)
365  })
366
367  // The tool only reads and writes files inside the resolved decisions folder, so it needs no permission prompt.
368  on('tool.check', { tool: TOOL }, () => ({ decision: 'allow' as const }))
369
370  // Keep the schema in the prompt's tool list rather than behind ToolSearch.
371  on('tool.describe', { tool: TOOL }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
372
373  on('tool.call', { tool: TOOL }, async ($, e) => {
374    const request = parseToolInput(e as unknown as Record<string, unknown>)
375    try {
376      switch (request.action) {
377        case 'error':
378          return { deny: request.message }
379        case 'record': {
380          const { action: _action, ...input } = request
381          return { result: (await recordDecision($, input)).text }
382        }
383        case 'list': {
384          const { place, entries } = await refresh($)
385          return { result: formatList(entries, place.dirPath, { status: request.status, tag: request.tag }) }
386        }
387        case 'search': {
388          const { entries } = await refresh($)
389          return { result: formatSearch(search(entries, request.query), request.query) }
390        }
391        case 'get': {
392          const { place, entries } = await refresh($)
393          const entry = find(entries, request.id)
394          if (entry === undefined) return { deny: `No record #${request.id}. ${entries.length} records in ${place.dirPath}/.` }
395          return { result: formatRecord(entry) }
396        }
397        case 'supersede':
398          return { result: await supersede($, request.id, request.by) }
399        case 'set-status':
400          return { result: await setStatus($, request.id, request.status) }
401      }
402    } catch (error) {
403      return { deny: messageOf(error) }
404    }
405  }).catch(() => ({ deny: 'decision-log: the tool failed. Call it with action "list" to see the current state.' }))
406
407  on('command.run', { command: 'decide' }, async ($, e) => {
408    const parsed = parseDecide(e.args)
409    if ('error' in parsed) return { text: parsed.error }
410    try {
411      const { text } = await recordDecision($, {
412        title: parsed.title,
413        context: parsed.why,
414        decision: parsed.title,
415        alternatives: [],
416        consequences: '',
417        tags: [],
418        files: [],
419        deciders: ['user'],
420        status: 'accepted',
421        supersedes: [],
422      })
423      return { text }
424    } catch (error) {
425      return { text: messageOf(error) }
426    }
427  })
428
429  on('command.run', { command: 'decisions' }, async ($, e) => {
430    const command = parseDecisionsArgs(e.args)
431    if (command.kind === 'error') return { text: command.message }
432    let loaded: Awaited<ReturnType<typeof refresh>>
433    try {
434      loaded = await refresh($)
435    } catch (error) {
436      return { text: messageOf(error) }
437    }
438    const { place, entries } = loaded
439    switch (command.kind) {
440      case 'list':
441        return { text: await openPane($, entries, place.dirPath) }
442      case 'show': {
443        const entry = find(entries, command.id)
444        return { text: entry === undefined ? `No record #${command.id}. ${entries.length} records in ${place.dirPath}/.` : formatRecord(entry) }
445      }
446      case 'search':
447        return { text: formatSearch(search(entries, command.query), command.query) }
448    }
449  })
450
451  if (nudge) {
452    on('prompt.compose', async ($, e, next) => {
453      const composed = await next(e)
454      if (e.traits.includes('bare')) return composed
455      const dirPath = dirOption.trim().replace(/[\\/]+/g, '/').replace(/\/+$/, '')
456      return { sections: [...composed.sections, { id: 'decision-log:guide', text: guideText(dirPath, TOOL), scope: 'session' as const }] }
457    })
458  }
459
460  // The pane, wherever a surface places one. Only Box, Text, Button and Markdown: all of them exist on mobile too.
461  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
462    const { Box, Button, Text, Markdown } = $.ui.resolve(e)
463    const ui: Ui = { Box, Button, Text, Markdown }
464    const entries = await read($, records)
465    const view = memberOf(selected, e)
466    const choice = await read($, view)
467    const go = (id: number) => update($, view, () => id)
468    const entry = choice > 0 ? find(entries, choice) : undefined
469    if (entry !== undefined) return recordTree(ui, entry, go)
470    return listTree(ui, entries, e.props.bodyColumns, go)
471  })
472
473  // `/decisions` draws the log inline as a card with Buttons: always on mobile (which places no pane), and
474  // elsewhere when the pane could not be placed. `/decisions <n>` and `/decisions search <q>` always draw inline.
475  on('ui.render', { component: 'CommandOutput', props: { command: 'decisions' } }, async ($, e, next) => {
476    if (e.props.isErrored) return next(e)
477    const command = parseDecisionsArgs(e.props.args)
478    if (command.kind === 'error') return next(e)
479    if (command.kind === 'list' && e.surface !== 'mobile' && !e.props.text.startsWith(WAITING_HEAD)) return next(e)
480    const { Box, Button, Text, Markdown } = $.ui.resolve(e)
481    const ui: Ui = { Box, Button, Text, Markdown }
482    const entries = await read($, records)
483    const view = memberOf(selected, e)
484    const choice = await read($, view)
485    const go = (id: number) => update($, view, () => id)
486    const columns = e.viewport?.columns
487    const shown = choice > 0 ? choice : choice === 0 && command.kind === 'show' ? command.id : undefined
488    if (shown !== undefined) {
489      const entry = find(entries, shown)
490      if (entry !== undefined) return recordTree(ui, entry, go)
491      if (command.kind === 'show' && choice === 0) return next(e)
492    }
493    if (choice === 0 && command.kind === 'search') return searchTree(ui, search(entries, command.query), command.query, columns, go)
494    return listTree(ui, entries, columns, go)
495  })
496}
497
498function statusColor(status: DecisionStatus): 'success' | 'warning' | 'error' | 'inactive' {
499  switch (status) {
500    case 'accepted':
501      return 'success'
502    case 'proposed':
503      return 'warning'
504    case 'rejected':
505      return 'error'
506    case 'superseded':
507      return 'inactive'
508  }
509}
510
511function row(ui: Ui, entry: DecisionEntry, columns: number | undefined, go: (id: number) => unknown): RenderElement {
512  const { Box, Button, Text } = ui
513  const isDim = entry.status === 'superseded'
514  // Room for `#nnnn`, the status and spaces; a narrow phone gets a shorter line rather than a wrapped one.
515  const titleMax = columns === undefined ? 200 : Math.max(columns - 20, 10)
516  return (
517    <Box key={`row-${entry.id}`} flexDirection="row">
518      <Button key={`open-${entry.id}`} plain label={`#${entry.id}`} dimColor={isDim} onPress={() => go(entry.id)} />
519      <Text color={statusColor(entry.status)} dimColor={isDim}>
520        {` ${entry.status.padEnd(10)} `}
521      </Text>
522      <Text dimColor={isDim} wrap="truncate-end">
523        {clip(entry.title, titleMax)}
524        {supersededNote(entry)}
525      </Text>
526    </Box>
527  )
528}
529
530/** Newest first, status coloured, superseded rows dimmed with `→ #N`; each row's Button opens the record. */
531function listTree(ui: Ui, entries: readonly DecisionEntry[], columns: number | undefined, go: (id: number) => unknown): RenderElement {
532  const { Box, Text } = ui
533  if (entries.length === 0) {
534    return (
535      <Box key="decisions-empty" flexDirection="column">
536        <Text dimColor>{'No decisions recorded yet. Add one with /decide <title> — <why>, or ask Claude to record one.'}</Text>
537      </Box>
538    )
539  }
540  return (
541    <Box key="decisions-list" flexDirection="column">
542      <Text bold>
543        {entries.length} decision{entries.length === 1 ? '' : 's'}
544      </Text>
545      {newestFirst(entries).map(entry => row(ui, entry, columns, go))}
546    </Box>
547  )
548}
549
550function searchTree(ui: Ui, hits: readonly SearchHit[], query: string, columns: number | undefined, go: (id: number) => unknown): RenderElement {
551  const { Box, Button, Text } = ui
552  const snippetMax = columns === undefined ? 160 : Math.max(columns - 4, 20)
553  return (
554    <Box key="decisions-search" flexDirection="column">
555      <Text bold>{hits.length === 0 ? `No decisions match "${query}".` : `${hits.length} match "${query}"`}</Text>
556      {hits.map(hit => (
557        <Box key={`hit-${hit.entry.id}`} flexDirection="column">
558          {row(ui, hit.entry, columns, go)}
559          <Text dimColor wrap="truncate-end">
560            {`  ${clip(hit.snippet, snippetMax)}`}
561          </Text>
562        </Box>
563      ))}
564      <Box flexDirection="row">
565        <Button key="all" label="All decisions" onPress={() => go(-1)} />
566      </Box>
567    </Box>
568  )
569}
570
571/** The full record as Markdown, a Button back to the list and one per linked record. */
572function recordTree(ui: Ui, entry: DecisionEntry, go: (id: number) => unknown): RenderElement {
573  const { Box, Button, Markdown } = ui
574  const links = [...entry.supersedes, ...entry.supersededBy]
575  return (
576    <Box key={`record-${entry.id}`} flexDirection="column">
577      <Box flexDirection="row">
578        <Button key="back" label="← All decisions" onPress={() => go(-1)} />
579        {links.map(id => (
580          <Button key={`link-${id}`} label={`#${id}`} onPress={() => go(id)} />
581        ))}
582      </Box>
583      <Markdown key={`md-${entry.id}`} text={recordMarkdown(entry)} />
584    </Box>
585  )
586}
587
hooks/lib.ts 739 lines
1// Pure helpers for decision-log: parsing, serializing, numbering, search and the text the tool returns.
2import type { Decision, DecisionEntry, DecisionStatus } from '../types'
3
4export const STATUSES: readonly DecisionStatus[] = ['proposed', 'accepted', 'superseded', 'rejected']
5export const INDEX_FILE = 'README.md'
6export const INDEX_MARKER = '<!-- generated by the decision-log plugin; edits here are overwritten -->'
7
8// ---------------------------------------------------------------------------------------------------------------
9// Small text helpers
10
11export function pad4(id: number): string {
12  return String(id).padStart(4, '0')
13}
14
15/** One line, collapsed whitespace, at most `max` characters. */
16export function oneLine(text: string, max = 200): string {
17  const flat = text.replace(/\s+/g, ' ').trim()
18  return flat.length <= max ? flat : `${flat.slice(0, max - 1).trimEnd()}…`
19}
20
21export function clip(text: string, max: number): string {
22  return text.length <= max ? text : `${text.slice(0, Math.max(max - 1, 1))}…`
23}
24
25/** Lowercase ASCII words joined by `-`, at most 60 characters, `decision` when nothing is left. */
26export function slugify(title: string): string {
27  const ascii = title.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
28  let slug = ascii.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
29  if (slug.length > 60) {
30    const cut = slug.slice(0, 60)
31    const lastDash = cut.lastIndexOf('-')
32    slug = (lastDash >= 20 ? cut.slice(0, lastDash) : cut).replace(/-+$/g, '')
33  }
34  return slug === '' ? 'decision' : slug
35}
36
37export function fileName(id: number, title: string): string {
38  return `${pad4(id)}-${slugify(title)}.md`
39}
40
41/** A file name inside the decisions folder that this plugin may read or write: no separators, no dot names. */
42export function isSafeName(name: string): boolean {
43  return name !== '' && name !== '.' && name !== '..' && !/[\\/\0]/.test(name) && !/^[A-Za-z]:/.test(name)
44}
45
46/** A decision file as named in a listing: `NNNN-anything.md`, the index excluded. */
47export function isDecisionFile(name: string): boolean {
48  return isSafeName(name) && /^\d+[-_ ].*\.md$/i.test(name) && name.toLowerCase() !== INDEX_FILE.toLowerCase()
49}
50
51export function idFromFileName(name: string): number | undefined {
52  const match = /^(\d+)/.exec(name)
53  if (match === null) return undefined
54  const id = Number(match[1])
55  return Number.isSafeInteger(id) && id > 0 ? id : undefined
56}
57
58export function isoDate(ms: number): string {
59  return new Date(ms).toISOString().slice(0, 10)
60}
61
62/**
63 * Validates the `dir` option: relative, `/`- or `\`-separated, no `.`/`..` segment. Answers the clean segments,
64 * or an error.
65 */
66export function dirSegments(dir: string): { segments: string[] } | { error: string } {
67  const trimmed = dir.trim()
68  if (trimmed === '') return { error: 'the decisions folder option is empty' }
69  if (/^([\\/]|[A-Za-z]:|~)/.test(trimmed)) return { error: `the decisions folder must be relative to the repository root, not "${trimmed}"` }
70  const segments = trimmed.split(/[\\/]+/).filter(s => s !== '' && s !== '.')
71  if (segments.length === 0) return { error: 'the decisions folder cannot be the repository root itself' }
72  if (segments.some(s => s === '..' || /\0/.test(s))) return { error: `the decisions folder may not leave the repository ("${trimmed}")` }
73  return { segments }
74}
75
76// ---------------------------------------------------------------------------------------------------------------
77// Front matter (a tolerant subset of YAML: scalars, flow lists, block lists)
78
79function unquote(raw: string): string {
80  const value = raw.trim()
81  if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
82    return value.slice(1, -1).replace(/\\(["\\nt])/g, (_m, c: string) => (c === 'n' ? '\n' : c === 't' ? '\t' : c))
83  }
84  if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1).replace(/''/g, "'")
85  // A trailing comment on a plain scalar.
86  return value.replace(/\s+#.*$/, '')
87}
88
89/** Splits `[a, "b, c", d]` (brackets included or not) on commas outside quotes. */
90function splitFlow(raw: string): string[] {
91  let inner = raw.trim()
92  if (inner.startsWith('[')) inner = inner.slice(1)
93  if (inner.endsWith(']')) inner = inner.slice(0, -1)
94  const parts: string[] = []
95  let current = ''
96  let quote: string | undefined
97  for (let i = 0; i < inner.length; i++) {
98    const ch = inner[i] as string
99    if (quote !== undefined) {
100      current += ch
101      if (ch === '\\' && quote === '"' && i + 1 < inner.length) {
102        current += inner[++i] as string
103      } else if (ch === quote) quote = undefined
104    } else if (ch === '"' || ch === "'") {
105      quote = ch
106      current += ch
107    } else if (ch === ',') {
108      parts.push(current)
109      current = ''
110    } else current += ch
111  }
112  parts.push(current)
113  return parts.map(unquote).filter(p => p !== '')
114}
115
116type RawMeta = { key: string; scalar?: string; list?: string[]; raw: string }
117
118function parseFrontMatter(lines: string[]): RawMeta[] {
119  const out: RawMeta[] = []
120  for (let i = 0; i < lines.length; i++) {
121    const line = lines[i] as string
122    if (line.trim() === '' || line.trimStart().startsWith('#')) continue
123    const match = /^([A-Za-z_][\w -]*?)\s*:(.*)$/.exec(line)
124    if (match === null) continue
125    const key = (match[1] as string).trim()
126    const rest = (match[2] as string).trim()
127    if (rest === '' || rest === '|' || rest === '>') {
128      // A block list (`- a`) or a block scalar on the indented lines below.
129      const block: string[] = []
130      while (i + 1 < lines.length && /^(\s+|-\s)/.test(lines[i + 1] as string) && !/^[A-Za-z_][\w -]*:/.test(lines[i + 1] as string)) {
131        block.push(lines[++i] as string)
132      }
133      const items = block.filter(l => l.trim() !== '')
134      if (items.length > 0 && items.every(l => /^\s*-\s/.test(l) || /^\s*-$/.test(l))) {
135        out.push({ key, list: items.map(l => unquote(l.replace(/^\s*-\s?/, ''))).filter(v => v !== ''), raw: [rest, ...block].join('\n') })
136      } else {
137        out.push({ key, scalar: items.map(l => l.trim()).join(rest === '|' ? '\n' : ' '), raw: [rest, ...block].join('\n') })
138      }
139    } else if (rest.startsWith('[')) {
140      out.push({ key, list: splitFlow(rest), raw: rest })
141    } else {
142      out.push({ key, scalar: unquote(rest), raw: rest })
143    }
144  }
145  return out
146}
147
148function normKey(key: string): string {
149  const k = key.trim().toLowerCase().replace(/[_ ]+/g, '-')
150  if (k === 'supersededby' || k === 'superseded') return 'superseded-by'
151  if (k === 'decider' || k === 'deciders') return 'deciders'
152  if (k === 'tag' || k === 'tags') return 'tags'
153  if (k === 'file' || k === 'files') return 'files'
154  return k
155}
156
157function listOf(meta: RawMeta): string[] {
158  if (meta.list !== undefined) return meta.list
159  const scalar = meta.scalar ?? ''
160  if (scalar === '' || scalar === '~' || scalar.toLowerCase() === 'null') return []
161  return scalar.split(',').map(s => unquote(s)).filter(s => s !== '')
162}
163
164function idsOf(meta: RawMeta): number[] {
165  const ids: number[] = []
166  for (const item of listOf(meta)) {
167    for (const match of item.matchAll(/\d+/g)) {
168      const n = Number(match[0])
169      if (Number.isSafeInteger(n) && n > 0 && !ids.includes(n)) ids.push(n)
170    }
171  }
172  return ids
173}
174
175export function normStatus(raw: string | undefined): DecisionStatus | undefined {
176  const s = (raw ?? '').trim().toLowerCase()
177  if ((STATUSES as readonly string[]).includes(s)) return s as DecisionStatus
178  if (s === 'deprecated' || s.startsWith('superseded')) return 'superseded'
179  if (s === 'approved' || s === 'done' || s === 'adopted') return 'accepted'
180  if (s === 'draft' || s === 'open' || s === 'pending') return 'proposed'
181  if (s === 'declined' || s === 'abandoned') return 'rejected'
182  return undefined
183}
184
185// ---------------------------------------------------------------------------------------------------------------
186// Parse
187
188const SECTION_ALIASES: Record<string, 'context' | 'decision' | 'alternatives' | 'consequences'> = {
189  context: 'context',
190  'context and problem statement': 'context',
191  'problem statement': 'context',
192  problem: 'context',
193  background: 'context',
194  decision: 'decision',
195  'decision outcome': 'decision',
196  outcome: 'decision',
197  'alternatives considered': 'alternatives',
198  alternatives: 'alternatives',
199  'considered options': 'alternatives',
200  'options considered': 'alternatives',
201  options: 'alternatives',
202  consequences: 'consequences',
203  implications: 'consequences',
204}
205
206/** Trims blank lines at both ends and trailing spaces on each line. */
207function tidy(text: string): string {
208  return text
209    .split('\n')
210    .map(l => l.replace(/[ \t]+$/, ''))
211    .join('\n')
212    .replace(/^\n+|\n+$/g, '')
213}
214
215/** A markdown list into items (continuation lines indented); a non-list text is one item. */
216export function parseAlternatives(body: string): string[] {
217  const text = tidy(body)
218  if (text === '') return []
219  const lines = text.split('\n')
220  const items: string[] = []
221  let isList = true
222  for (const line of lines) {
223    const bullet = /^\s{0,3}(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line)
224    if (bullet !== null) items.push(bullet[1] as string)
225    else if (line.trim() === '') {
226      if (items.length > 0) items[items.length - 1] += '\n'
227    }
228    else if (/^\s+/.test(line) && items.length > 0) items[items.length - 1] += `\n${line.replace(/^\s{1,4}/, '')}`
229    else {
230      isList = false
231      break
232    }
233  }
234  if (!isList) return [text]
235  return items.map(tidy).filter(i => i !== '')
236}
237
238function emptyDecision(): Decision {
239  return {
240    id: 0,
241    title: '',
242    status: 'proposed',
243    date: '',
244    deciders: [],
245    supersedes: [],
246    supersededBy: [],
247    tags: [],
248    files: [],
249    context: '',
250    decision: '',
251    alternatives: [],
252    consequences: '',
253    preamble: '',
254    extraSections: [],
255    extraMeta: [],
256  }
257}
258
259/**
260 * Parses a decision file, tolerating hand edits: CRLF, a BOM, no front matter, block or flow lists, quoted values,
261 * MADR-style section names, missing sections. `fileName` gives the id and the title when the file lacks them.
262 */
263export function parseDecision(source: string, fileName?: string): Decision {
264  const d = emptyDecision()
265  const text = source.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
266  let lines = text.split('\n')
267
268  if ((lines[0] ?? '').trim() === '---') {
269    const end = lines.findIndex((l, i) => i > 0 && (l.trim() === '---' || l.trim() === '...'))
270    if (end > 0) {
271      for (const meta of parseFrontMatter(lines.slice(1, end))) {
272        const key = normKey(meta.key)
273        switch (key) {
274          case 'id': {
275            const n = Number(/\d+/.exec(meta.scalar ?? '')?.[0])
276            if (Number.isSafeInteger(n) && n > 0) d.id = n
277            break
278          }
279          case 'title':
280            d.title = oneLine(meta.scalar ?? listOf(meta).join(', '))
281            break
282          case 'status':
283            d.status = normStatus(meta.scalar) ?? 'proposed'
284            break
285          case 'date':
286            d.date = (meta.scalar ?? '').trim()
287            break
288          case 'deciders':
289            d.deciders = listOf(meta)
290            break
291          case 'supersedes':
292            d.supersedes = idsOf(meta)
293            break
294          case 'superseded-by':
295            d.supersededBy = idsOf(meta)
296            break
297          case 'tags':
298            d.tags = listOf(meta)
299            break
300          case 'files':
301            d.files = listOf(meta)
302            break
303          default:
304            d.extraMeta.push([meta.key, meta.raw])
305        }
306      }
307      lines = lines.slice(end + 1)
308    }
309  }
310
311  let current: { key: string; heading: string; lines: string[] } | undefined
312  const preamble: string[] = []
313  const sections: { key: string; heading: string; lines: string[] }[] = []
314  let fence: string | undefined
315  let sawTitle = false
316  for (const line of lines) {
317    const fenceMatch = /^\s{0,3}(`{3,}|~{3,})/.exec(line)
318    if (fenceMatch !== null) {
319      const marker = (fenceMatch[1] as string)[0] as string
320      if (fence === undefined) fence = marker
321      else if (fence === marker) fence = undefined
322    }
323    if (fence === undefined || fenceMatch !== null) {
324      const h1 = fence === undefined ? /^#\s+(.+?)\s*#*\s*$/.exec(line) : null
325      if (h1 !== null && !sawTitle && current === undefined && preamble.every(l => l.trim() === '')) {
326        sawTitle = true
327        const heading = (h1[1] as string).trim()
328        const numbered = /^(?:ADR[-\s]?)?(\d+)\s*[.:)\-–—]\s*(.+)$/i.exec(heading)
329        if (numbered !== null) {
330          if (d.id === 0) d.id = Number(numbered[1])
331          if (d.title === '') d.title = oneLine(numbered[2] as string)
332        } else if (d.title === '') d.title = oneLine(heading)
333        continue
334      }
335      const h2 = fence === undefined ? /^##\s+(.+?)\s*#*\s*$/.exec(line) : null
336      if (h2 !== null) {
337        const heading = (h2[1] as string).trim()
338        const key = SECTION_ALIASES[heading.toLowerCase().replace(/[:.]+$/, '')] ?? `extra:${heading}`
339        current = { key, heading, lines: [] }
340        sections.push(current)
341        continue
342      }
343    }
344    if (current === undefined) preamble.push(line)
345    else current.lines.push(line)
346  }
347
348  d.preamble = tidy(preamble.join('\n'))
349  for (const section of sections) {
350    const body = tidy(section.lines.join('\n'))
351    const join = (a: string) => (a === '' ? body : body === '' ? a : `${a}\n\n${body}`)
352    switch (section.key) {
353      case 'context':
354        d.context = join(d.context)
355        break
356      case 'decision':
357        d.decision = join(d.decision)
358        break
359      case 'consequences':
360        d.consequences = join(d.consequences)
361        break
362      case 'alternatives':
363        d.alternatives = [...d.alternatives, ...parseAlternatives(body)]
364        break
365      default:
366        d.extraSections.push({ heading: section.heading, body })
367    }
368  }
369
370  if (d.id === 0 && fileName !== undefined) d.id = idFromFileName(fileName) ?? 0
371  if (d.title === '' && fileName !== undefined) {
372    d.title = oneLine(fileName.replace(/\.md$/i, '').replace(/^\d+[-_ ]*/, '').replace(/[-_]+/g, ' ')) || `Decision ${d.id}`
373  }
374  return d
375}
376
377// ---------------------------------------------------------------------------------------------------------------
378// Serialize
379
380const RESERVED = /^(true|false|yes|no|on|off|null|~|-?\d[\d_.,]*|0x[0-9a-f]+)$/i
381
382function quote(value: string): string {
383  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t')}"`
384}
385
386/** A scalar as plain YAML when that reads back the same, else double-quoted. */
387export function yamlScalar(value: string): string {
388  if (value === '') return '""'
389  const isPlain = /^[A-Za-z0-9_./(@][^\n:#"'[\]{}]*$/.test(value) && !/\s$/.test(value) && !RESERVED.test(value) && !value.includes(': ')
390  return isPlain ? value : quote(value)
391}
392
393function yamlItem(value: string): string {
394  const isPlain = /^[A-Za-z0-9_./@+-][A-Za-z0-9_./@+ -]*$/.test(value) && !/\s$/.test(value) && !RESERVED.test(value) && !/^-\s/.test(value)
395  return isPlain ? value : quote(value)
396}
397
398function yamlList(values: readonly (string | number)[]): string {
399  return `[${values.map(v => (typeof v === 'number' ? String(v) : yamlItem(v))).join(', ')}]`
400}
401
402/** The markdown body (title heading and sections) without front matter: what a reader sees. */
403export function serializeBody(d: Decision): string {
404  const out: string[] = [`# ${d.id}. ${oneLine(d.title)}`, '']
405  if (d.preamble !== '') out.push(d.preamble, '')
406  const section = (heading: string, body: string) => {
407    out.push(`## ${heading}`, '')
408    if (body !== '') out.push(body, '')
409  }
410  section('Context', tidy(d.context))
411  section('Decision', tidy(d.decision))
412  section(
413    'Alternatives considered',
414    d.alternatives
415      .map(tidy)
416      .filter(a => a !== '')
417      .map(a => `- ${a.split('\n').map(l => (l === '' ? '' : `  ${l}`)).join('\n').slice(2)}`)
418      .join('\n'),
419  )
420  section('Consequences', tidy(d.consequences))
421  for (const extra of d.extraSections) section(extra.heading, tidy(extra.body))
422  while (out.length > 0 && out[out.length - 1] === '') out.pop()
423  return `${out.join('\n')}\n`
424}
425
426/** Deterministic: fixed key order, flow lists, LF line endings, one trailing newline. */
427export function serializeDecision(d: Decision): string {
428  const meta = [
429    '---',
430    `id: ${d.id}`,
431    `title: ${yamlScalar(oneLine(d.title))}`,
432    `status: ${d.status}`,
433    `date: ${yamlScalar(d.date)}`,
434    `deciders: ${yamlList(d.deciders)}`,
435    `supersedes: ${yamlList(d.supersedes)}`,
436    `superseded-by: ${yamlList(d.supersededBy)}`,
437    `tags: ${yamlList(d.tags)}`,
438    `files: ${yamlList(d.files)}`,
439    ...d.extraMeta.map(([k, raw]) => `${k}:${raw.startsWith('\n') || raw === '' ? '' : ' '}${raw}`),
440    '---',
441    '',
442  ]
443  return `${meta.join('\n')}${serializeBody(d)}`
444}
445
446// ---------------------------------------------------------------------------------------------------------------
447// Numbering, index, listing, search
448
449export function nextId(entries: readonly { id: number; file?: string }[], fileNames: readonly string[] = []): number {
450  let max = 0
451  for (const e of entries) max = Math.max(max, e.id)
452  for (const name of fileNames) max = Math.max(max, idFromFileName(name) ?? 0)
453  return max + 1
454}
455
456function cell(text: string): string {
457  return oneLine(text, 120).replace(/\|/g, '\\|')
458}
459
460/** `<dir>/README.md`: a table of every record, oldest first. */
461export function renderIndex(entries: readonly DecisionEntry[]): string {
462  const rows = [...entries].sort((a, b) => a.id - b.id)
463  const lines = [
464    '# Decision log',
465    '',
466    INDEX_MARKER,
467    '',
468    'Architecture decision records for this repository. Record one with `/decide <title> — <why>` or ask Claude; browse with `/decisions`.',
469    '',
470    '| # | Title | Status | Date |',
471    '| --- | --- | --- | --- |',
472    ...rows.map(e => {
473      const by = e.status === 'superseded' && e.supersededBy.length > 0 ? ` (by ${e.supersededBy.map(n => `#${n}`).join(', ')})` : ''
474      return `| ${pad4(e.id)} | [${cell(e.title)}](${encodeURI(e.file)}) | ${e.status}${by} | ${cell(e.date)} |`
475    }),
476  ]
477  if (rows.length === 0) lines.push('', '_No decisions recorded yet._')
478  return `${lines.join('\n')}\n`
479}
480
481export function newestFirst<T extends { id: number }>(entries: readonly T[]): T[] {
482  return [...entries].sort((a, b) => b.id - a.id)
483}
484
485export function supersededNote(d: Pick<Decision, 'supersededBy'>): string {
486  return d.supersededBy.length === 0 ? '' : ` → ${d.supersededBy.map(n => `#${n}`).join(', ')}`
487}
488
489/** `#7 accepted 2026-10-06 Use SQLite [storage] → #9 (docs/decisions/0007-use-sqlite.md)` */
490export function formatLine(e: DecisionEntry): string {
491  const tags = e.tags.length > 0 ? ` [${e.tags.join(', ')}]` : ''
492  return `#${e.id} ${e.status} ${e.date || '----------'} ${oneLine(e.title, 120)}${tags}${supersededNote(e)} (${e.path})`
493}
494
495export function filterEntries(entries: readonly DecisionEntry[], filter: { status?: string; tag?: string }): DecisionEntry[] {
496  const status = filter.status === undefined ? undefined : normStatus(filter.status)
497  const tag = filter.tag?.trim().toLowerCase()
498  return newestFirst(entries).filter(
499    e => (status === undefined || e.status === status) && (tag === undefined || tag === '' || e.tags.some(t => t.toLowerCase() === tag)),
500  )
501}
502
503export function formatList(entries: readonly DecisionEntry[], dirPath: string, filter: { status?: string; tag?: string } = {}): string {
504  const shown = filterEntries(entries, filter)
505  const what = [filter.status, filter.tag === undefined ? undefined : `tag ${filter.tag}`].filter(Boolean).join(', ')
506  if (shown.length === 0) {
507    return entries.length === 0 ? `No decisions recorded yet in ${dirPath}/.` : `No decisions match (${what}). ${entries.length} in ${dirPath}/.`
508  }
509  const head = `${shown.length} decision${shown.length === 1 ? '' : 's'}${what === '' ? '' : ` (${what})`} in ${dirPath}/, newest first:`
510  return [head, ...shown.map(formatLine)].join('\n')
511}
512
513export function plainBody(d: Decision): string {
514  return [d.context, d.decision, ...d.alternatives, d.consequences, ...d.extraSections.map(s => s.body), d.preamble].join('\n')
515}
516
517export type SearchHit = { entry: DecisionEntry; score: number; snippet: string }
518
519function snippetAround(text: string, terms: readonly string[], width = 140): string {
520  const flat = text.replace(/\s+/g, ' ').trim()
521  const lower = flat.toLowerCase()
522  let at = -1
523  for (const term of terms) {
524    const i = lower.indexOf(term)
525    if (i >= 0 && (at < 0 || i < at)) at = i
526  }
527  if (at < 0) return clip(flat, width)
528  const start = Math.max(0, at - Math.floor(width / 3))
529  const piece = flat.slice(start, start + width)
530  return `${start > 0 ? '…' : ''}${piece}${start + width < flat.length ? '…' : ''}`
531}
532
533function countOf(haystack: string, needle: string): number {
534  let n = 0
535  let i = haystack.indexOf(needle)
536  while (i >= 0 && n < 20) {
537    n++
538    i = haystack.indexOf(needle, i + needle.length)
539  }
540  return n
541}
542
543/** Ranks records by title (heaviest), tags, then body matches; the whole phrase scores extra. */
544export function search(entries: readonly DecisionEntry[], query: string, limit = 10): SearchHit[] {
545  const phrase = query.trim().toLowerCase()
546  const terms = [...new Set(phrase.split(/[^\p{L}\p{N}_]+/u).filter(t => t.length > 0))]
547  if (terms.length === 0) return []
548  const hits: SearchHit[] = []
549  for (const entry of entries) {
550    const title = entry.title.toLowerCase()
551    const tags = entry.tags.map(t => t.toLowerCase())
552    const body = plainBody(entry).toLowerCase()
553    let score = 0
554    let matched = 0
555    for (const term of terms) {
556      const inTitle = countOf(title, term)
557      const inTags = tags.filter(t => t === term).length * 2 + tags.filter(t => t.includes(term)).length
558      const inBody = countOf(body, term)
559      if (inTitle + inTags + inBody > 0) matched++
560      score += inTitle * 6 + inTags * 4 + Math.min(inBody, 5)
561    }
562    if (matched === 0) continue
563    score += matched === terms.length ? 5 : 0
564    if (terms.length > 1 && (title.includes(phrase) || body.includes(phrase))) score += 8
565    if (entry.status === 'superseded' || entry.status === 'rejected') score -= 1
566    hits.push({ entry, score, snippet: snippetAround(plainBody(entry), terms) })
567  }
568  return hits.sort((a, b) => b.score - a.score || b.entry.id - a.entry.id).slice(0, limit)
569}
570
571export function formatSearch(hits: readonly SearchHit[], query: string): string {
572  if (hits.length === 0) return `No decisions match "${query}".`
573  return [
574    `${hits.length} decision${hits.length === 1 ? '' : 's'} match "${query}", best first:`,
575    ...hits.map(h => `${formatLine(h.entry)}\n    ${h.snippet}`),
576  ].join('\n')
577}
578
579/** The full record for the model or a reader: the path, then the file as written. */
580export function formatRecord(e: DecisionEntry): string {
581  return `${e.path}\n\n${serializeDecision(e)}`
582}
583
584/** Markdown for the pane or the inline card: a meta line, then the body. */
585export function recordMarkdown(e: DecisionEntry): string {
586  const meta = [
587    `**${e.status}**`,
588    e.date,
589    e.deciders.length > 0 ? `deciders: ${e.deciders.join(', ')}` : '',
590    e.tags.length > 0 ? `tags: ${e.tags.join(', ')}` : '',
591    e.supersedes.length > 0 ? `supersedes ${e.supersedes.map(n => `#${n}`).join(', ')}` : '',
592    e.supersededBy.length > 0 ? `superseded by ${e.supersededBy.map(n => `#${n}`).join(', ')}` : '',
593  ].filter(s => s !== '')
594  const files = e.files.length > 0 ? `\n\nFiles: ${e.files.map(f => `\`${f}\``).join(', ')}` : ''
595  const [heading, ...rest] = serializeBody(e).split('\n')
596  return [heading, '', `${meta.join(' · ')}  \n\`${e.path}\`${files}`, ...rest].join('\n')
597}
598
599// ---------------------------------------------------------------------------------------------------------------
600// Inputs
601
602export type ToolRequest =
603  | {
604      action: 'record'
605      title: string
606      context: string
607      decision: string
608      alternatives: string[]
609      consequences: string
610      tags: string[]
611      files: string[]
612      deciders: string[]
613      status: DecisionStatus
614      supersedes: number[]
615    }
616  | { action: 'list'; status?: string; tag?: string }
617  | { action: 'search'; query: string }
618  | { action: 'get'; id: number }
619  | { action: 'supersede'; id: number; by: number }
620  | { action: 'set-status'; id: number; status: DecisionStatus }
621  | { action: 'error'; message: string }
622
623function str(v: unknown): string {
624  return typeof v === 'string' ? v : typeof v === 'number' ? String(v) : ''
625}
626
627function strList(v: unknown): string[] {
628  if (typeof v === 'string') return v.split(',').map(s => oneLine(s)).filter(s => s !== '')
629  if (!Array.isArray(v)) return []
630  return v.map(x => oneLine(str(x))).filter(s => s !== '')
631}
632
633function idOf(v: unknown): number | undefined {
634  const n = typeof v === 'number' ? v : Number(/\d+/.exec(str(v))?.[0])
635  return Number.isSafeInteger(n) && n > 0 ? n : undefined
636}
637
638export function parseToolInput(input: Record<string, unknown>): ToolRequest {
639  const action = str(input.action)
640  switch (action) {
641    case 'record': {
642      const title = oneLine(str(input.title))
643      if (title === '') return { action: 'error', message: 'record needs a "title".' }
644      const decision = str(input.decision).trim()
645      if (decision === '') return { action: 'error', message: 'record needs "decision": what was decided.' }
646      const statusRaw = input.status === undefined ? 'accepted' : str(input.status)
647      const status = normStatus(statusRaw)
648      if (status === undefined || status === 'superseded') {
649        return { action: 'error', message: `record "status" must be proposed, accepted or rejected, not "${statusRaw}".` }
650      }
651      const supersedes = (Array.isArray(input.supersedes) ? input.supersedes : input.supersedes === undefined ? [] : [input.supersedes])
652        .map(idOf)
653        .filter((n): n is number => n !== undefined)
654      const deciders = strList(input.deciders)
655      return {
656        action,
657        title,
658        context: str(input.context).trim(),
659        decision,
660        alternatives: Array.isArray(input.alternatives) ? input.alternatives.map(a => str(a).trim()).filter(a => a !== '') : strList(input.alternatives),
661        consequences: str(input.consequences).trim(),
662        tags: strList(input.tags).map(t => t.toLowerCase()),
663        files: strList(input.files),
664        deciders: deciders.length > 0 ? deciders : ['user', 'claude'],
665        status,
666        supersedes,
667      }
668    }
669    case 'list': {
670      const status = input.status === undefined ? undefined : str(input.status)
671      if (status !== undefined && normStatus(status) === undefined) return { action: 'error', message: `Unknown status "${status}".` }
672      return { action, status, tag: input.tag === undefined ? undefined : str(input.tag) }
673    }
674    case 'search': {
675      const query = str(input.query).trim()
676      return query === '' ? { action: 'error', message: 'search needs a "query".' } : { action, query }
677    }
678    case 'get': {
679      const id = idOf(input.id)
680      return id === undefined ? { action: 'error', message: 'get needs an "id" (the record number).' } : { action, id }
681    }
682    case 'supersede': {
683      const id = idOf(input.id)
684      const by = idOf(input.by)
685      if (id === undefined || by === undefined) return { action: 'error', message: 'supersede needs "id" (the old record) and "by" (the new one).' }
686      if (id === by) return { action: 'error', message: 'A record cannot supersede itself.' }
687      return { action, id, by }
688    }
689    case 'set-status': {
690      const id = idOf(input.id)
691      const status = normStatus(str(input.status))
692      if (id === undefined || status === undefined) {
693        return { action: 'error', message: 'set-status needs "id" and "status" (proposed, accepted, superseded or rejected).' }
694      }
695      return { action, id, status }
696    }
697    default:
698      return { action: 'error', message: `Unknown action "${action}". Use record, list, search, get, supersede or set-status.` }
699  }
700}
701
702/** `/decide <title> — <why>`: the separators, in order of preference, are an em or en dash, `--`, then `:`. */
703export function parseDecide(args: string): { title: string; why: string } | { error: string } {
704  const text = args.trim()
705  const usage = 'Usage: /decide <title> — <why>   (also `--` or `:` between them)'
706  if (text === '') return { error: usage }
707  for (const sep of [/\s*[—–]\s*/, /\s+--\s+|\s*--\s*/, /\s*:\s+|:\s*/]) {
708    const match = sep.exec(text)
709    if (match !== null && match.index > 0) {
710      const title = oneLine(text.slice(0, match.index).replace(/^["']|["']$/g, ''))
711      const why = text.slice(match.index + match[0].length).trim()
712      if (title !== '') return { title, why }
713    }
714  }
715  return { title: oneLine(text.replace(/^["']|["']$/g, '')), why: '' }
716}
717
718export type DecisionsCommand = { kind: 'list' } | { kind: 'show'; id: number } | { kind: 'search'; query: string } | { kind: 'error'; message: string }
719
720export function parseDecisionsArgs(args: string): DecisionsCommand {
721  const text = args.trim()
722  if (text === '' || /^(list|ls|all)$/i.test(text)) return { kind: 'list' }
723  const show = /^(?:show\s+|get\s+)?#?(\d+)$/i.exec(text)
724  if (show !== null) return { kind: 'show', id: Number(show[1]) }
725  const found = /^(?:search|find|grep)\s+(.+)$/i.exec(text)
726  if (found !== null) return { kind: 'search', query: (found[1] as string).trim() }
727  return { kind: 'error', message: 'Usage: /decisions | /decisions <n> | /decisions search <query>' }
728}
729
730/** The system-prompt section: where the log is and when to use it. */
731export function guideText(dirPath: string, tool: string): string {
732  return [
733    `Decision log: this repository keeps architecture decision records in ${dirPath}/ (NNNN-slug.md), managed with the ${tool} tool.`,
734    'When you and the user settle a genuine decision (architecture, a dependency, the data model, an API shape, a trade-off the user agreed to), record it with the why and the alternatives considered. Do not record trivial edits or routine fixes.',
735    'Do not rewrite an accepted record: record the new decision and supersede the old one.',
736    'When you need context on why something is the way it is, or before reversing an earlier choice, search the log and get the record.',
737  ].join(' ')
738}
739
types/index.d.ts 45 lines
1export type DecisionStatus = 'proposed' | 'accepted' | 'superseded' | 'rejected'
2
3/** One decision record, as parsed from (and serialized to) its markdown file. */
4export type Decision = {
5  id: number
6  title: string
7  status: DecisionStatus
8  /** YYYY-MM-DD, or whatever a hand-edited file holds. */
9  date: string
10  deciders: string[]
11  supersedes: number[]
12  supersededBy: number[]
13  tags: string[]
14  files: string[]
15  context: string
16  decision: string
17  alternatives: string[]
18  consequences: string
19  /** Text between the title heading and the first section, kept as written. */
20  preamble: string
21  /** Sections other than the four known ones, kept in order. */
22  extraSections: { heading: string; body: string }[]
23  /** Front-matter keys this plugin does not know, kept with their raw values. */
24  extraMeta: [string, string][]
25}
26
27/** A record with where it lives. */
28export type DecisionEntry = Decision & {
29  /** The file name inside the decisions folder. */
30  file: string
31  /** The path relative to the repository root, `/`-separated. */
32  path: string
33}
34
35declare module 'claude-code' {
36  interface PluginState {
37    'decision-log': {
38      /** Every record in the decisions folder, as last read from disk. */
39      records: DecisionEntry[]
40      /** Per drawing (pane or command row): 0 its default view, -1 the list, n > 0 record #n. */
41      selected: StateFamily<number>
42    }
43  }
44}
45