@nikovirtala/adr 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +70 -9
  2. package/dist/cli.js +821 -61
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.js +3 -3
  5. package/dist/{kind-DqFhApu7.js → kind-DjvA3dyA.js} +10 -23
  6. package/dist/kind-DjvA3dyA.js.map +1 -0
  7. package/dist/kind.js +1 -1
  8. package/dist/scope-CLqH-q1y.js +2873 -0
  9. package/dist/scope-CLqH-q1y.js.map +1 -0
  10. package/dist/types/cli/accept.d.ts +2 -2
  11. package/dist/types/cli/deprecate.d.ts +3 -0
  12. package/dist/types/cli/for.d.ts +3 -0
  13. package/dist/types/cli/hooks.d.ts +6 -0
  14. package/dist/types/cli/index-command.d.ts +3 -0
  15. package/dist/types/cli/init.d.ts +3 -0
  16. package/dist/types/cli/link.d.ts +3 -0
  17. package/dist/types/cli/list.d.ts +1 -1
  18. package/dist/types/cli/new.d.ts +1 -1
  19. package/dist/types/cli/note.d.ts +3 -0
  20. package/dist/types/cli/open.d.ts +2 -2
  21. package/dist/types/cli/reject.d.ts +3 -0
  22. package/dist/types/cli/scope.d.ts +7 -0
  23. package/dist/types/cli/supersede.d.ts +1 -1
  24. package/dist/types/cli/text.d.ts +39 -3
  25. package/dist/types/cli/withdraw.d.ts +3 -0
  26. package/dist/types/commit-actions.d.ts +72 -0
  27. package/dist/types/config.d.ts +16 -2
  28. package/dist/types/hooks.d.ts +59 -0
  29. package/dist/types/index.d.ts +6 -2
  30. package/dist/types/init.d.ts +33 -0
  31. package/dist/types/log-index.d.ts +117 -0
  32. package/dist/types/log.d.ts +34 -1
  33. package/dist/types/scope.d.ts +51 -0
  34. package/dist/types/verify.d.ts +6 -4
  35. package/dist/types/views.d.ts +9 -3
  36. package/dist/types/write.d.ts +206 -26
  37. package/package.json +2 -2
  38. package/dist/kind-DqFhApu7.js.map +0 -1
  39. package/dist/write-C3IW5WrA.js +0 -1011
  40. package/dist/write-C3IW5WrA.js.map +0 -1
package/dist/cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { a as showDecision, f as resolveDecision, i as listDecisions, l as openDecisionLog, n as proposeDecision, p as unavailableReason, r as supersedeDecision, s as adrChecks, t as acceptDecision, u as readDecisions } from "./write-C3IW5WrA.js";
3
- import { n as DECISION_STATUSES, t as adrKind } from "./kind-DqFhApu7.js";
2
+ import { A as openDecisionLog, C as adrChecks, D as runDecisionIndex, N as resolveDecision, O as changedSince, S as withdrawDecision, _ as proposeDecision, a as initDecisionLog, c as installDecisionHooks, d as uninstallDecisionHooks, f as acceptDecision, g as noteDecision, h as linkDecision, j as readDecisions, k as numberingNote, l as printDecisionHooks, m as followScopeRenames, n as listDecisions, o as addHookTrailers, p as deprecateDecision, r as showDecision, t as decisionsFor, u as runPreCommitHook, v as rejectDecision, x as supersedeDecision, y as scopeDecision } from "./scope-CLqH-q1y.js";
3
+ import { n as DECISION_STATUSES, t as adrKind } from "./kind-DjvA3dyA.js";
4
4
  import { createRequire } from "node:module";
5
- import { UsageError, renderStateHeader, resolveActor, templateHistoryRef } from "@nikovirtala/ledger";
6
- import { processIO, runCli, verifyCommand } from "@nikovirtala/ledger/cli";
5
+ import { UsageError, exitCode, findingsReport, formatText, renderStateHeader, resolveAttribution, stagedRenames, templateHistoryRef } from "@nikovirtala/ledger";
6
+ import { resolve } from "node:path";
7
+ import { COMMIT_OPTIONS, commitChoice, confirm, processIO, runCli, verifyCommand } from "@nikovirtala/ledger/cli";
7
8
  import { spawn } from "node:child_process";
8
9
  // src/cli/open.ts
9
10
  /**
@@ -27,10 +28,8 @@ async function displayAfterWrite(log) {
27
28
  const read = await readDecisions(log);
28
29
  return { display: (id) => read.projection.views.get(id)?.display ?? id };
29
30
  }
30
- /** `--commit`/`--no-commit` when given, else `[policy] auto_commit`. */
31
- function commitRequested(log, values) {
32
- return typeof values.commit === "boolean" ? values.commit : log.adr.autoCommit;
33
- }
31
+ /** `notes` with `note` appended when there is one. */
32
+ const notesWith = (notes, note) => note === null ? notes : [...notes, note];
34
33
  // src/cli/text.ts
35
34
  /** A table: each column padded to its widest value, trailing spaces removed. */
36
35
  function columns(rows) {
@@ -43,6 +42,21 @@ function columns(rows) {
43
42
  /** `adr list`: one row per decision, `→ <effective>` when it differs from the decision. */
44
43
  function listText(doc, display) {
45
44
  if (doc.decisions.length === 0) return "No decisions.";
45
+ return listRows(doc, display).join("\n");
46
+ }
47
+ /**
48
+ * `adr list --since <rev>` (design 2b D7): a heading, then each row followed by the event types
49
+ * added since `rev`; nothing at all when no decision changed, so the post-merge hook stays quiet.
50
+ */
51
+ function sinceText(doc, since, display) {
52
+ if (doc.decisions.length === 0) return "";
53
+ const rows = listRows(doc, display);
54
+ return [`Changed since ${since.rev} (${since.commit.slice(0, 7)}):`, ...doc.decisions.flatMap((v, i) => {
55
+ const types = [...new Set(since.events.filter((e) => e.subject === v.id).map((e) => e.type))];
56
+ return [rows[i] ?? "", ` [new: ${types.join(", ")}]`];
57
+ })].join("\n");
58
+ }
59
+ function listRows(doc, display) {
46
60
  return columns(doc.decisions.map((v) => {
47
61
  const arrow = v.effective === null ? "→ (cycle)" : v.effective === v.id ? "" : `→ ${display(v.effective)}`;
48
62
  return [
@@ -52,7 +66,7 @@ function listText(doc, display) {
52
66
  v.title,
53
67
  arrow
54
68
  ];
55
- }));
69
+ })).split("\n");
56
70
  }
57
71
  /** `ADR-0003 (m2x7p4d2)`, or the id alone when it has no number. */
