@nikovirtala/adr 0.1.0 → 0.2.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.
package/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
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 { n as DECISION_STATUSES, t as adrKind } from "./kind-BcHjCHvi.js";
3
+ import { C as initDecisionLog, S as unavailableReason, a as noteDecision, c as scopeDecision, d as withdrawDecision, f as adrChecks, g as showDecision, h as listDecisions, i as linkDecision, m as decisionsFor, n as deprecateDecision, o as proposeDecision, r as followScopeRenames, s as rejectDecision, t as acceptDecision, u as supersedeDecision, v as openDecisionLog, x as resolveDecision, y as readDecisions } from "./write-9IaqHRrY.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, renderStateHeader, resolveAttribution, stagedRenames, templateHistoryRef } from "@nikovirtala/ledger";
6
+ import { COMMIT_OPTIONS, commitChoice, confirm, processIO, runCli, verifyCommand } from "@nikovirtala/ledger/cli";
7
7
  import { spawn } from "node:child_process";
8
8
  // src/cli/open.ts
9
9
  /**
@@ -27,10 +27,6 @@ async function displayAfterWrite(log) {
27
27
  const read = await readDecisions(log);
28
28
  return { display: (id) => read.projection.views.get(id)?.display ?? id };
29
29
  }
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
- }
34
30
  // src/cli/text.ts
35
31
  /** A table: each column padded to its widest value, trailing spaces removed. */
