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…

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 … │
╰─────────────────────────────────────────────────────────╯
/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.
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.
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].a-z0-9-, and cut to 60 characters.# 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.<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.
| Command | What 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 :. |
/decisions | Lists 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.
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.
| Action | Input | Result |
|---|---|---|
record | title, 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 |
list | status?, tag? | One line per record, newest first: #7 accepted 2026-10-06 Title [tags] → #9 (path) |
search | query | Ranked by title, then tags, then body matches, each with a snippet and the path |
get | id | The path and the whole file |
supersede | id, by | Marks #id superseded by #by and links both files |
set-status | id, status | Changes 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.
The plugin's rows in /config (pluginConfigs.decision-log.options in settings):
| Option | Default | |
|---|---|---|
dir | docs/decisions | Folder for the records, relative to the repository root. It cannot be absolute and cannot contain ... |
nudge | true | Add the system-prompt note |
$.fs.stat(…, { resolve: true }).dir like ../x and a docs/decisions that is a symlink out of the repo.<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.| Terminal | Desktop (Code tab) | Mobile app | |
|---|---|---|---|
Tool, prompt note, /decide | yes | yes | yes |
/decisions pane | yes (docked or inline) | yes | never placed |
/decisions inline card with buttons | when the pane cannot be placed | same | always |
/decisions <n>, /decisions search inline | yes | yes | yes |
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.
/decisions or tool call./decide takes a single line. For a full record with alternatives and consequences, ask Claude, or edit the file afterwards.hooks/register.tsx 587 lines1import { 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}
587hooks/lib.ts 739 lines1// 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}
739types/index.d.ts 45 lines1export 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