58
72
  function named(id, display) {
@@ -90,18 +104,15 @@ const REASONS = {
90
104
  "is empty": "empty",
91
105
  "still contains its guidance text": "guidance text left"
92
106
  };
93
- /**
94
- * The draft lint of `new` as advice: `Fill in N sections before accepting: A, B (why)` for the
95
- * section findings, and one line per other finding.
96
- */
97
- function draftAdvice(findings) {
107
+ /** The section findings of a draft lint as headings and reasons, and the other findings. */
108
+ function sectionGaps(findings) {
98
109
  const headings = [];
99
110
  const reasons = [];
100
111
  const other = [];
101
112
  for (const f of findings) {
102
113
  const m = SECTION_FINDING.exec(f.message);
103
114
  if (m === null) {
104
- other.push(`Draft: ${f.message} [${f.rule}]`);
115
+ other.push(f);
105
116
  continue;
106
117
  }
107
118
  const [, heading = "", what = ""] = m;
@@ -109,47 +120,138 @@ function draftAdvice(findings) {
109
120
  if (!headings.includes(heading)) headings.push(heading);
110
121
  if (!reasons.includes(reason)) reasons.push(reason);
111
122
  }
123
+ return {
124
+ headings,
125
+ reasons,
126
+ other
127
+ };
128
+ }
129
+ /**
130
+ * The draft lint of `new` as advice: `Fill in N sections before accepting: A, B (why)` for the
131
+ * section findings, and one line per other finding.
132
+ */
133
+ function draftAdvice(findings) {
134
+ const { headings, reasons, other } = sectionGaps(findings);
135
+ const n = headings.length;
136
+ return [...n === 0 ? [] : [`Fill in ${n === 1 ? "1 section" : `${n} sections`} before accepting: ${headings.join(", ")} (${reasons.join(", ")})`], ...other.map((f) => `Draft: ${f.message} [${f.rule}]`)];
137
+ }
138
+ /**
139
+ * The stderr warning of `new` when the draft it wrote has unfilled sections (design §7.2), or
140
+ * `null` when there are none.
141
+ */
142
+ function unfilledWarning(doc) {
143
+ if (doc.command !== "new" || !doc.ok || doc.written.length === 0) return null;
144
+ const { headings, reasons } = sectionGaps(doc.findings);
112
145
  const n = headings.length;
113
- return [...n === 0 ? [] : [`Fill in ${n === 1 ? "1 section" : `${n} sections`} before accepting: ${headings.join(", ")} (${reasons.join(", ")})`], ...other];
146
+ const path = doc.findings.find((f) => f.file !== null)?.file ?? doc.decision?.body_path ?? null;
147
+ if (n === 0 || path === null) return null;
148
+ return `${path}: fill in ${n === 1 ? "1 required section" : `${n} required sections`} before committing or accepting: ${headings.join(", ")} (${reasons.join(", ")}); adr verify reports this until then`;
114
149
  }
115
150
  function findingLines(findings) {
116
151
  return findings.map((f) => ` ${f.file === null ? "" : `${f.file}: `}${f.message} [${f.rule}]`);
117
152
  }
118
- const short = (sha) => sha.slice(0, 7);
153
+ const short$1 = (sha) => sha.slice(0, 7);
154
+ /**
155
+ * `adr scope --follow-renames`: one `<named>: <old globs> → <new globs>` line per changed
156
+ * decision (`affected`), whether or not a decision was given.
157
+ */
158
+ function followRenamesText(doc, display, o) {
159
+ if (!o.renamed) return "No staged renames.";
160
+ if (doc.affected.length === 0) return "No scope globs follow the staged renames.";
161
+ return [...doc.affected.map((v) => {
162
+ const before = o.before.get(v.id) ?? [];
163
+ const old = before.filter((g) => !v.applies_to.includes(g));
164
+ const now = v.applies_to.filter((g) => !before.includes(g));
165
+ const change = old.length === 0 ? `+ ${now.join(", ")}` : `${old.join(", ")} → ${now.join(", ")}`;
166
+ return `${named(v.id, display)}: ${change}`;
167
+ }), "Staged; commit the scoped events with the renames."].join("\n");
168
+ }
169
+ /** The past participle of each write command, for `Not <verb>: …`. */
170
+ const VERBS = {
171
+ new: "proposed",
172
+ accept: "accepted",
173
+ supersede: "superseded",
174
+ reject: "rejected",
175
+ withdraw: "withdrawn",
176
+ deprecate: "deprecated",
177
+ note: "noted",
178
+ link: "linked",
179
+ scope: "scoped"
180
+ };
119
181
  /**
120
- * The text of `adr new|accept|supersede` (design §5.6). `subject` names what a refused command
121
- * was about; `number` is the decision's number in the write result, if it has one now.
182
+ * The text of a write command (design §5.6, §6.1). `subject` names what a refused command was
183
+ * about; `number` is the decision's number in the write result, if it has one now; `reason` is
184
+ * the reason a lifecycle exit recorded.
122
185
  */
123
186
  function writeText(doc, display, o) {
124
- const verb = {
125
- new: "proposed",
126
- accept: "accepted",
127
- supersede: "superseded"
128
- }[doc.command];
129
- if (!doc.ok) return [`Not ${verb}: ${o.subject}`, ...findingLines(doc.findings)].join("\n");
187
+ const verb = VERBS[doc.command];
188
+ if (!doc.ok) {
189
+ const unfilled = doc.command === "new" && sectionGaps(doc.findings).headings.length > 0;
190
+ return [
191
+ `Not ${verb}: ${o.subject}`,
192
+ ...findingLines(doc.findings),
193
+ ...unfilled ? ["Fill them in with --section <id>=<markdown>, or run without --commit and edit the draft."] : []
194
+ ].join("\n");
195
+ }
196
+ if (o.followRenames !== void 0) return followRenamesText(doc, display, o.followRenames);
130
197
  const v = doc.decision;
131
198
  if (v === null) return "";
132
- const committed = doc.commit === null ? null : `Committed ${short(doc.commit)}`;
199
+ const committed = doc.commit === null ? null : `Committed ${short$1(doc.commit)}`;
200
+ const recorded = committed === null ? "Staged; commit to record it." : `${committed}.`;
133
201
  switch (doc.command) {
134
202
  case "new": return [
135
203
  `Proposed ${v.id}: ${v.title}`,
136
204
  ...doc.written.map((p) => ` ${p}`),
137
205
  ...draftAdvice(doc.findings),
138
206
  ...committed === null ? [] : [`${committed}.`],
139
- `Next: adr accept ${v.id}`
207
+ o.actor?.kind === "agent" && o.acceptRequiresHuman === true ? `Next: ask a human to review and accept it (adr accept ${v.id})` : `Next: adr accept ${v.id}`
140
208
  ].join("\n");
141
209
  case "accept": return [
142
210
  `Accepted ${v.id}: ${v.title}`,
143
211
  ...doc.affected.map((a) => `Superseded ${named(a.id, display)}: ${a.title}`),
212
+ ...o.copied === void 0 || o.copied === null ? [] : [`Copied scope from ${o.copied.displays.join(", ")}: ${o.copied.globs.join(", ")}`],
144
213
  committed === null ? "Staged; commit to record it." : o.number === null ? `${committed}. The number is assigned when this commit lands on main.` : `${committed} as ${o.number}.`
145
214
  ].join("\n");
146
215
  case "supersede": return [
147
216
  `Superseded ${named(v.id, display)}: ${v.title}`,
148
217
  ` by ${named(v.superseded_by ?? "", display)}`,
149
- committed === null ? "Staged; commit to record it." : `${committed}.`
218
+ recorded
150
219
  ].join("\n");
220
+ case "reject":
221
+ case "withdraw":
222
+ case "deprecate": return [
223
+ `${verb.charAt(0).toUpperCase()}${verb.slice(1)} ${named(v.id, display)}: ${v.title}`,
224
+ ...o.reason === void 0 || o.reason === null ? [] : [` reason: ${o.reason}`],
225
+ recorded
226
+ ].join("\n");
227
+ case "note": return [`Noted on ${named(v.id, display)}: ${v.title}`, recorded].join("\n");
228
+ case "link": {
229
+ const link = doc.written.length === 0 ? void 0 : v.links.at(-1);
230
+ if (link === void 0) return `Links of ${named(v.id, display)} unchanged`;
231
+ return [`Linked ${named(v.id, display)} ${link.rel} ${named(link.to, display)}`, recorded].join("\n");
232
+ }
233
+ case "scope": {
234
+ const before = new Set(o.scopeBefore ?? []);
235
+ const after = new Set(v.applies_to);
236
+ const globs = v.applies_to.length === 0 ? "no globs" : v.applies_to.join(", ");
237
+ if (doc.written.length === 0) return `Scope of ${named(v.id, display)} unchanged: ${globs}`;
238
+ return [
239
+ `Scope of ${named(v.id, display)}: ${v.title}`,
240
+ ...v.applies_to.filter((g) => !before.has(g)).map((g) => ` + ${g}`),
241
+ ...[...before].filter((g) => !after.has(g)).map((g) => ` - ${g}`),
242
+ ` = ${globs}`,
243
+ recorded
244
+ ].join("\n");
245
+ }
151
246
  }
152
247
  }
248
+ /**
249
+ * "Applies to" without globs: nothing is recorded, which isn't "governs nothing" (design §7.3),
250
+ * with the command that adds globs while the decision can still take them.
251
+ */
252
+ function noGlobs(v, display) {
253
+ return adrKind.machine.allowed(v.status, "scoped") ? `no globs recorded; add them with adr scope ${display(v.id)} --add <glob>` : "no globs recorded";
254
+ }
153
255
  /** `adr show`: the state header, the decision's fields and its body. */
154
256
  function showText(doc, display) {
155
257
  const v = doc.decision;
@@ -160,7 +262,7 @@ function showText(doc, display) {
160
262
  ["Effective", v.effective === null ? "none (supersession cycle)" : named(v.effective, display)],
161
263
  ["Chain", v.chain.map(display).join(" → ")],
162
264
  ["Supersedes", supersedes],
163
- ["Applies to", v.applies_to.length === 0 ? "none" : v.applies_to.join(", ")],
265
+ ["Applies to", v.applies_to.length === 0 ? noGlobs(v, display) : v.applies_to.join(", ")],
164
266
  ["Body", bodyLine(v)]
165
267
  ];
166
268
  if (doc.events === null) fields.push(["History", v.history.join(" · ")]);
@@ -170,13 +272,81 @@ function showText(doc, display) {
170
272
  const body = doc.body === null ? "" : `\n${doc.body.endsWith("\n") ? doc.body : `${doc.body}\n`}`;
171
273
  return `${renderStateHeader(doc.header)}\n${lines.join("\n")}\n${body}`;
172
274
  }
275
+ /**
276
+ * `adr for`: per path, one row per decision (named, status, title, how it matched), its warning
277
+ * below it when it isn't in force, then the anchors that name no decision of this log.
278
+ */
279
+ function forText(doc, display, keyword) {
280
+ const lines = [];
281
+ for (const p of doc.paths) {
282
+ lines.push(p.tracked ? p.path : `${p.path} (not tracked)`);
283
+ if (p.decisions.length === 0 && p.unresolved.length === 0) lines.push(" no decisions");
284
+ const rows = columns(p.decisions.map((d) => [
285
+ named(d.id, display),
286
+ d.status,
287
+ d.title,
288
+ d.via.map((v) => v.type === "glob" ? `glob ${v.glob}` : `anchor line ${v.line}`).join(", ")
289
+ ])).split("\n");
290
+ p.decisions.forEach((d, i) => {
291
+ lines.push(` ${rows[i] ?? ""}`);
292
+ if (d.warning !== null) lines.push(` ${d.warning}`);
293
+ });
294
+ for (const u of p.unresolved) {
295
+ const why = u.reason === "other-log" ? "in another log" : "not in this log";
296
+ lines.push(` unresolved: ${keyword}:${u.ref} (line ${u.line}, ${why})`);
297
+ }
298
+ }
299
+ return lines.join("\n");
300
+ }
301
+ /** `adr init`: what was written and kept, then the next step; or that nothing was needed. */
302
+ function initText(doc) {
303
+ if (doc.written.length === 0) return `Already initialized: ${doc.root} (kept ${doc.kept.join(", ")})`;
304
+ return [
305
+ `Initialized ${doc.root}`,
306
+ ...doc.written.map((p) => ` ${p}`),
307
+ ...doc.kept.map((p) => `Kept ${p}`),
308
+ ...doc.commit === null ? [] : [`Committed ${short$1(doc.commit)}.`],
309
+ "Next: adr new \"<title>\""
310
+ ].join("\n");
311
+ }
173
312
  // src/cli/accept.ts
174
- /** `adr accept <decision> [--commit | --no-commit]` (design §5.6). */
313
+ const MAX_LISTED = 5;
314
+ /** `Copy the scope of ADR-0002 (src/a/**, src/b/**) to k3f9q8a1? [y/N] ` (design §6.10). */
315
+ function question(id, offer) {
316
+ const shown = offer.globs.slice(0, MAX_LISTED).join(", ");
317
+ const more = offer.globs.length - MAX_LISTED;
318
+ const globs = more > 0 ? `${shown} and ${more} more` : shown;
319
+ const ds = offer.displays;
320
+ return `Copy the scope of ${ds.length === 1 ? ds[0] : `${ds.slice(0, -1).join(", ")} and ${ds.at(-1)}`} (${ds.length === 1 ? globs : `${offer.globs.length} globs: ${globs}`}) to ${id}? [y/N] `;
321
+ }
322
+ /**
323
+ * The flags decide; without them a person at a terminal is asked, and everyone else (scripts,
324
+ * hooks, CI, `--json`) gets the note with the `adr scope` command (design §6.10, D-e). `seen`
325
+ * receives the offer that was copied, for the text.
326
+ */
327
+ function copyChoice(io, json, value, seen) {
328
+ const copy = async (offer) => {
329
+ seen(offer);
330
+ return true;
331
+ };
332
+ if (value === true) return copy;
333
+ if (value === false) return "skip";
334
+ if (json || !io.interactive) return "suggest";
335
+ return async (offer) => {
336
+ const yes = await confirm(io, question(offer.id, offer), { json });
337
+ if (yes) seen(offer);
338
+ return yes;
339
+ };
340
+ }
341
+ /** `adr accept <decision> [--copy-scope | --no-copy-scope] …` (design §5.6, §6.10). */
175
342
  const acceptCommand = {
176
343
  name: "accept",
177
344
  summary: "accept a proposed decision, superseding the decisions it declares",
178
- usage: "accept <decision> [--commit | --no-commit]",
179
- options: { commit: { type: "boolean" } },
345
+ usage: "accept <decision> [--copy-scope | --no-copy-scope] [--commit | --no-commit] [-m <message>]",
346
+ options: {
347
+ "copy-scope": { type: "boolean" },
348
+ ...COMMIT_OPTIONS
349
+ },
180
350
  positionals: {
181
351
  min: 1,
182
352
  max: 1,
@@ -186,11 +356,19 @@ const acceptCommand = {
186
356
  async run({ io, flags, values, positionals }) {
187
357
  const input = positionals[0] ?? "";
188
358
  const log = await openForCli(io, flags);
189
- const actor = await resolveActor(log.git, { env: io.env });
359
+ const { actor, notes: attribution } = await resolveAttribution(log.git, { env: io.env });
360
+ const seen = { offer: null };
361
+ const copyScope = copyChoice(io, flags.json, values["copy-scope"], (offer) => {
362
+ seen.offer = offer;
363
+ });
190
364
  const doc = await acceptDecision(log, {
191
365
  id: input,
192
366
  actor
193
- }, { commit: commitRequested(log, values) });
367
+ }, {
368
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit }),
369
+ notes: attribution,
370
+ copyScope
371
+ });
194
372
  const { display } = await displayAfterWrite(log);
195
373
  const v = doc.decision;
196
374
  return {
@@ -198,7 +376,295 @@ const acceptCommand = {
198
376
  json: doc,
199
377
  text: writeText(doc, display, {
200
378
  subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
201
- number: v === null || doc.commit === null || v.number === null ? null : v.display
379
+ number: v === null || doc.commit === null || v.number === null ? null : v.display,
380
+ copied: doc.ok ? seen.offer : null
381
+ }),
382
+ notes: doc.notes
383
+ };
384
+ }
385
+ };
386
+ // src/cli/deprecate.ts
387
+ const USAGE$5 = "deprecate <decision> --reason <text> [--commit | --no-commit] [-m <message>]";
388
+ /** `adr deprecate` (design §6.5): an accepted decision no longer in force. */
389
+ const deprecateCommand = {
390
+ name: "deprecate",
391
+ summary: "deprecate an accepted decision",
392
+ usage: USAGE$5,
393
+ options: {
394
+ reason: { type: "string" },
395
+ ...COMMIT_OPTIONS
396
+ },
397
+ positionals: {
398
+ min: 1,
399
+ max: 1,
400
+ names: ["decision"]
401
+ },
402
+ ref: "reject",
403
+ async run({ io, flags, values, positionals }) {
404
+ const input = positionals[0] ?? "";
405
+ if (typeof values.reason !== "string" || values.reason.trim() === "") throw new UsageError(`missing --reason <text>; usage: adr ${USAGE$5}`);
406
+ const reason = typeof values.reason === "string" ? values.reason.trim() : null;
407
+ const log = await openForCli(io, flags);
408
+ const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
409
+ const doc = await deprecateDecision(log, {
410
+ id: input,
411
+ reason,
412
+ actor
413
+ }, {
414
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit }),
415
+ notes
416
+ });
417
+ const { display } = await displayAfterWrite(log);
418
+ const v = doc.decision;
419
+ return {
420
+ exit: doc.ok ? 0 : 1,
421
+ json: doc,
422
+ text: writeText(doc, display, {
423
+ subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
424
+ number: null,
425
+ reason
426
+ }),
427
+ notes: doc.notes
428
+ };
429
+ }
430
+ };
431
+ // src/cli/for.ts
432
+ /** `adr for <path>… [--ref <ref>]` (design §6.9): the decisions that govern each file. */
433
+ const forCommand = {
434
+ name: "for",
435
+ summary: "list the decisions that govern files, by scope glob and code anchor",
436
+ usage: "for <path>…",
437
+ options: {},
438
+ positionals: {
439
+ min: 1,
440
+ max: 100,
441
+ names: ["path"]
442
+ },
443
+ ref: "read",
444
+ async run({ io, flags, positionals }) {
445
+ const log = await openForCli(io, flags);
446
+ const read = await readDecisions(log);
447
+ const doc = await decisionsFor(read, log, {
448
+ paths: positionals,
449
+ cwd: io.cwd
450
+ });
451
+ const display = (id) => read.projection.views.get(id)?.display ?? id;
452
+ return {
453
+ exit: 0,
454
+ json: doc,
455
+ text: forText(doc, display, read.adr.anchorKeyword),
456
+ notes: notesWith(doc.notes, numberingNote(read, { unavailable: false }))
457
+ };
458
+ }
459
+ };
460
+ // src/cli/hooks.ts
461
+ const USAGE$4 = "hooks install | uninstall | print --manager lefthook|husky|pre-commit | run pre-commit | trailers <message-file>";
462
+ const MANAGERS = [
463
+ "lefthook",
464
+ "husky",
465
+ "pre-commit"
466
+ ];
467
+ const outcome = (doc, text, notes) => ({
468
+ exit: 0,
469
+ json: doc,
470
+ text,
471
+ notes
472
+ });
473
+ /**
474
+ * `adr hooks install | uninstall | print --manager … | run pre-commit | trailers <message-file>`
475
+ * (design 2b D4, D8, §4.3): adr's git hooks through ledger's shims, shared with other ark tools.
476
+ */
477
+ const hooksCommand = {
478
+ name: "hooks",
479
+ summary: "install, remove or print adr's git hooks, and run their steps",
480
+ usage: USAGE$4,
481
+ options: { manager: { type: "string" } },
482
+ positionals: {
483
+ min: 1,
484
+ max: 2,
485
+ names: ["action", "argument"]
486
+ },
487
+ ref: "reject",
488
+ async run({ io, flags, values, positionals }) {
489
+ const [action = "", argument] = positionals;
490
+ const manager = values.manager;
491
+ if (manager !== void 0 && action !== "print") throw new UsageError(`--manager goes with hooks print only; usage: adr ${USAGE$4}`);
492
+ const noArgument = () => {
493
+ if (argument !== void 0) throw new UsageError(`unexpected argument ${JSON.stringify(argument)}; usage: adr ${USAGE$4}`);
494
+ };
495
+ switch (action) {
496
+ case "install": {
497
+ noArgument();
498
+ const log = await openForCli(io, flags);
499
+ const doc = await installDecisionHooks(log);
500
+ const lines = [...[...doc.written, ...doc.unchanged].map((p) => `${doc.written.includes(p) ? "wrote" : "unchanged"} ${p}`).sort((a, b) => order(a) - order(b)), `adr hooks: ${doc.registrations.map((r) => r.hook).join(", ")}`];
501
+ return outcome(doc, lines.join("\n"), log.notes);
502
+ }
503
+ case "uninstall": {
504
+ noArgument();
505
+ const log = await openForCli(io, flags);
506
+ const doc = await uninstallDecisionHooks(log);
507
+ const text = doc.written.length === 0 ? "adr hooks were not installed" : doc.written.map((p) => `removed adr from ${p}`).join("\n");
508
+ return outcome(doc, text, log.notes);
509
+ }
510
+ case "print": {
511
+ noArgument();
512
+ if (manager === void 0) throw new UsageError("hooks print needs --manager lefthook, husky or pre-commit");
513
+ if (!MANAGERS.includes(manager)) throw new UsageError(`unknown hook manager ${JSON.stringify(manager)}; use lefthook, husky or pre-commit`);
514
+ const log = await openForCli(io, flags);
515
+ const doc = printDecisionHooks(log, manager);
516
+ return outcome(doc, doc.snippet ?? "", log.notes);
517
+ }
518
+ case "run": {
519
+ if (argument !== "pre-commit") throw new UsageError("adr hooks run supports pre-commit");
520
+ const worktree = await openForCli(io, flags);
521
+ const index = await openForCli(io, flags, { reader: "index" });
522
+ const r = await runPreCommitHook({
523
+ worktree,
524
+ index,
525
+ env: io.env
526
+ });
527
+ return {
528
+ exit: r.exit,
529
+ json: findingsReport(r.findings, { failOn: r.failOn }),
530
+ text: r.findings.length === 0 ? "" : formatText(r.findings),
531
+ notes: r.notes
532
+ };
533
+ }
534
+ case "trailers": {
535
+ if (argument === void 0) throw new UsageError(`missing argument <message-file>; usage: adr ${USAGE$4}`);
536
+ const log = await openForCli(io, flags);
537
+ const doc = await addHookTrailers(log, resolve(io.cwd, argument));
538
+ return outcome(doc, "", log.notes);
539
+ }
540
+ default: throw new UsageError(`unknown hooks action ${JSON.stringify(action)}; use install, uninstall, print, run or trailers`);
541
+ }
542
+ }
543
+ };
544
+ /** A shim line's rank in hook order, for the install text. */
545
+ function order(line) {
546
+ return [
547
+ "pre-commit",
548
+ "commit-msg",
549
+ "pre-push",
550
+ "post-merge"
551
+ ].findIndex((h) => line.endsWith(`/${h}`));
552
+ }
553
+ // src/cli/index-command.ts
554
+ const short = (sha) => sha.slice(0, 7);
555
+ /** The text of `adr index` (design 2b §4.5). */
556
+ function indexText({ doc, refreshed }) {
557
+ const { path } = doc;
558
+ if (doc.check) return doc.findings.length === 0 ? `${path} is up to date` : formatText(doc.findings);
559
+ if (doc.commit !== null) return [`Committed ${path} as ${short(doc.commit)}`, ...refreshed ? [`Wrote ${path} with uncommitted log changes`] : []].join("\n");
560
+ if (refreshed) return `${path} is up to date in HEAD; wrote the work tree's copy, which includes uncommitted log changes`;
561
+ if (doc.fresh) return `${path} is up to date`;
562
+ return doc.written.length > 0 ? `Wrote ${path}` : `Staged ${path}`;
563
+ }
564
+ /** `adr index [--check] [--commit | --no-commit] [-m <message>]` (design 2b §4.5). */
565
+ const indexCommand = {
566
+ name: "index",
567
+ summary: "regenerate the decision index (index.md), or check that it is current",
568
+ usage: "index [--check] [--commit | --no-commit] [-m <message>]",
569
+ options: {
570
+ check: { type: "boolean" },
571
+ ...COMMIT_OPTIONS
572
+ },
573
+ positionals: {
574
+ min: 0,
575
+ max: 0,
576
+ names: []
577
+ },
578
+ ref: "read",
579
+ async run({ io, flags, values }) {
580
+ const check = values.check === true;
581
+ if (check && (values.commit === true || typeof values.message === "string")) throw new UsageError("--check writes nothing; drop --commit and --message");
582
+ if (!check && flags.ref !== null) throw new UsageError("adr index writes the work tree; use --ref only with --check");
583
+ const log = await openForCli(io, flags);
584
+ const run = await runDecisionIndex(log, {
585
+ check,
586
+ ...check ? {} : commitChoice(values, { autoCommit: log.adr.autoCommit })
587
+ });
588
+ return {
589
+ exit: check ? exitCode(run.doc.findings, { failOn: log.adr.failOn }) : 0,
590
+ json: run.doc,
591
+ text: indexText(run),
592
+ notes: run.doc.notes
593
+ };
594
+ }
595
+ };
596
+ // src/cli/init.ts
597
+ /** `adr init [--template <name>]… [--commit | --no-commit] [-m <message>]` (design §6.2). */
598
+ const initCommand = {
599
+ name: "init",
600
+ summary: "set up a decision log: a config file and starter templates",
601
+ usage: "init [--template <name>]… [--commit | --no-commit] [-m <message>]",
602
+ options: {
603
+ template: {
604
+ type: "string",
605
+ multiple: true
606
+ },
607
+ ...COMMIT_OPTIONS
608
+ },
609
+ positionals: {
610
+ min: 0,
611
+ max: 0,
612
+ names: []
613
+ },
614
+ ref: "reject",
615
+ async run({ io, flags, values }) {
616
+ const templates = Array.isArray(values.template) ? values.template : void 0;
617
+ const log = await openForCli(io, flags);
618
+ const doc = await initDecisionLog(log, {
619
+ ...templates === void 0 ? {} : { templates },
620
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit })
621
+ });
622
+ return {
623
+ exit: 0,
624
+ json: doc,
625
+ text: initText(doc),
626
+ notes: doc.notes
627
+ };
628
+ }
629
+ };
630
+ // src/cli/link.ts
631
+ /** `adr link <decision> <target> [--rel <rel>]` (design §6.7): a typed link, in any state. */
632
+ const linkCommand = {
633
+ name: "link",
634
+ summary: "link a decision to another one, here or in a configured source",
635
+ usage: "link <decision> <target> [--rel relates-to|refines|depends-on] [--commit | --no-commit] [-m <message>]",
636
+ options: {
637
+ rel: { type: "string" },
638
+ ...COMMIT_OPTIONS
639
+ },
640
+ positionals: {
641
+ min: 2,
642
+ max: 2,
643
+ names: ["decision", "target"]
644
+ },
645
+ ref: "reject",
646
+ async run({ io, flags, values, positionals }) {
647
+ const [input = "", to = ""] = positionals;
648
+ const rel = typeof values.rel === "string" ? values.rel : "relates-to";
649
+ const log = await openForCli(io, flags);
650
+ const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
651
+ const doc = await linkDecision(log, {
652
+ id: input,
653
+ to,
654
+ rel,
655
+ actor
656
+ }, {
657
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit }),
658
+ notes
659
+ });
660
+ const { display } = await displayAfterWrite(log);
661
+ const v = doc.decision;
662
+ return {
663
+ exit: doc.ok ? 0 : 1,
664
+ json: doc,
665
+ text: writeText(doc, display, {
666
+ subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
667
+ number: null
202
668
  }),
203
669
  notes: doc.notes
204
670
  };