36
32
  function columns(rows) {
@@ -90,18 +86,15 @@ const REASONS = {
90
86
  "is empty": "empty",
91
87
  "still contains its guidance text": "guidance text left"
92
88
  };
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) {
89
+ /** The section findings of a draft lint as headings and reasons, and the other findings. */
90
+ function sectionGaps(findings) {
98
91
  const headings = [];
99
92
  const reasons = [];
100
93
  const other = [];
101
94
  for (const f of findings) {
102
95
  const m = SECTION_FINDING.exec(f.message);
103
96
  if (m === null) {
104
- other.push(`Draft: ${f.message} [${f.rule}]`);
97
+ other.push(f);
105
98
  continue;
106
99
  }
107
100
  const [, heading = "", what = ""] = m;
@@ -109,47 +102,138 @@ function draftAdvice(findings) {
109
102
  if (!headings.includes(heading)) headings.push(heading);
110
103
  if (!reasons.includes(reason)) reasons.push(reason);
111
104
  }
105
+ return {
106
+ headings,
107
+ reasons,
108
+ other
109
+ };
110
+ }
111
+ /**
112
+ * The draft lint of `new` as advice: `Fill in N sections before accepting: A, B (why)` for the
113
+ * section findings, and one line per other finding.
114
+ */
115
+ function draftAdvice(findings) {
116
+ const { headings, reasons, other } = sectionGaps(findings);
117
+ const n = headings.length;
118
+ 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}]`)];
119
+ }
120
+ /**
121
+ * The stderr warning of `new` when the draft it wrote has unfilled sections (design §7.2), or
122
+ * `null` when there are none.
123
+ */
124
+ function unfilledWarning(doc) {
125
+ if (doc.command !== "new" || !doc.ok || doc.written.length === 0) return null;
126
+ const { headings, reasons } = sectionGaps(doc.findings);
112
127
  const n = headings.length;
113
- return [...n === 0 ? [] : [`Fill in ${n === 1 ? "1 section" : `${n} sections`} before accepting: ${headings.join(", ")} (${reasons.join(", ")})`], ...other];
128
+ const path = doc.findings.find((f) => f.file !== null)?.file ?? doc.decision?.body_path ?? null;
129
+ if (n === 0 || path === null) return null;
130
+ 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
131
  }
115
132
  function findingLines(findings) {
116
133
  return findings.map((f) => ` ${f.file === null ? "" : `${f.file}: `}${f.message} [${f.rule}]`);
117
134
  }
118
135
  const short = (sha) => sha.slice(0, 7);
119
136
  /**
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.
137
+ * `adr scope --follow-renames`: one `<named>: <old globs> → <new globs>` line per changed
138
+ * decision (`affected`), whether or not a decision was given.
139
+ */
140
+ function followRenamesText(doc, display, o) {
141
+ if (!o.renamed) return "No staged renames.";
142
+ if (doc.affected.length === 0) return "No scope globs follow the staged renames.";
143
+ return [...doc.affected.map((v) => {
144
+ const before = o.before.get(v.id) ?? [];
145
+ const old = before.filter((g) => !v.applies_to.includes(g));
146
+ const now = v.applies_to.filter((g) => !before.includes(g));
147
+ const change = old.length === 0 ? `+ ${now.join(", ")}` : `${old.join(", ")} → ${now.join(", ")}`;
148
+ return `${named(v.id, display)}: ${change}`;
149
+ }), "Staged; commit the scoped events with the renames."].join("\n");
150
+ }
151
+ /** The past participle of each write command, for `Not <verb>: …`. */
152
+ const VERBS = {
153
+ new: "proposed",
154
+ accept: "accepted",
155
+ supersede: "superseded",
156
+ reject: "rejected",
157
+ withdraw: "withdrawn",
158
+ deprecate: "deprecated",
159
+ note: "noted",
160
+ link: "linked",
161
+ scope: "scoped"
162
+ };
163
+ /**
164
+ * The text of a write command (design §5.6, §6.1). `subject` names what a refused command was
165
+ * about; `number` is the decision's number in the write result, if it has one now; `reason` is
166
+ * the reason a lifecycle exit recorded.
122
167
  */
123
168
  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");
169
+ const verb = VERBS[doc.command];
170
+ if (!doc.ok) {
171
+ const unfilled = doc.command === "new" && sectionGaps(doc.findings).headings.length > 0;
172
+ return [
173
+ `Not ${verb}: ${o.subject}`,
174
+ ...findingLines(doc.findings),
175
+ ...unfilled ? ["Fill them in with --section <id>=<markdown>, or run without --commit and edit the draft."] : []
176
+ ].join("\n");
177
+ }
178
+ if (o.followRenames !== void 0) return followRenamesText(doc, display, o.followRenames);
130
179
  const v = doc.decision;
131
180
  if (v === null) return "";
132
181
  const committed = doc.commit === null ? null : `Committed ${short(doc.commit)}`;
182
+ const recorded = committed === null ? "Staged; commit to record it." : `${committed}.`;
133
183
  switch (doc.command) {
134
184
  case "new": return [
135
185
  `Proposed ${v.id}: ${v.title}`,
136
186
  ...doc.written.map((p) => ` ${p}`),
137
187
  ...draftAdvice(doc.findings),
138
188
  ...committed === null ? [] : [`${committed}.`],
139
- `Next: adr accept ${v.id}`
189
+ 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
190
  ].join("\n");
141
191
  case "accept": return [
142
192
  `Accepted ${v.id}: ${v.title}`,
143
193
  ...doc.affected.map((a) => `Superseded ${named(a.id, display)}: ${a.title}`),
194
+ ...o.copied === void 0 || o.copied === null ? [] : [`Copied scope from ${o.copied.displays.join(", ")}: ${o.copied.globs.join(", ")}`],
144
195
  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
196
  ].join("\n");
146
197
  case "supersede": return [
147
198
  `Superseded ${named(v.id, display)}: ${v.title}`,
148
199
  ` by ${named(v.superseded_by ?? "", display)}`,
149
- committed === null ? "Staged; commit to record it." : `${committed}.`
200
+ recorded
201
+ ].join("\n");
202
+ case "reject":
203
+ case "withdraw":
204
+ case "deprecate": return [
205
+ `${verb.charAt(0).toUpperCase()}${verb.slice(1)} ${named(v.id, display)}: ${v.title}`,
206
+ ...o.reason === void 0 || o.reason === null ? [] : [` reason: ${o.reason}`],
207
+ recorded
150
208
  ].join("\n");
209
+ case "note": return [`Noted on ${named(v.id, display)}: ${v.title}`, recorded].join("\n");
210
+ case "link": {
211
+ const link = doc.written.length === 0 ? void 0 : v.links.at(-1);
212
+ if (link === void 0) return `Links of ${named(v.id, display)} unchanged`;
213
+ return [`Linked ${named(v.id, display)} ${link.rel} ${named(link.to, display)}`, recorded].join("\n");
214
+ }
215
+ case "scope": {
216
+ const before = new Set(o.scopeBefore ?? []);
217
+ const after = new Set(v.applies_to);
218
+ const globs = v.applies_to.length === 0 ? "no globs" : v.applies_to.join(", ");
219
+ if (doc.written.length === 0) return `Scope of ${named(v.id, display)} unchanged: ${globs}`;
220
+ return [
221
+ `Scope of ${named(v.id, display)}: ${v.title}`,
222
+ ...v.applies_to.filter((g) => !before.has(g)).map((g) => ` + ${g}`),
223
+ ...[...before].filter((g) => !after.has(g)).map((g) => ` - ${g}`),
224
+ ` = ${globs}`,
225
+ recorded
226
+ ].join("\n");
227
+ }
151
228
  }
152
229
  }
230
+ /**
231
+ * "Applies to" without globs: nothing is recorded, which isn't "governs nothing" (design §7.3),
232
+ * with the command that adds globs while the decision can still take them.
233
+ */
234
+ function noGlobs(v, display) {
235
+ return adrKind.machine.allowed(v.status, "scoped") ? `no globs recorded; add them with adr scope ${display(v.id)} --add <glob>` : "no globs recorded";
236
+ }
153
237
  /** `adr show`: the state header, the decision's fields and its body. */
154
238
  function showText(doc, display) {
155
239
  const v = doc.decision;
@@ -160,7 +244,7 @@ function showText(doc, display) {
160
244
  ["Effective", v.effective === null ? "none (supersession cycle)" : named(v.effective, display)],
161
245
  ["Chain", v.chain.map(display).join(" → ")],
162
246
  ["Supersedes", supersedes],
163
- ["Applies to", v.applies_to.length === 0 ? "none" : v.applies_to.join(", ")],
247
+ ["Applies to", v.applies_to.length === 0 ? noGlobs(v, display) : v.applies_to.join(", ")],
164
248
  ["Body", bodyLine(v)]
165
249
  ];
166
250
  if (doc.events === null) fields.push(["History", v.history.join(" · ")]);
@@ -170,13 +254,81 @@ function showText(doc, display) {
170
254
  const body = doc.body === null ? "" : `\n${doc.body.endsWith("\n") ? doc.body : `${doc.body}\n`}`;
171
255
  return `${renderStateHeader(doc.header)}\n${lines.join("\n")}\n${body}`;
172
256
  }
257
+ /**
258
+ * `adr for`: per path, one row per decision (named, status, title, how it matched), its warning
259
+ * below it when it isn't in force, then the anchors that name no decision of this log.
260
+ */
261
+ function forText(doc, display, keyword) {
262
+ const lines = [];
263
+ for (const p of doc.paths) {
264
+ lines.push(p.tracked ? p.path : `${p.path} (not tracked)`);
265
+ if (p.decisions.length === 0 && p.unresolved.length === 0) lines.push(" no decisions");
266
+ const rows = columns(p.decisions.map((d) => [
267
+ named(d.id, display),
268
+ d.status,
269
+ d.title,
270
+ d.via.map((v) => v.type === "glob" ? `glob ${v.glob}` : `anchor line ${v.line}`).join(", ")
271
+ ])).split("\n");
272
+ p.decisions.forEach((d, i) => {
273
+ lines.push(` ${rows[i] ?? ""}`);
274
+ if (d.warning !== null) lines.push(` ${d.warning}`);
275
+ });
276
+ for (const u of p.unresolved) {
277
+ const why = u.reason === "other-log" ? "in another log" : "not in this log";
278
+ lines.push(` unresolved: ${keyword}:${u.ref} (line ${u.line}, ${why})`);
279
+ }
280
+ }
281
+ return lines.join("\n");
282
+ }
283
+ /** `adr init`: what was written and kept, then the next step; or that nothing was needed. */
284
+ function initText(doc) {
285
+ if (doc.written.length === 0) return `Already initialized: ${doc.root} (kept ${doc.kept.join(", ")})`;
286
+ return [
287
+ `Initialized ${doc.root}`,
288
+ ...doc.written.map((p) => ` ${p}`),
289
+ ...doc.kept.map((p) => `Kept ${p}`),
290
+ ...doc.commit === null ? [] : [`Committed ${short(doc.commit)}.`],
291
+ "Next: adr new \"<title>\""
292
+ ].join("\n");
293
+ }
173
294
  // src/cli/accept.ts
174
- /** `adr accept <decision> [--commit | --no-commit]` (design §5.6). */
295
+ const MAX_LISTED = 5;
296
+ /** `Copy the scope of ADR-0002 (src/a/**, src/b/**) to k3f9q8a1? [y/N] ` (design §6.10). */
297
+ function question(id, offer) {
298
+ const shown = offer.globs.slice(0, MAX_LISTED).join(", ");
299
+ const more = offer.globs.length - MAX_LISTED;
300
+ const globs = more > 0 ? `${shown} and ${more} more` : shown;
301
+ const ds = offer.displays;
302
+ 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] `;
303
+ }
304
+ /**
305
+ * The flags decide; without them a person at a terminal is asked, and everyone else (scripts,
306
+ * hooks, CI, `--json`) gets the note with the `adr scope` command (design §6.10, D-e). `seen`
307
+ * receives the offer that was copied, for the text.
308
+ */
309
+ function copyChoice(io, json, value, seen) {
310
+ const copy = async (offer) => {
311
+ seen(offer);
312
+ return true;
313
+ };
314
+ if (value === true) return copy;
315
+ if (value === false) return "skip";
316
+ if (json || !io.interactive) return "suggest";
317
+ return async (offer) => {
318
+ const yes = await confirm(io, question(offer.id, offer), { json });
319
+ if (yes) seen(offer);
320
+ return yes;
321
+ };
322
+ }
323
+ /** `adr accept <decision> [--copy-scope | --no-copy-scope] …` (design §5.6, §6.10). */
175
324
  const acceptCommand = {
176
325
  name: "accept",
177
326
  summary: "accept a proposed decision, superseding the decisions it declares",
178
- usage: "accept <decision> [--commit | --no-commit]",
179
- options: { commit: { type: "boolean" } },
327
+ usage: "accept <decision> [--copy-scope | --no-copy-scope] [--commit | --no-commit] [-m <message>]",
328
+ options: {
329
+ "copy-scope": { type: "boolean" },
330
+ ...COMMIT_OPTIONS
331
+ },
180
332
  positionals: {
181
333
  min: 1,
182
334
  max: 1,
@@ -186,11 +338,171 @@ const acceptCommand = {
186
338
  async run({ io, flags, values, positionals }) {
187
339
  const input = positionals[0] ?? "";
188
340
  const log = await openForCli(io, flags);
189
- const actor = await resolveActor(log.git, { env: io.env });
341
+ const { actor, notes: attribution } = await resolveAttribution(log.git, { env: io.env });
342
+ const seen = { offer: null };
343
+ const copyScope = copyChoice(io, flags.json, values["copy-scope"], (offer) => {
344
+ seen.offer = offer;
345
+ });
190
346
  const doc = await acceptDecision(log, {
191
347
  id: input,
192
348
  actor
193
- }, { commit: commitRequested(log, values) });
349
+ }, {
350
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit }),
351
+ notes: attribution,
352
+ copyScope
353
+ });
354
+ const { display } = await displayAfterWrite(log);
355
+ const v = doc.decision;
356
+ return {
357
+ exit: doc.ok ? 0 : 1,
358
+ json: doc,
359
+ text: writeText(doc, display, {
360
+ subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
361
+ number: v === null || doc.commit === null || v.number === null ? null : v.display,
362
+ copied: doc.ok ? seen.offer : null
363
+ }),
364
+ notes: doc.notes
365
+ };
366
+ }
367
+ };
368
+ // src/cli/deprecate.ts
369
+ const USAGE$4 = "deprecate <decision> --reason <text> [--commit | --no-commit] [-m <message>]";
370
+ /** `adr deprecate` (design §6.5): an accepted decision no longer in force. */
371
+ const deprecateCommand = {
372
+ name: "deprecate",
373
+ summary: "deprecate an accepted decision",
374
+ usage: USAGE$4,
375
+ options: {
376
+ reason: { type: "string" },
377
+ ...COMMIT_OPTIONS
378
+ },
379
+ positionals: {
380
+ min: 1,
381
+ max: 1,
382
+ names: ["decision"]
383
+ },
384
+ ref: "reject",
385
+ async run({ io, flags, values, positionals }) {
386
+ const input = positionals[0] ?? "";
387
+ if (typeof values.reason !== "string" || values.reason.trim() === "") throw new UsageError(`missing --reason <text>; usage: adr ${USAGE$4}`);
388
+ const reason = typeof values.reason === "string" ? values.reason.trim() : null;
389
+ const log = await openForCli(io, flags);
390
+ const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
391
+ const doc = await deprecateDecision(log, {
392
+ id: input,
393
+ reason,
394
+ actor
395
+ }, {
396
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit }),
397
+ notes
398
+ });
399
+ const { display } = await displayAfterWrite(log);
400
+ const v = doc.decision;
401
+ return {
402
+ exit: doc.ok ? 0 : 1,
403
+ json: doc,
404
+ text: writeText(doc, display, {
405
+ subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
406
+ number: null,
407
+ reason
408
+ }),
409
+ notes: doc.notes
410
+ };
411
+ }
412
+ };
413
+ // src/cli/for.ts
414
+ /** `adr for <path>… [--ref <ref>]` (design §6.9): the decisions that govern each file. */
415
+ const forCommand = {
416
+ name: "for",
417
+ summary: "list the decisions that govern files, by scope glob and code anchor",
418
+ usage: "for <path>…",
419
+ options: {},
420
+ positionals: {
421
+ min: 1,
422
+ max: 100,
423
+ names: ["path"]
424
+ },
425
+ ref: "read",
426
+ async run({ io, flags, positionals }) {
427
+ const log = await openForCli(io, flags);
428
+ const read = await readDecisions(log);
429
+ const doc = await decisionsFor(read, log, {
430
+ paths: positionals,
431
+ cwd: io.cwd
432
+ });
433
+ const display = (id) => read.projection.views.get(id)?.display ?? id;
434
+ return {
435
+ exit: 0,
436
+ json: doc,
437
+ text: forText(doc, display, read.adr.anchorKeyword),
438
+ notes: doc.notes
439
+ };
440
+ }
441
+ };
442
+ // src/cli/init.ts
443
+ /** `adr init [--template <name>]… [--commit | --no-commit] [-m <message>]` (design §6.2). */
444
+ const initCommand = {
445
+ name: "init",
446
+ summary: "set up a decision log: a config file and starter templates",
447
+ usage: "init [--template <name>]… [--commit | --no-commit] [-m <message>]",
448
+ options: {
449
+ template: {
450
+ type: "string",
451
+ multiple: true
452
+ },
453
+ ...COMMIT_OPTIONS
454
+ },
455
+ positionals: {
456
+ min: 0,
457
+ max: 0,
458
+ names: []
459
+ },
460
+ ref: "reject",
461
+ async run({ io, flags, values }) {
462
+ const templates = Array.isArray(values.template) ? values.template : void 0;
463
+ const log = await openForCli(io, flags);
464
+ const doc = await initDecisionLog(log, {
465
+ ...templates === void 0 ? {} : { templates },
466
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit })
467
+ });
468
+ return {
469
+ exit: 0,
470
+ json: doc,
471
+ text: initText(doc),
472
+ notes: doc.notes
473
+ };
474
+ }
475
+ };
476
+ // src/cli/link.ts
477
+ /** `adr link <decision> <target> [--rel <rel>]` (design §6.7): a typed link, in any state. */
478
+ const linkCommand = {
479
+ name: "link",
480
+ summary: "link a decision to another one, here or in a configured source",
481
+ usage: "link <decision> <target> [--rel relates-to|refines|depends-on] [--commit | --no-commit] [-m <message>]",
482
+ options: {
483
+ rel: { type: "string" },
484
+ ...COMMIT_OPTIONS
485
+ },
486
+ positionals: {
487
+ min: 2,
488
+ max: 2,
489
+ names: ["decision", "target"]
490
+ },
491
+ ref: "reject",
492
+ async run({ io, flags, values, positionals }) {
493
+ const [input = "", to = ""] = positionals;
494
+ const rel = typeof values.rel === "string" ? values.rel : "relates-to";
495
+ const log = await openForCli(io, flags);
496
+ const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
497
+ const doc = await linkDecision(log, {
498
+ id: input,
499
+ to,
500
+ rel,
501
+ actor
502
+ }, {
503
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit }),
504
+ notes
505
+ });
194
506
  const { display } = await displayAfterWrite(log);
195
507
  const v = doc.decision;
196
508
  return {
@@ -198,7 +510,7 @@ const acceptCommand = {
198
510
  json: doc,
199
511
  text: writeText(doc, display, {
200
512
  subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
201
- number: v === null || doc.commit === null || v.number === null ? null : v.display
513
+ number: null
202
514
  }),
203
515
  notes: doc.notes
204
516
  };
@@ -283,7 +595,7 @@ function editorFor(env, o) {
283
595
  });
284
596
  }
285
597
  // src/cli/new.ts
286
- const strings = (v) => Array.isArray(v) ? v : [];
598
+ const strings$1 = (v) => Array.isArray(v) ? v : [];
287
599
  /** `--section <id>=<markdown>` values, split at the first `=`; each id at most once. */
288
600
  function sections(values) {
289
601
  const out = {};
@@ -303,7 +615,7 @@ function sections(values) {
303
615
  const newCommand = {
304
616
  name: "new",
305
617
  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]",
618
+ usage: "new <title> [--template <name>] [--supersedes <decision>]… [--section <id>=<markdown>]… [--edit | --no-edit] [--commit | --no-commit] [-m <message>]",
307
619
  options: {
308
620
  template: { type: "string" },
309
621
  supersedes: {
@@ -315,7 +627,7 @@ const newCommand = {
315
627
  multiple: true
316
628
  },
317
629
  edit: { type: "boolean" },
318
- commit: { type: "boolean" }
630
+ ...COMMIT_OPTIONS
319
631
  },
320
632
  positionals: {
321
633
  min: 1,
@@ -325,10 +637,10 @@ const newCommand = {
325
637
  ref: "reject",
326
638
  async run({ io, flags, values, positionals }) {
327
639
  const title = positionals[0] ?? "";
328
- const given = sections(strings(values.section));
640
+ const given = sections(strings$1(values.section));
329
641
  const log = await openForCli(io, flags);
330
- const actor = await resolveActor(log.git, { env: io.env });
331
- const requested = commitRequested(log, values);
642
+ const { actor, notes: attribution } = await resolveAttribution(log.git, { env: io.env });
643
+ const { commit: requested, message } = commitChoice(values, { autoCommit: log.adr.autoCommit });
332
644
  const notes = [];
333
645
  const editor = editorFor(io.env, { stdoutIsTTY: io.stdoutIsTTY });
334
646
  const wantsEdit = values.edit === true || values.edit === void 0 && io.interactive;
@@ -341,24 +653,245 @@ const newCommand = {
341
653
  const doc = await proposeDecision(log, {
342
654
  title,
343
655
  sections: given,
344
- supersedes: strings(values.supersedes),
656
+ supersedes: strings$1(values.supersedes),
345
657
  actor,
346
658
  ...typeof values.template === "string" ? { template: values.template } : {}
347
659
  }, {
348
660
  commit: requested,
661
+ message,
662
+ notes: attribution,
349
663
  ...edit === void 0 ? {} : { edit },
350
664
  onNote: (note) => notes.push(requested ? `not committed: ${note}` : note)
351
665
  });
352
666
  const { display } = await displayAfterWrite(log);
353
667
  const v = doc.decision;
668
+ const unfilled = unfilledWarning(doc);
354
669
  return {
355
670
  exit: doc.ok ? 0 : 1,
356
671
  json: doc,
357
672
  text: writeText(doc, display, {
358
673
  subject: title.trim(),
359
- number: v === null || v.number === null ? null : v.display
674
+ number: v === null || v.number === null ? null : v.display,
675
+ actor,
676
+ acceptRequiresHuman: log.adr.acceptRequiresHuman
677
+ }),
678
+ notes: [
679
+ ...doc.notes,
680
+ ...notes,
681
+ ...unfilled === null ? [] : [unfilled]
682
+ ]
683
+ };
684
+ }
685
+ };
686
+ // src/cli/note.ts
687
+ /** `adr note <decision> <text>` (design §6.6): a note on a decision in any state. */
688
+ const noteCommand = {
689
+ name: "note",
690
+ summary: "add a note to a decision, in any state",
691
+ usage: "note <decision> <text> [--commit | --no-commit] [-m <message>]",
692
+ options: { ...COMMIT_OPTIONS },
693
+ positionals: {
694
+ min: 2,
695
+ max: 2,
696
+ names: ["decision", "text"]
697
+ },
698
+ ref: "reject",
699
+ async run({ io, flags, values, positionals }) {
700
+ const [input = "", text = ""] = positionals;
701
+ const log = await openForCli(io, flags);
702
+ const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
703
+ const doc = await noteDecision(log, {
704
+ id: input,
705
+ text,
706
+ actor
707
+ }, {
708
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit }),
709
+ notes
710
+ });
711
+ const { display } = await displayAfterWrite(log);
712
+ const v = doc.decision;
713
+ return {
714
+ exit: doc.ok ? 0 : 1,
715
+ json: doc,
716
+ text: writeText(doc, display, {
717
+ subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
718
+ number: null
719
+ }),
720
+ notes: doc.notes
721
+ };
722
+ }
723
+ };
724
+ // src/cli/reject.ts
725
+ const USAGE$3 = "reject <decision> --reason <text> [--commit | --no-commit] [-m <message>]";
726
+ /** `adr reject` (design §6.3): a proposal that won't be adopted. */
727
+ const rejectCommand = {
728
+ name: "reject",
729
+ summary: "reject a proposed decision",
730
+ usage: USAGE$3,
731
+ options: {
732
+ reason: { type: "string" },
733
+ ...COMMIT_OPTIONS
734
+ },
735
+ positionals: {
736
+ min: 1,
737
+ max: 1,
738
+ names: ["decision"]
739
+ },
740
+ ref: "reject",
741
+ async run({ io, flags, values, positionals }) {
742
+ const input = positionals[0] ?? "";
743
+ if (typeof values.reason !== "string" || values.reason.trim() === "") throw new UsageError(`missing --reason <text>; usage: adr ${USAGE$3}`);
744
+ const reason = typeof values.reason === "string" ? values.reason.trim() : null;
745
+ const log = await openForCli(io, flags);
746
+ const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
747
+ const doc = await rejectDecision(log, {
748
+ id: input,
749
+ reason,
750
+ actor
751
+ }, {
752
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit }),
753
+ notes
754
+ });
755
+ const { display } = await displayAfterWrite(log);
756
+ const v = doc.decision;
757
+ return {
758
+ exit: doc.ok ? 0 : 1,
759
+ json: doc,
760
+ text: writeText(doc, display, {
761
+ subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
762
+ number: null,
763
+ reason
360
764
  }),
361
- notes: [...doc.notes, ...notes]
765
+ notes: doc.notes
766
+ };
767
+ }
768
+ };
769
+ // src/cli/scope.ts
770
+ const USAGE$2 = "scope <decision> ([--set <glob>]… | --clear | [--add <glob>]… [--remove <glob>]…) [--commit | --no-commit] [-m <message>] | scope [<decision>] --follow-renames";
771
+ /** The flag names `applySetOp` uses in its messages (design §3.2). */
772
+ const NAMES = {
773
+ set: "--set",
774
+ add: "--add",
775
+ remove: "--remove",
776
+ clear: "--clear"
777
+ };
778
+ const strings = (v) => Array.isArray(v) ? v : [];
779
+ /** `--set`/`--clear`/`--add`/`--remove` as a set operation; `--clear` is `{ set: [] }`. */
780
+ function setOp(values) {
781
+ const set = strings(values.set);
782
+ const add = strings(values.add);
783
+ const remove = strings(values.remove);
784
+ if (values.clear === true) {
785
+ if (set.length > 0 || add.length > 0 || remove.length > 0) throw new UsageError("--clear can't be combined with --set, --add or --remove");
786
+ return { set: [] };
787
+ }
788
+ return {
789
+ ...set.length > 0 ? { set } : {},
790
+ add,
791
+ remove
792
+ };
793
+ }
794
+ /**
795
+ * `--follow-renames` alone: no set operation, no commit and no message, all refused before
796
+ * `commitChoice` so the answer doesn't depend on `auto_commit` (design §6.8).
797
+ */
798
+ function checkFollowRenames(values) {
799
+ if ([
800
+ "set",
801
+ "add",
802
+ "remove"
803
+ ].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");
804
+ if (values.commit === true) throw new UsageError("--follow-renames stages its events for the commit that contains the renames; commit them together");
805
+ if (typeof values.message === "string") throw new UsageError("--follow-renames never commits, so it takes no --message");
806
+ }
807
+ /**
808
+ * `adr scope <decision> --set|--clear|--add|--remove …` (design §6.8): the globs of the code a
809
+ * decision governs, written as one whole-set `scoped` event. `adr scope [<decision>]
810
+ * --follow-renames` rewrites them after staged renames and stages the events, never committing.
811
+ */
812
+ const scopeCommand = {
813
+ name: "scope",
814
+ summary: "set, add or remove the globs of the code a decision governs",
815
+ usage: USAGE$2,
816
+ options: {
817
+ set: {
818
+ type: "string",
819
+ multiple: true
820
+ },
821
+ add: {
822
+ type: "string",
823
+ multiple: true
824
+ },
825
+ remove: {
826
+ type: "string",
827
+ multiple: true
828
+ },
829
+ clear: { type: "boolean" },
830
+ "follow-renames": { type: "boolean" },
831
+ ...COMMIT_OPTIONS
832
+ },
833
+ positionals: {
834
+ min: 0,
835
+ max: 1,
836
+ names: ["decision"]
837
+ },
838
+ ref: "reject",
839
+ async run({ io, flags, values, positionals }) {
840
+ const input = positionals[0];
841
+ if (values["follow-renames"] === true) {
842
+ checkFollowRenames(values);
843
+ const log = await openForCli(io, flags);
844
+ const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
845
+ const read = await readDecisions(log);
846
+ const before = new Map([...read.projection.views].map(([id, v]) => [id, v.applies_to]));
847
+ const renamed = (await stagedRenames(log.git)).length > 0;
848
+ const doc = await followScopeRenames(log, {
849
+ ...input === void 0 ? {} : { id: input },
850
+ actor
851
+ }, { notes });
852
+ const { display } = await displayAfterWrite(log);
853
+ const v = doc.decision;
854
+ return {
855
+ exit: doc.ok ? 0 : 1,
856
+ json: doc,
857
+ text: writeText(doc, display, {
858
+ subject: v === null ? (input ?? "").trim() : `${display(v.id)}: ${v.title}`,
859
+ number: null,
860
+ followRenames: {
861
+ before,
862
+ renamed
863
+ }
864
+ }),
865
+ notes: doc.notes
866
+ };
867
+ }
868
+ const op = setOp(values);
869
+ if (input === void 0) throw new UsageError(`missing argument <decision>; usage: adr ${USAGE$2}`);
870
+ const log = await openForCli(io, flags);
871
+ const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
872
+ const choice = commitChoice(values, { autoCommit: log.adr.autoCommit });
873
+ const read = await readDecisions(log);
874
+ const before = read.projection.views.get(resolveDecision(read, input))?.applies_to ?? [];
875
+ const doc = await scopeDecision(log, {
876
+ id: input,
877
+ op,
878
+ actor,
879
+ names: NAMES
880
+ }, {
881
+ ...choice,
882
+ notes
883
+ });
884
+ const { display } = await displayAfterWrite(log);
885
+ const v = doc.decision;
886
+ return {
887
+ exit: doc.ok ? 0 : 1,
888
+ json: doc,
889
+ text: writeText(doc, display, {
890
+ subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
891
+ number: null,
892
+ scopeBefore: before
893
+ }),
894
+ notes: doc.notes
362
895
  };
363
896
  }
364
897
  };
@@ -389,16 +922,16 @@ const showCommand = {
389
922
  }
390
923
  };
391
924
  // src/cli/supersede.ts
392
- const USAGE = "supersede <decision> --by <decision> [--reason <text>] [--commit | --no-commit]";
925
+ const USAGE$1 = "supersede <decision> --by <decision> [--reason <text>] [--commit | --no-commit] [-m <message>]";
393
926
  /** `adr supersede <decision> --by <decision> …` (design §5.6). */
394
927
  const supersedeCommand = {
395
928
  name: "supersede",
396
929
  summary: "mark an accepted decision as superseded by another accepted one",
397
- usage: USAGE,
930
+ usage: USAGE$1,
398
931
  options: {
399
932
  by: { type: "string" },
400
933
  reason: { type: "string" },
401
- commit: { type: "boolean" }
934
+ ...COMMIT_OPTIONS
402
935
  },
403
936
  positionals: {
404
937
  min: 1,
@@ -408,15 +941,18 @@ const supersedeCommand = {
408
941
  ref: "reject",
409
942
  async run({ io, flags, values, positionals }) {
410
943
  const input = positionals[0] ?? "";
411
- if (typeof values.by !== "string") throw new UsageError(`missing --by <decision>; usage: adr ${USAGE}`);
944
+ if (typeof values.by !== "string") throw new UsageError(`missing --by <decision>; usage: adr ${USAGE$1}`);
412
945
  const log = await openForCli(io, flags);
413
- const actor = await resolveActor(log.git, { env: io.env });
946
+ const { actor, notes: attribution } = await resolveAttribution(log.git, { env: io.env });
414
947
  const doc = await supersedeDecision(log, {
415
948
  id: input,
416
949
  by: values.by,
417
950
  actor,
418
951
  ...typeof values.reason === "string" ? { reason: values.reason } : {}
419
- }, { commit: commitRequested(log, values) });
952
+ }, {
953
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit }),
954
+ notes: attribution
955
+ });
420
956
  const { display } = await displayAfterWrite(log);
421
957
  const v = doc.decision;
422
958
  return {
@@ -430,6 +966,49 @@ const supersedeCommand = {
430
966
  };
431
967
  }
432
968
  };
969
+ /** `adr withdraw` (design §6.4): a proposal its author takes back. */
970
+ const withdrawCommand = {
971
+ name: "withdraw",
972
+ summary: "withdraw a proposed decision",
973
+ usage: "withdraw <decision> [--reason <text>] [--commit | --no-commit] [-m <message>]",
974
+ options: {
975
+ reason: { type: "string" },
976
+ ...COMMIT_OPTIONS
977
+ },
978
+ positionals: {
979
+ min: 1,
980
+ max: 1,
981
+ names: ["decision"]
982
+ },
983
+ ref: "reject",
984
+ async run({ io, flags, values, positionals }) {
985
+ const input = positionals[0] ?? "";
986
+ if (typeof values.reason === "string" && values.reason.trim() === "") throw new UsageError("--reason must not be empty");
987
+ const reason = typeof values.reason === "string" ? values.reason.trim() : null;
988
+ const log = await openForCli(io, flags);
989
+ const { actor, notes } = await resolveAttribution(log.git, { env: io.env });
990
+ const doc = await withdrawDecision(log, {
991
+ id: input,
992
+ reason,
993
+ actor
994
+ }, {
995
+ ...commitChoice(values, { autoCommit: log.adr.autoCommit }),
996
+ notes
997
+ });
998
+ const { display } = await displayAfterWrite(log);
999
+ const v = doc.decision;
1000
+ return {
1001
+ exit: doc.ok ? 0 : 1,
1002
+ json: doc,
1003
+ text: writeText(doc, display, {
1004
+ subject: v === null ? input.trim() : `${display(v.id)}: ${v.title}`,
1005
+ number: null,
1006
+ reason
1007
+ }),
1008
+ notes: doc.notes
1009
+ };
1010
+ }
1011
+ };
433
1012
  // src/cli/commands.ts
434
1013
  /**
435
1014
  * ledger's shared `verify`: the log as the mode reads it (the index for `--staged`, else the
@@ -453,9 +1032,17 @@ function adrCli(version) {
453
1032
  version,
454
1033
  summary: "Architecture decision records kept as event logs in git.",
455
1034
  commands: [
1035
+ initCommand,
456
1036
  newCommand,
457
1037
  acceptCommand,
1038
+ rejectCommand,
1039
+ withdrawCommand,
1040
+ deprecateCommand,
458
1041
  supersedeCommand,
1042
+ noteCommand,
1043
+ linkCommand,
1044
+ scopeCommand,
1045
+ forCommand,
459
1046
  listCommand,
460
1047
  showCommand,
461
1048
  verify