@@ -212,15 +678,25 @@ function statuses(values) {
212
678
  return s;
213
679
  });
214
680
  }
215
- /** `adr list [--status <status>]…` (design §5.6). */
681
+ /** `--since`'s revision: non-empty and not an option. */
682
+ function sinceRev(values) {
683
+ const v = values.since;
684
+ if (typeof v !== "string") return null;
685
+ if (v.trim() === "" || v.startsWith("-")) throw new UsageError("--since must be a revision");
686
+ return v;
687
+ }
688
+ /** `adr list [--status <status>]… [--since <rev>]` (design §5.6, 2b D7). */
216
689
  const listCommand = {
217
690
  name: "list",
218
691
  summary: "list decisions with their computed status",
219
- usage: "list [--status <status>]…",
220
- options: { status: {
221
- type: "string",
222
- multiple: true
223
- } },
692
+ usage: "list [--status <status>]… [--since <rev>]",
693
+ options: {
694
+ status: {
695
+ type: "string",
696
+ multiple: true
697
+ },
698
+ since: { type: "string" }
699
+ },
224
700
  positionals: {
225
701
  min: 0,
226
702
  max: 0,
@@ -229,10 +705,17 @@ const listCommand = {
229
705
  ref: "read",
230
706
  async run({ io, flags, values }) {
231
707
  const status = statuses(values);
232
- const read = await readDecisions(await openForCli(io, flags));
233
- const doc = listDecisions(read, { status });
708
+ const rev = sinceRev(values);
709
+ const log = await openForCli(io, flags);
710
+ const since = rev === null ? null : await changedSince(log, rev);
711
+ const read = await readDecisions(log);
712
+ const doc = listDecisions(read, {
713
+ status,
714
+ since
715
+ });
234
716
  const notes = [...doc.notes];
235
- if (!read.numbering.available && doc.decisions.length > 0) notes.push(`numbers are unavailable (${unavailableReason(read.numbering)}); decisions display by id`);
717
+ const numbering = numberingNote(read, { unavailable: true });
718
+ if (numbering !== null && doc.decisions.length > 0) notes.push(numbering);
236
719
  if (doc.findings.length > 0) notes.push(`the decision log has ${plural(doc.findings.length, "problem", "problems")}; run adr verify`);
237
720
  if (doc.unreadable.length > 0) notes.push(`${plural(doc.unreadable.length, "decision uses", "decisions use")} a newer format; upgrade adr`);
238
721
  if (doc.unfoldable.length > 0) notes.push(`${plural(doc.unfoldable.length, "decision", "decisions")} can't be read; run adr verify`);
@@ -240,7 +723,7 @@ const listCommand = {
240
723
  return {
241
724
  exit: 0,
242
725
  json: doc,
243
- text: listText(doc, display),
726
+ text: doc.since === null ? listText(doc, display) : sinceText(doc, doc.since, display),
244
727
  notes
245
728
  };
246
729
  }
@@ -283,7 +766,7 @@ function editorFor(env, o) {
283
766
  });
284
767
  }
285
768
  // src/cli/new.ts
286
- const strings = (v) => Array.isArray(v) ? v : [];
769
+ const strings$1 = (v) => Array.isArray(v) ? v : [];
287
770
  /** `--section <id>=<markdown>` values, split at the first `=`; each id at most once. */
288
771
  function sections(values) {
289
772
  const out = {};
@@ -303,7 +786,7 @@ function sections(values) {
303
786
  const newCommand = {
304
787
  name: "new",
305
788
  summary: "propose a decision from a template and stage it",
306
- usage: "new <title> [--template <name>] [--supersedes <decision>]… [--section <id>=<markdown>]… [--edit | --no-edit] [--commit | --no-commit]",
789
+ usage: "new <title> [--template <name>] [--supersedes <decision>]… [--section <id>=<markdown>]… [--edit | --no-edit] [--commit | --no-commit] [-m <message>]",
307
790
  options: {
308
791
  template: { type: "string" },
309
792
  supersedes: {
@@ -315,7 +798,7 @@ const newCommand = {
315
798
  multiple: true
316
799
  },
317
800
  edit: { type: "boolean" },
318
- commit: { type: "boolean" }
801
+ ...COMMIT_OPTIONS
319
802
  },
320
803
  positionals: {
321
804
  min: 1,
@@ -325,10 +808,10 @@ const newCommand = {
325
808
  ref: "reject",
326
809
  async run({ io, flags, values, positionals }) {
327
810
  const title = positionals[0] ?? "";
328
- const given = sections(strings(values.section));
811
+ const given = sections(strings$1(values.section));
329
812
  const log = await openForCli(io, flags);
330
- const actor = await resolveActor(log.git, { env: io.env });
331
- const requested = commitRequested(log, values);
813
+ const { actor, notes: attribution } = await resolveAttribution(log.git, { env: io.env });
814
+ const { commit: requested, message } = commitChoice(values, { autoCommit: log.adr.autoCommit });
332
815
  const notes = [];
333
816
  const editor = editorFor(io.env, { stdoutIsTTY: io.stdoutIsTTY });
334
817
  const wantsEdit = values.edit === true || values.edit === void 0 && io.interactive;
@@ -341,24 +824,245 @@ const newCommand = {
341
824
  const doc = await proposeDecision(log, {
342
825
  title,
343
826
  sections: given,
344
- supersedes: strings(values.supersedes),
827
+ supersedes: strings$1(values.supersedes),
345
828
  actor,
346
829
  ...typeof values.template === "string" ? { template: values.template } : {}
347
830
  }, {
348
831
  commit: requested,
832
+ message,
833
+ notes: attribution,
349
834
  ...edit === void 0 ? {} : { edit },
350
835
  onNote: (note) => notes.push(requested ? `not committed: ${note}` : note)
351
836
  });
352
837
  const { display } = await displayAfterWrite(log);
353
838
  const v = doc.decision;
839
+ const unfilled = unfilledWarning(doc);
354
840
  return {
355
841
  exit: doc.ok ? 0 : 1,
356
842
  json: doc,
357
843
  text: writeText(doc, display, {
358
844
  subject: title.trim(),
359
- number: v === null || v.number === null ? null : v.display
845
+ number: v === null || v.number === null ? null : v.display,
846
+ actor,
847
+ acceptRequiresHuman: log.adr.acceptRequiresHuman
848
+ }),
849
+ notes: [
850
+ ...doc.notes,
851
+ ...notes,
852
+ ...unfilled === null ? [] : [unfilled]
853
+ ]
854
+ };
855
+ }
856
+ };
857
+ // src/cli/note.ts
858
+ /** `adr note <decision> <text>` (design §6.6): a note on a decision in any state. */
859
+ const noteCommand = {
860
+ name: "note",
861
+ summary: "add a note to a decision, in any state",
862
+ usage: "note <decision> <text> [--commit | --no-commit] [-m <message>]",
863
+ options: { ...COMMIT_OPTIONS },
864
+ positionals: {
865
+ min: 2,
866
+ max: 2,
867
+ names: ["decision", "text"]
868
+ },
869
+ ref: "reject",
870
+ async run({ io, flags, values, positionals }) {
871
+ const [input = "", text = ""] = positionals;
872
+ const log = await openForCli(io, flags);
873
+ const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
874
+ const doc = await noteDecision(log, {
875
+ id: input,
876
+ text,
877
+ actor
878
+ }, {
879
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit }),
880
+ notes
881
+ });
882
+ const { display } = await displayAfterWrite(log);
883
+ const v = doc.decision;
884
+ return {
885
+ exit: doc.ok ? 0 : 1,
886
+ json: doc,
887
+ text: writeText(doc, display, {
888
+ subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
889
+ number: null
890
+ }),
891
+ notes: doc.notes
892
+ };
893
+ }
894
+ };
895
+ // src/cli/reject.ts
896
+ const USAGE$3 = "reject <decision> --reason <text> [--commit | --no-commit] [-m <message>]";
897
+ /** `adr reject` (design §6.3): a proposal that won't be adopted. */
898
+ const rejectCommand = {
899
+ name: "reject",
900
+ summary: "reject a proposed decision",
901
+ usage: USAGE$3,
902
+ options: {
903
+ reason: { type: "string" },
904
+ ...COMMIT_OPTIONS
905
+ },
906
+ positionals: {
907
+ min: 1,
908
+ max: 1,
909
+ names: ["decision"]
910
+ },
911
+ ref: "reject",
912
+ async run({ io, flags, values, positionals }) {
913
+ const input = positionals[0] ?? "";
914
+ if (typeof values.reason !== "string" || values.reason.trim() === "") throw new UsageError(`missing --reason <text>; usage: adr ${USAGE$3}`);
915
+ const reason = typeof values.reason === "string" ? values.reason.trim() : null;
916
+ const log = await openForCli(io, flags);
917
+ const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
918
+ const doc = await rejectDecision(log, {
919
+ id: input,
920
+ reason,
921
+ actor
922
+ }, {
923
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit }),
924
+ notes
925
+ });
926
+ const { display } = await displayAfterWrite(log);
927
+ const v = doc.decision;
928
+ return {
929
+ exit: doc.ok ? 0 : 1,
930
+ json: doc,
931
+ text: writeText(doc, display, {
932
+ subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
933
+ number: null,
934
+ reason
935
+ }),
936
+ notes: doc.notes
937
+ };
938
+ }
939
+ };
940
+ // src/cli/scope.ts
941
+ const USAGE$2 = "scope <decision> ([--set <glob>]… | --clear | [--add <glob>]… [--remove <glob>]…) [--commit | --no-commit] [-m <message>] | scope [<decision>] --follow-renames";
942
+ /** The flag names `applySetOp` uses in its messages (design §3.2). */
943
+ const NAMES = {
944
+ set: "--set",
945
+ add: "--add",
946
+ remove: "--remove",
947
+ clear: "--clear"
948
+ };
949
+ const strings = (v) => Array.isArray(v) ? v : [];
950
+ /** `--set`/`--clear`/`--add`/`--remove` as a set operation; `--clear` is `{ set: [] }`. */
951
+ function setOp(values) {
952
+ const set = strings(values.set);
953
+ const add = strings(values.add);
954
+ const remove = strings(values.remove);
955
+ if (values.clear === true) {
956
+ if (set.length > 0 || add.length > 0 || remove.length > 0) throw new UsageError("--clear can't be combined with --set, --add or --remove");
957
+ return { set: [] };
958
+ }
959
+ return {
960
+ ...set.length > 0 ? { set } : {},
961
+ add,
962
+ remove
963
+ };
964
+ }
965
+ /**
966
+ * `--follow-renames` alone: no set operation, no commit and no message, all refused before
967
+ * `commitChoice` so the answer doesn't depend on `auto_commit` (design §6.8).
968
+ */
969
+ function checkFollowRenames(values) {
970
+ if ([
971
+ "set",
972
+ "add",
973
+ "remove"
974
+ ].some((k) => strings(values[k]).length > 0) || values.clear === true) throw new UsageError("--follow-renames can't be combined with --set, --clear, --add or --remove");
975
+ if (values.commit === true) throw new UsageError("--follow-renames stages its events for the commit that contains the renames; commit them together");
976
+ if (typeof values.message === "string") throw new UsageError("--follow-renames never commits, so it takes no --message");
977
+ }
978
+ /**
979
+ * `adr scope <decision> --set|--clear|--add|--remove …` (design §6.8): the globs of the code a
980
+ * decision governs, written as one whole-set `scoped` event. `adr scope [<decision>]
981
+ * --follow-renames` rewrites them after staged renames and stages the events, never committing.
982
+ */
983
+ const scopeCommand = {
984
+ name: "scope",
985
+ summary: "set, add or remove the globs of the code a decision governs",
986
+ usage: USAGE$2,
987
+ options: {
988
+ set: {
989
+ type: "string",
990
+ multiple: true
991
+ },
992
+ add: {
993
+ type: "string",
994
+ multiple: true
995
+ },
996
+ remove: {
997
+ type: "string",
998
+ multiple: true
999
+ },
1000
+ clear: { type: "boolean" },
1001
+ "follow-renames": { type: "boolean" },
1002
+ ...COMMIT_OPTIONS
1003
+ },
1004
+ positionals: {
1005
+ min: 0,
1006
+ max: 1,
1007
+ names: ["decision"]
1008
+ },
1009
+ ref: "reject",
1010
+ async run({ io, flags, values, positionals }) {
1011
+ const input = positionals[0];
1012
+ if (values["follow-renames"] === true) {
1013
+ checkFollowRenames(values);
1014
+ const log = await openForCli(io, flags);
1015
+ const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
1016
+ const read = await readDecisions(log);
1017
+ const before = new Map([...read.projection.views].map(([id, v]) => [id, v.applies_to]));
1018
+ const renamed = (await stagedRenames(log.git)).length > 0;
1019
+ const doc = await followScopeRenames(log, {
1020
+ ...input === void 0 ? {} : { id: input },
1021
+ actor
1022
+ }, { notes });
1023
+ const { display } = await displayAfterWrite(log);
1024
+ const v = doc.decision;
1025
+ return {
1026
+ exit: doc.ok ? 0 : 1,
1027
+ json: doc,
1028
+ text: writeText(doc, display, {
1029
+ subject: v === null ? (input ?? "").trim() : `${display(v.id)}: ${v.title}`,
1030
+ number: null,
1031
+ followRenames: {
1032
+ before,
1033
+ renamed
1034
+ }
1035
+ }),
1036
+ notes: doc.notes
1037
+ };
1038
+ }
1039
+ const op = setOp(values);
1040
+ if (input === void 0) throw new UsageError(`missing argument <decision>; usage: adr ${USAGE$2}`);
1041
+ const log = await openForCli(io, flags);
1042
+ const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
1043
+ const choice = commitChoice(values, { autoCommit: log.adr.autoCommit });
1044
+ const read = await readDecisions(log);
1045
+ const before = read.projection.views.get(resolveDecision(read, input))?.applies_to ?? [];
1046
+ const doc = await scopeDecision(log, {
1047
+ id: input,
1048
+ op,
1049
+ actor,
1050
+ names: NAMES
1051
+ }, {
1052
+ ...choice,
1053
+ notes
1054
+ });
1055
+ const { display } = await displayAfterWrite(log);
1056
+ const v = doc.decision;
1057
+ return {
1058
+ exit: doc.ok ? 0 : 1,
1059
+ json: doc,
1060
+ text: writeText(doc, display, {
1061
+ subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
1062
+ number: null,
1063
+ scopeBefore: before
360
1064
  }),
361
- notes: [...doc.notes, ...notes]
1065
+ notes: doc.notes
362
1066
  };
363
1067
  }
364
1068
  };
@@ -384,21 +1088,21 @@ const showCommand = {
384
1088
  exit: 0,
385
1089
  json: doc,
386
1090
  text: showText(doc, display),
387
- notes: doc.notes
1091
+ notes: notesWith(doc.notes, numberingNote(read, { unavailable: false }))
388
1092
  };
389
1093
  }
390
1094
  };
391
1095
  // src/cli/supersede.ts
392
- const USAGE = "supersede <decision> --by <decision> [--reason <text>] [--commit | --no-commit]";
1096
+ const USAGE$1 = "supersede <decision> --by <decision> [--reason <text>] [--commit | --no-commit] [-m <message>]";
393
1097
  /** `adr supersede <decision> --by <decision> …` (design §5.6). */
394
1098
  const supersedeCommand = {
395
1099
  name: "supersede",
396
1100
  summary: "mark an accepted decision as superseded by another accepted one",
397
- usage: USAGE,
1101
+ usage: USAGE$1,
398
1102
  options: {
399
1103
  by: { type: "string" },
400
1104
  reason: { type: "string" },
401
- commit: { type: "boolean" }
1105
+ ...COMMIT_OPTIONS
402
1106
  },
403
1107
  positionals: {
404
1108
  min: 1,
@@ -408,15 +1112,18 @@ const supersedeCommand = {
408
1112
  ref: "reject",
409
1113
  async run({ io, flags, values, positionals }) {
410
1114
  const input = positionals[0] ?? "";
411
- if (typeof values.by !== "string") throw new UsageError(`missing --by <decision>; usage: adr ${USAGE}`);
1115
+ if (typeof values.by !== "string") throw new UsageError(`missing --by <decision>; usage: adr ${USAGE$1}`);
412
1116
  const log = await openForCli(io, flags);
413
- const actor = await resolveActor(log.git, { env: io.env });
1117
+ const { actor, notes: attribution } = await resolveAttribution(log.git, { env: io.env });
414
1118
  const doc = await supersedeDecision(log, {
415
1119
  id: input,
416
1120
  by: values.by,
417
1121
  actor,
418
1122
  ...typeof values.reason === "string" ? { reason: values.reason } : {}
419
- }, { commit: commitRequested(log, values) });
1123
+ }, {
1124
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit }),
1125
+ notes: attribution
1126
+ });
420
1127
  const { display } = await displayAfterWrite(log);
421
1128
  const v = doc.decision;
422
1129
  return {
@@ -430,6 +1137,49 @@ const supersedeCommand = {
430
1137
  };
431
1138
  }
432
1139
  };
1140
+ /** `adr withdraw` (design §6.4): a proposal its author takes back. */
1141
+ const withdrawCommand = {
1142
+ name: "withdraw",
1143
+ summary: "withdraw a proposed decision",
1144
+ usage: "withdraw <decision> [--reason <text>] [--commit | --no-commit] [-m <message>]",
1145
+ options: {
1146
+ reason: { type: "string" },
1147
+ ...COMMIT_OPTIONS
1148
+ },
1149
+ positionals: {
1150
+ min: 1,
1151
+ max: 1,
1152
+ names: ["decision"]
1153
+ },
1154
+ ref: "reject",
1155
+ async run({ io, flags, values, positionals }) {
1156
+ const input = positionals[0] ?? "";
1157
+ if (typeof values.reason === "string" && values.reason.trim() === "") throw new UsageError("--reason must not be empty");
1158
+ const reason = typeof values.reason === "string" ? values.reason.trim() : null;
1159
+ const log = await openForCli(io, flags);
1160
+ const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
1161
+ const doc = await withdrawDecision(log, {
1162
+ id: input,
1163
+ reason,
1164
+ actor
1165
+ }, {
1166
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit }),
1167
+ notes
1168
+ });
1169
+ const { display } = await displayAfterWrite(log);
1170
+ const v = doc.decision;
1171
+ return {
1172
+ exit: doc.ok ? 0 : 1,
1173
+ json: doc,
1174
+ text: writeText(doc, display, {
1175
+ subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
1176
+ number: null,
1177
+ reason
1178
+ }),
1179
+ notes: doc.notes
1180
+ };
1181
+ }
1182
+ };
433
1183
  // src/cli/commands.ts
434
1184
  /**
435
1185
  * ledger's shared `verify`: the log as the mode reads it (the index for `--staged`, else the
@@ -453,9 +1203,19 @@ function adrCli(version) {
453
1203
  version,
454
1204
  summary: "Architecture decision records kept as event logs in git.",
455
1205
  commands: [
1206
+ initCommand,
456
1207
  newCommand,
457
1208
  acceptCommand,
1209
+ rejectCommand,
1210
+ withdrawCommand,
1211
+ deprecateCommand,
458
1212
  supersedeCommand,
1213
+ noteCommand,
1214
+ linkCommand,
1215
+ scopeCommand,
1216
+ indexCommand,
1217
+ hooksCommand,
1218
+ forCommand,
459
1219
  listCommand,
460
1220
  showCommand,
461
1221
  verify