@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
@@ -0,0 +1,2873 @@
1
+ import { t as adrKind } from "./kind-DjvA3dyA.js";
2
+ import { CommitFailedError, ConfigError, ENVELOPE_VERSION, EventExistsError, FileExistsError, InvalidArgumentError, MemoryTree, UsageError, anchorCheck, applySetOp, assertNoHookConflicts, bodyFileName, bodySha256, classifyPath, commitWithRecovery, compareEvents, compileKind, diffTrees, escapeMarkdown, eventDir, eventPath, exitCode, finding, foldChecks, foldLog, followRenames, gitTemplates, globMatcher, governedChanges, historyTemplates, hookSnippet, indexCheck, indexReader, installHooks, invariantsCheck, isSubjectId, lintDocument, lintTemplate, loadFindings, loadLog, narrowedScopes, newSubjectId, nextEventIdentity, numberLog, openToolLog, overlayTree, parseCommitTemplate, parseEventFileName, parseGlob, parseTemplate, projectLog, projectNumbering, readCommitTemplate, readIndexNumbers, readToolConfig, refReader, relateCheck, renderCommitMessage, renderIndex, renderTemplate, resolveAttribution, resolveSubjectInput, saveCommitRecovery, scanAnchors, scopeGlobCheck, scopeLookup, serializeEvent, slugify, sortFindings, stagedRenames, templateHistoryRef, transitions, uninstallHooks, verifyLog, worktreeReader, writeEvents, writeIndexFile, writeNewFile } from "@nikovirtala/ledger";
3
+ import { access, constants, lstat, readFile, realpath, rm, writeFile } from "node:fs/promises";
4
+ import { isAbsolute, join, posix, relative, resolve, sep } from "node:path";
5
+ // src/commit-actions.ts
6
+ /**
7
+ * `{action}` of a commit that writes one event of each type (design §4.3), and what the title slot
8
+ * names: the decision's title for the events that change whether it is decided, else its display.
9
+ */
10
+ const ACTIONS = {
11
+ proposed: {
12
+ action: "propose to",
13
+ names: "title"
14
+ },
15
+ accepted: {
16
+ action: "decide to",
17
+ names: "title"
18
+ },
19
+ rejected: {
20
+ action: "decide not to",
21
+ names: "title"
22
+ },
23
+ withdrawn: {
24
+ action: "withdraw the proposal to",
25
+ names: "title"
26
+ },
27
+ deprecated: {
28
+ action: "deprecate",
29
+ names: "display"
30
+ },
31
+ superseded: {
32
+ action: "supersede",
33
+ names: "display"
34
+ },
35
+ noted: {
36
+ action: "add a note to",
37
+ names: "display"
38
+ },
39
+ linked: {
40
+ action: "link",
41
+ names: "display"
42
+ },
43
+ scoped: {
44
+ action: "set the scope of",
45
+ names: "display"
46
+ }
47
+ };
48
+ /** The longest `{action}`, which `[commit] subject` must leave room for. */
49
+ const LONGEST_ACTION = Math.max(...Object.values(ACTIONS).map((a) => a.action.length), 10);
50
+ /** What a link's title slot names after the display when the target is in another log. */
51
+ const CROSS_LOG_LINK = "to a decision in another log";
52
+ /** adr's commit-subject placeholders, each with its longest value (design §4.2). */
53
+ const COMMIT_VARS = Object.freeze({
54
+ action: LONGEST_ACTION,
55
+ id: 8,
56
+ display: 13,
57
+ command: 9
58
+ });
59
+ /**
60
+ * The names the title placeholder holds instead of a title, which are never shortened, each with
61
+ * its action: supersede's `<display> with <display>` and a link's `<display> to <display>` or
62
+ * `<display> to a decision in another log` (`planLink` in write.ts), and `adr index`'s
63
+ * `the decision index`. The shorter names (a display, at most 13 characters) always fit in the
64
+ * room a title gets.
65
+ */
66
+ const COMMIT_NAMES = Object.freeze([
67
+ {
68
+ length: 32,
69
+ vars: { action: ACTIONS.superseded.action.length }
70
+ },
71
+ {
72
+ length: 42,
73
+ vars: { action: ACTIONS.linked.action.length }
74
+ },
75
+ {
76
+ length: 18,
77
+ vars: { action: 10 }
78
+ }
79
+ ]);
80
+ // src/config.ts
81
+ /** The default `[commit] subject`. */
82
+ const DEFAULT_COMMIT_SUBJECT = "docs(adr): {action} {title}";
83
+ const DEFAULT_ADR_CONFIG = Object.freeze({
84
+ defaultTemplate: "nygard",
85
+ mainBranch: null,
86
+ index: "none",
87
+ acceptRequiresHuman: true,
88
+ autoCommit: false,
89
+ mcpCanDecide: false,
90
+ failOn: "error",
91
+ governedByInactive: "warning",
92
+ scopeFollowRenames: "stage",
93
+ hookIndex: "check",
94
+ commitSubject: parseCommitTemplate(DEFAULT_COMMIT_SUBJECT, {
95
+ vars: COMMIT_VARS,
96
+ where: "commit.subject",
97
+ names: COMMIT_NAMES
98
+ }),
99
+ anchorKeyword: adrKind.anchorKeyword,
100
+ anchorExclude: Object.freeze([])
101
+ });
102
+ const TEMPLATE = /^[a-z][a-z0-9-]*$/;
103
+ const KEYWORD = /^[a-z][a-z0-9-]{0,31}$/;
104
+ const MAX_ANCHOR_EXCLUDE = 100;
105
+ function isTable(v) {
106
+ return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date);
107
+ }
108
+ /**
109
+ * Validates `adr.toml` (ledger's parsed `ToolConfig`; `null` when there is none). A wrong type or
110
+ * value throws `ConfigError` naming the file and key. Unknown keys are ignored, so newer files
111
+ * still load. `[log] root` is ledger's (`openToolLog` already checked it).
112
+ */
113
+ function parseAdrConfig(config, file) {
114
+ if (config === null) return DEFAULT_ADR_CONFIG;
115
+ const where = file ?? config.file ?? "adr.toml";
116
+ const fail = (message) => new ConfigError(`${where}: ${message}`, { file: where });
117
+ const table = (name) => {
118
+ const t = config.raw[name];
119
+ if (t === void 0) return {};
120
+ if (!isTable(t)) throw fail(`${name} must be a table`);
121
+ return t;
122
+ };
123
+ const log = table("log");
124
+ const policy = table("policy");
125
+ const verify = table("verify");
126
+ const scope = table("scope");
127
+ const hooks = table("hooks");
128
+ const bool = (t, section, key, fallback) => {
129
+ const v = t[key];
130
+ if (v === void 0) return fallback;
131
+ if (typeof v !== "boolean") throw fail(`${section}.${key} must be true or false`);
132
+ return v;
133
+ };
134
+ const oneOf = (t, section, key, values, fallback) => {
135
+ const v = t[key];
136
+ if (v === void 0) return fallback;
137
+ if (typeof v !== "string" || !values.includes(v)) throw fail(`${section}.${key} must be ${values.map((x) => JSON.stringify(x)).join(" or ")}`);
138
+ return v;
139
+ };
140
+ const template = log.default_template;
141
+ if (template !== void 0 && (typeof template !== "string" || template.length > 64 || !TEMPLATE.test(template))) throw fail(`log.default_template must be a template name matching ${TEMPLATE.source}`);
142
+ const main = log.main_branch;
143
+ if (main !== void 0 && (typeof main !== "string" || main.trim() === "" || main.startsWith("-") || main.includes("\0"))) throw fail("log.main_branch must be a ref name that doesn't start with -");
144
+ const keyword = scope.anchor_keyword;
145
+ if (keyword !== void 0 && (typeof keyword !== "string" || !KEYWORD.test(keyword))) throw fail(`scope.anchor_keyword must match ${KEYWORD.source}`);
146
+ const exclude = scope.anchor_exclude;
147
+ if (exclude !== void 0) {
148
+ if (!Array.isArray(exclude) || exclude.length > MAX_ANCHOR_EXCLUDE) throw fail(`scope.anchor_exclude must be an array of at most ${MAX_ANCHOR_EXCLUDE} globs`);
149
+ exclude.forEach((g, i) => {
150
+ if (typeof g !== "string") throw fail(`scope.anchor_exclude[${i}] must be a string`);
151
+ const parsed = parseGlob(g);
152
+ if (!parsed.ok) throw fail(`scope.anchor_exclude[${i}]: ${parsed.message}`);
153
+ });
154
+ }
155
+ return {
156
+ defaultTemplate: typeof template === "string" ? template : DEFAULT_ADR_CONFIG.defaultTemplate,
157
+ mainBranch: typeof main === "string" ? main : null,
158
+ index: oneOf(log, "log", "index", ["committed", "none"], "none"),
159
+ acceptRequiresHuman: bool(policy, "policy", "accept_requires_human", true),
160
+ autoCommit: bool(policy, "policy", "auto_commit", false),
161
+ mcpCanDecide: bool(policy, "policy", "mcp_can_decide", false),
162
+ failOn: oneOf(verify, "verify", "fail_on", ["error", "warning"], "error"),
163
+ governedByInactive: oneOf(verify, "verify", "governed_by_inactive", ["warning", "error"], "warning"),
164
+ scopeFollowRenames: oneOf(hooks, "hooks", "scope_follow_renames", [
165
+ "stage",
166
+ "warn",
167
+ "off"
168
+ ], "stage"),
169
+ hookIndex: oneOf(hooks, "hooks", "index", ["check", "stage"], "check"),
170
+ commitSubject: readCommitTemplate(config, {
171
+ file: where,
172
+ vars: COMMIT_VARS,
173
+ defaultSubject: DEFAULT_COMMIT_SUBJECT,
174
+ names: COMMIT_NAMES
175
+ }),
176
+ anchorKeyword: typeof keyword === "string" ? keyword : DEFAULT_ADR_CONFIG.anchorKeyword,
177
+ anchorExclude: Array.isArray(exclude) ? exclude : []
178
+ };
179
+ }
180
+ // src/log.ts
181
+ /**
182
+ * Opens the decision log (design D3): `--root`, else `adr.toml`'s `[log] root`, else `docs/adr`;
183
+ * read from the `ref` tree, the index or the work tree.
184
+ */
185
+ async function openDecisionLog(o) {
186
+ const log = await openToolLog({
187
+ ...o,
188
+ tool: "adr",
189
+ kind: adrKind
190
+ });
191
+ return {
192
+ ...log,
193
+ adr: parseAdrConfig(log.config, log.configPath)
194
+ };
195
+ }
196
+ /**
197
+ * `numberLog` on the repository, then `readDecisionsFrom(log.tree, …)`. In a shallow clone of a
198
+ * log with `[log] index = "committed"`, the numbers come from `<root>/index.md` in the commit
199
+ * being read (`--ref`, else HEAD, resolved when the numbers are needed), never from the work tree's
200
+ * copy, which may number acceptances that exist only there (design 2b D1, §4.6).
201
+ */
202
+ async function readDecisions(log) {
203
+ return readDecisionsFrom(log.tree, {
204
+ layout: log.layout,
205
+ adr: log.adr,
206
+ numbering: await numberDecisions(log),
207
+ ref: log.ref,
208
+ commit: log.commit,
209
+ notes: log.notes
210
+ });
211
+ }
212
+ /** The display numbers `readDecisions` uses, with its shallow-clone fallback to `index.md`. */
213
+ async function numberDecisions(log) {
214
+ return numberLog(log.git, {
215
+ kind: adrKind,
216
+ layout: log.layout,
217
+ mainRef: log.adr.mainBranch,
218
+ ...log.adr.index === "committed" ? { fallback: async () => {
219
+ const c = await log.git.resolveCommit(log.ref ?? "HEAD");
220
+ return c === null ? null : readIndexNumbers(await refReader(log.git, c), `${log.layout.root}/index.md`);
221
+ } } : {}
222
+ });
223
+ }
224
+ const compiled$1 = compileKind(adrKind);
225
+ /** The git-free core of `readDecisions`; tests pass a MemoryTree and a hand-made `Numbering`. */
226
+ async function readDecisionsFrom(tree, o) {
227
+ const { layout, adr, numbering } = o;
228
+ const snapshot = await loadLog(tree, {
229
+ kind: compiled$1,
230
+ layout
231
+ });
232
+ const folded = foldLog(adrKind, snapshot);
233
+ const ctx = {
234
+ kind: compiled$1,
235
+ layout,
236
+ base: null,
237
+ head: snapshot,
238
+ changes: [],
239
+ scope: "all",
240
+ inScope: new Set(snapshot.subjects.keys()),
241
+ folded: {
242
+ head: folded,
243
+ base: null
244
+ }
245
+ };
246
+ const options = { acceptRequiresHuman: adr.acceptRequiresHuman };
247
+ const findings = [];
248
+ for (const check of [
249
+ loadFindings,
250
+ transitions,
251
+ invariantsCheck(options),
252
+ relateCheck(options)
253
+ ]) findings.push(...await check.run(ctx));
254
+ return {
255
+ tree,
256
+ layout,
257
+ adr,
258
+ root: layout.root,
259
+ ref: o.ref ?? null,
260
+ commit: o.commit ?? null,
261
+ folded,
262
+ projection: projectLog(adrKind, folded, { numbering }),
263
+ numbering,
264
+ findings: sortFindings(findings.map((f) => ({
265
+ ...f,
266
+ kind: adrKind.name
267
+ }))),
268
+ notes: o.notes ?? []
269
+ };
270
+ }
271
+ /**
272
+ * The note a read command adds about where numbers come from: the shallow-clone index, or why
273
+ * they are unavailable (`unavailable: false` leaves that one out); `null` for none.
274
+ */
275
+ function numberingNote(read, o) {
276
+ const n = read.numbering;
277
+ if (n.reason === "index") return `numbers come from ${read.root}/index.md (shallow clone)`;
278
+ if (n.available || !o.unavailable) return null;
279
+ return n.reason === "shallow" ? "numbers are unavailable (shallow clone); run git fetch --unshallow, or commit an index with [log] index = \"committed\"" : `numbers are unavailable (${unavailableReason(n)}); decisions display by id`;
280
+ }
281
+ /** Why numbers are unavailable, in words: `shallow clone` or `no main branch`. */
282
+ function unavailableReason(n) {
283
+ return n.reason === "shallow" ? "shallow clone" : "no main branch";
284
+ }
285
+ /**
286
+ * The id a decision argument names: an id, or a number in any form `parseDecisionNumber` takes.
287
+ * Throws `UsageError` (exit 2) naming the reason when it names nothing.
288
+ */
289
+ function resolveDecision(read, input) {
290
+ const r = resolveSubjectInput(input, {
291
+ subjects: new Set(read.folded.subjects.keys()),
292
+ kind: adrKind,
293
+ numbering: read.numbering
294
+ });
295
+ if (r.ok) return r.id;
296
+ const shown = JSON.stringify(input.trim());
297
+ switch (r.reason) {
298
+ case "malformed": throw new UsageError(`invalid decision ${JSON.stringify(input)}`);
299
+ case "qualified": throw new UsageError(`decision ${shown} is in another log; cross-log references aren't supported yet`);
300
+ case "numbers-unavailable": throw new UsageError(`decision numbers are unavailable (${unavailableReason(read.numbering)}); use the decision's id instead of ${shown}`);
301
+ case "not-found": throw new UsageError(`unknown decision ${input.trim()}`);
302
+ }
303
+ }
304
+ /**
305
+ * The event files in `--ref`'s tree (else HEAD's) that `rev`'s tree lacks (design 2b D7). Diffing
306
+ * trees answers "what did this pull bring" for fast-forwards and merges (`ORIG_HEAD` is the tip
307
+ * before them), and lists nothing for a rebase of your own work, whose events are in both trees.
308
+ * Throws `RefNotFoundError` when either revision doesn't resolve.
309
+ */
310
+ async function changedSince(log, rev) {
311
+ const base = await refReader(log.git, rev);
312
+ const head = await refReader(log.git, log.ref ?? "HEAD");
313
+ const events = (await diffTrees(base, head, `${log.layout.root}/${log.layout.events}`)).flatMap((c) => {
314
+ if (c.status !== "added") return [];
315
+ const p = classifyPath(log.layout, c.path);
316
+ if (p.area !== "event" || p.subject === null || p.file.includes("/")) return [];
317
+ const name = parseEventFileName(p.file);
318
+ return name === null ? [] : [{
319
+ subject: p.subject,
320
+ type: name.type,
321
+ path: c.path
322
+ }];
323
+ });
324
+ events.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
325
+ return {
326
+ rev,
327
+ commit: base.commit ?? "",
328
+ events
329
+ };
330
+ }
331
+ // src/log-index.ts
332
+ /**
333
+ * What the recovery hint says about rerunning `adr index --commit` after `git commit` failed
334
+ * (design 2b D5). It names the file, not the path, which the hint lists above it.
335
+ */
336
+ const INDEX_RERUN = "You can also run adr index --commit again once git accepts the commit; it commits index.md while it differs from HEAD's.";
337
+ /**
338
+ * The step before the manual commit when a write's second index render failed after staging
339
+ * (design 2b §4.5 step 4).
340
+ */
341
+ const INDEX_BEFORE = (root) => `Run adr index --no-commit first; it writes and stages ${root}/index.md from the work tree. If other decisions in ${root} have uncommitted changes, the work tree's index includes them; append their paths after -- in the command below, so the commit matches the index.`;
342
+ /**
343
+ * The step after the manual commit when the commit carried the index: the commit's render leaves
344
+ * out other decisions' uncommitted entries (design 2b D5, §4.5 step 5).
345
+ */
346
+ const INDEX_AFTER = (root) => `Then run adr index --no-commit to restore other decisions' entries in ${root}/index.md.`;
347
+ /** The repo-relative path of a log's generated index. */
348
+ const indexPath = (layout) => `${layout.root}/index.md`;
349
+ const GROUPS = [
350
+ {
351
+ heading: "Accepted",
352
+ statuses: ["accepted"]
353
+ },
354
+ {
355
+ heading: "Proposed",
356
+ statuses: ["proposed"]
357
+ },
358
+ {
359
+ heading: "Superseded and deprecated",
360
+ statuses: ["superseded", "deprecated"]
361
+ },
362
+ {
363
+ heading: "Rejected and withdrawn",
364
+ statuses: ["rejected", "withdrawn"]
365
+ }
366
+ ];
367
+ const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
368
+ /**
369
+ * adr's index spec (01:209: active decisions first), built per render so that a superseded
370
+ * decision's status can name its successor's display from `views`.
371
+ */
372
+ const DECISION_INDEX = (root, views) => ({
373
+ tool: "adr",
374
+ title: "Architecture decisions",
375
+ groups: GROUPS.map((g) => ({
376
+ heading: g.heading,
377
+ filter: (v) => g.statuses.includes(v.status)
378
+ })),
379
+ columns: [
380
+ {
381
+ heading: "Decision",
382
+ cell: (v) => escapeMarkdown(v.display)
383
+ },
384
+ {
385
+ heading: "Title",
386
+ cell: (v) => v.body_path === null ? escapeMarkdown(v.title) : `[${escapeMarkdown(v.title)}](${encodeURI(posix.relative(root, v.body_path))})`
387
+ },
388
+ {
389
+ heading: "Status",
390
+ cell: (v) => v.status === "superseded" && v.superseded_by !== null ? `superseded by ${escapeMarkdown(views.get(v.superseded_by)?.display ?? v.superseded_by)}` : v.status
391
+ },
392
+ {
393
+ heading: "Since",
394
+ cell: (v) => v.status_at.slice(0, 10)
395
+ }
396
+ ],
397
+ order: (a, b) => (a.number ?? Number.POSITIVE_INFINITY) - (b.number ?? Number.POSITIVE_INFINITY) || cmp(a.status_at, b.status_at) || cmp(a.id, b.id)
398
+ });
399
+ /**
400
+ * The index `tree` must hold: its decisions, numbered as they will be once the commit holding
401
+ * `tree` (which builds on `history`: HEAD, a commit, or null on an unborn HEAD) is on main
402
+ * (design 2b D2), with `[log] main_branch`'s numbers first. A tree that isn't a commit's (the work
403
+ * tree, the index) also merges `MERGE_HEAD` while a merge is in progress. In a shallow clone the
404
+ * numbers continue from those `history`'s index recorded; `null` when there are none, so the
405
+ * numbers can't be projected.
406
+ */
407
+ async function renderDecisionIndex(git, o) {
408
+ const { layout, history } = o;
409
+ const mergeHead = o.tree.commit === null && history !== null ? await git.resolveCommit("MERGE_HEAD") : null;
410
+ const numbering = await projectNumbering(git, {
411
+ kind: adrKind,
412
+ layout,
413
+ history,
414
+ tree: o.tree,
415
+ mainRef: o.adr.mainBranch,
416
+ merging: mergeHead === null ? [] : [mergeHead],
417
+ recorded: async () => history === null ? null : readIndexNumbers(await refReader(git, history), indexPath(layout))
418
+ });
419
+ if (!numbering.available) return null;
420
+ const views = (await readDecisionsFrom(o.tree, {
421
+ layout,
422
+ adr: o.adr,
423
+ numbering
424
+ })).projection.views;
425
+ return renderIndex(DECISION_INDEX(layout.root, views), [...views.values()], numbering);
426
+ }
427
+ /** At most this many paths are named in the unstaged-files `index/stale` message. */
428
+ const UNSTAGED_NAMED = 5;
429
+ /**
430
+ * What the unstaged-files `index/stale` finding and the matching write note both start with:
431
+ * `path` lists `files` (some of them), which aren't staged.
432
+ */
433
+ function unstagedIndexText(path, files) {
434
+ return `${path} lists decision files that aren't staged (${files.slice(0, UNSTAGED_NAMED).join(", ")}${files.length > UNSTAGED_NAMED ? ", …" : ""})`;
435
+ }
436
+ /**
437
+ * The log's event and body files whose work-tree content isn't staged (untracked or modified),
438
+ * sorted: the cause of a staged `index.md` that only the work tree's render matches.
439
+ */
440
+ async function unstagedLogFiles(git, layout) {
441
+ const paths = [...await git.unstagedPaths([layout.root]), ...await git.untrackedPaths([layout.root])];
442
+ return [...new Set(paths)].filter((p) => {
443
+ const area = classifyPath(layout, p).area;
444
+ return area === "event" || area === "body";
445
+ }).sort();
446
+ }
447
+ /**
448
+ * The verify check that `<root>/index.md` is current (design 2b §4.5, "Freshness in verify"). In
449
+ * staged mode (the pre-commit hook), a stale staged copy that equals the work tree's render
450
+ * means another decision's files aren't staged: writes stage the work tree's render (D2), and
451
+ * `adr index` would change nothing. The finding then names those files and says to stage them.
452
+ */
453
+ function decisionIndexCheck(log) {
454
+ const path = indexPath(log.layout);
455
+ const check = indexCheck({
456
+ path,
457
+ fix: "run adr index",
458
+ async expected(ctx) {
459
+ const head = ctx.repo?.head;
460
+ const history = head === void 0 ? null : typeof head === "object" ? head.commit : await log.git.resolveCommit("HEAD");
461
+ return renderDecisionIndex(log.git, {
462
+ layout: log.layout,
463
+ adr: log.adr,
464
+ tree: ctx.head.tree,
465
+ history
466
+ });
467
+ }
468
+ });
469
+ return {
470
+ id: check.id,
471
+ async run(ctx) {
472
+ const findings = await check.run(ctx);
473
+ const stale = findings.find((f) => f.rule === "index/stale");
474
+ if (stale === void 0 || ctx.repo?.head !== "index") return findings;
475
+ const files = await unstagedLogFiles(log.git, log.layout);
476
+ if (files.length === 0) return findings;
477
+ const worktree = await renderDecisionIndex(log.git, {
478
+ layout: log.layout,
479
+ adr: log.adr,
480
+ tree: worktreeReader(log.git),
481
+ history: await log.git.resolveCommit("HEAD")
482
+ });
483
+ if (worktree === null || !holdsText(await ctx.head.tree.read(path), worktree)) return findings;
484
+ const message = `${unstagedIndexText(path, files)}; git add them and commit again, or move them out of the work tree and run adr index`;
485
+ return findings.map((f) => f === stale ? {
486
+ ...f,
487
+ message
488
+ } : f);
489
+ }
490
+ };
491
+ }
492
+ const encoder$2 = new TextEncoder();
493
+ /** Whether `bytes` (null: absent) hold exactly `text`. */
494
+ function holdsText(bytes, text) {
495
+ if (bytes === null) return false;
496
+ const want = encoder$2.encode(text);
497
+ return bytes.byteLength === want.byteLength && bytes.every((x, i) => x === want[i]);
498
+ }
499
+ /** HEAD's copy of `path`; `null` when it is absent or HEAD is unborn. */
500
+ async function headBytes(git, path) {
501
+ const head = await git.resolveCommit("HEAD");
502
+ return head === null ? null : (await refReader(git, head)).read(path);
503
+ }
504
+ /** HEAD's tree, or an empty tree on an unborn HEAD. */
505
+ async function headTree(git, head) {
506
+ return head === null ? new MemoryTree({}) : refReader(git, head);
507
+ }
508
+ /**
509
+ * Brings the work-tree copy of `path` to `text` (atomically, when it differs) and reports whether
510
+ * the staged copy differs, so the caller stages it. `old` is the work-tree copy before the write
511
+ * (`null` when absent), for rollback.
512
+ */
513
+ async function writeIndexCopy(git, path, text) {
514
+ const old = await worktreeReader(git).read(path);
515
+ return {
516
+ wrote: !holdsText(old, text) && await writeIndexFile(git, path, text),
517
+ old,
518
+ stage: !holdsText(await (await indexReader(git)).read(path), text)
519
+ };
520
+ }
521
+ /** The first line of an error's message, for notes and headlines. */
522
+ const firstLine = (err) => (err instanceof Error ? err.message : String(err)).split("\n")[0] ?? "";
523
+ /**
524
+ * `adr index` (design 2b §4.5) with what the text output needs; `indexDecisionLog` is the
525
+ * library entry.
526
+ */
527
+ async function runDecisionIndex(log, o = {}) {
528
+ const path = indexPath(log.layout);
529
+ const root = log.layout.root;
530
+ const check = o.check === true;
531
+ const notes = [...log.notes, ...o.notes ?? []];
532
+ if (log.adr.index !== "committed") throw new UsageError("adr index needs [log] index = \"committed\" in adr.toml");
533
+ if (check) {
534
+ if (o.commit === true || o.message !== void 0 && o.message !== null) throw new UsageError("--check writes nothing; drop --commit and --message");
535
+ const findings = await verifyLog({
536
+ git: log.git,
537
+ kind: adrKind,
538
+ layout: log.layout,
539
+ mode: {
540
+ type: "full",
541
+ ...log.ref === null ? {} : { head: log.ref }
542
+ },
543
+ checks: [decisionIndexCheck(log)]
544
+ });
545
+ return {
546
+ doc: {
547
+ format: "adr-index/v1",
548
+ path,
549
+ check: true,
550
+ fresh: findings.length === 0,
551
+ written: [],
552
+ commit: null,
553
+ findings,
554
+ notes
555
+ },
556
+ refreshed: false
557
+ };
558
+ }
559
+ if (log.ref !== null || log.tree.label !== "worktree") throw new UsageError("adr index writes the work tree; use --ref only with --check");
560
+ const { git } = log;
561
+ const commit = o.commit ?? log.adr.autoCommit;
562
+ if (commit && (await git.identity() === null || await git.committerIdentity() === null)) throw new ConfigError("--commit needs a git identity; set git user.name and user.email");
563
+ const head = await git.resolveCommit("HEAD");
564
+ const render = (tree, history) => renderDecisionIndex(git, {
565
+ layout: log.layout,
566
+ adr: log.adr,
567
+ tree,
568
+ history
569
+ });
570
+ const shallow = () => new UsageError("numbers need history in this shallow clone; run git fetch --unshallow");
571
+ let written = false;
572
+ const place = async (text) => {
573
+ const r = await writeIndexCopy(git, path, text);
574
+ if (r.stage) await git.add([path]);
575
+ written ||= r.wrote;
576
+ return r.wrote || r.stage;
577
+ };
578
+ if (!commit) {
579
+ const text = await render(worktreeReader(git), head);
580
+ if (text === null) throw shallow();
581
+ return {
582
+ doc: {
583
+ format: "adr-index/v1",
584
+ path,
585
+ check: false,
586
+ fresh: !await place(text),
587
+ written: written ? [path] : [],
588
+ commit: null,
589
+ findings: [],
590
+ notes
591
+ },
592
+ refreshed: false
593
+ };
594
+ }
595
+ const text = await render(await headTree(git, head), head);
596
+ if (text === null) throw shallow();
597
+ const fresh = holdsText(await headBytes(git, path), text);
598
+ let sha = null;
599
+ if (!fresh) {
600
+ await place(text);
601
+ const message = o.message ?? renderCommitMessage(log.adr.commitSubject, {
602
+ title: "the decision index",
603
+ vars: {
604
+ action: "regenerate",
605
+ id: "index.md",
606
+ display: "index.md",
607
+ command: "index"
608
+ }
609
+ }).message;
610
+ sha = await commitWithRecovery(git, {
611
+ message,
612
+ only: [path],
613
+ rerun: INDEX_RERUN,
614
+ after: INDEX_AFTER(root)
615
+ });
616
+ }
617
+ let refreshed = false;
618
+ try {
619
+ const again = await render(worktreeReader(git), sha ?? head);
620
+ if (again !== null) refreshed = await place(again);
621
+ } catch (err) {
622
+ notes.push(`${path} may be stale: ${firstLine(err)}; run adr index`);
623
+ }
624
+ return {
625
+ doc: {
626
+ format: "adr-index/v1",
627
+ path,
628
+ check: false,
629
+ fresh,
630
+ written: written ? [path] : [],
631
+ commit: sha,
632
+ findings: [],
633
+ notes
634
+ },
635
+ refreshed
636
+ };
637
+ }
638
+ /**
639
+ * `adr index [--check] [--commit | --no-commit] [-m <message>]` (design 2b §4.5): renders
640
+ * `<root>/index.md` and writes and stages it where the work tree or the index differ. With
641
+ * `commit` (default `[policy] auto_commit`) it renders HEAD's tree and commits the file alone when
642
+ * HEAD's copy differs, then refreshes the work tree's copy, which may include other decisions'
643
+ * uncommitted entries. `check` writes nothing and reports `index/*` findings.
644
+ */
645
+ async function indexDecisionLog(log, o = {}) {
646
+ return (await runDecisionIndex(log, o)).doc;
647
+ }
648
+ // src/verify.ts
649
+ /** At most this many paths are named in a `decision/scope-narrowed` message. */
650
+ const NARROWED_PATHS = 3;
651
+ /** The head's decision views with the read's numbering, for the displays in messages. */
652
+ async function headViews(log, ctx) {
653
+ const head = ctx.folded?.head;
654
+ if (head === void 0) return /* @__PURE__ */ new Map();
655
+ return projectLog(adrKind, head, { numbering: await numberDecisions(log) }).views;
656
+ }
657
+ /**
658
+ * `decision/governed-by-inactive` (`[verify] governed_by_inactive`, default warning): a changed
659
+ * code file that a superseded or deprecated decision governs, by glob or anchor (01:366).
660
+ */
661
+ function governedByInactive(log) {
662
+ return {
663
+ id: "decision-governed-by-inactive",
664
+ async run(ctx) {
665
+ const changes = await governedChanges(ctx, {
666
+ keyword: log.adr.anchorKeyword,
667
+ exclude: log.adr.anchorExclude,
668
+ statuses: ["superseded", "deprecated"]
669
+ });
670
+ if (changes.length === 0) return [];
671
+ const views = await headViews(log, ctx);
672
+ const display = (id) => views.get(id)?.display ?? id;
673
+ const message = (c) => {
674
+ const what = `${c.path} is governed by ${display(c.subject)}`;
675
+ if (c.status === "deprecated") return `${what}, which is deprecated; check whether the change still needs it`;
676
+ const next = views.get(c.subject)?.superseded_by ?? null;
677
+ return next === null ? `${what}, which is superseded; check the change against its successor` : `${what}, which is superseded by ${display(next)}; check the change against ${display(next)}`;
678
+ };
679
+ return changes.map((c) => finding({
680
+ severity: log.adr.governedByInactive,
681
+ rule: "decision/governed-by-inactive",
682
+ message: message(c),
683
+ file: c.path,
684
+ subject: c.subject
685
+ }));
686
+ }
687
+ };
688
+ }
689
+ /**
690
+ * `decision/scope-narrowed` (warning): an accepted decision whose new scope stops covering files
691
+ * that still exist (01:367), which calls for a check against its Scope section.
692
+ */
693
+ function scopeNarrowed(log) {
694
+ return {
695
+ id: "decision-scope-narrowed",
696
+ async run(ctx) {
697
+ const narrowed = await narrowedScopes(ctx, { statuses: ["accepted"] });
698
+ if (narrowed.length === 0) return [];
699
+ const views = await headViews(log, ctx);
700
+ return narrowed.map((n) => {
701
+ const count = n.lost.length;
702
+ const files = count === 1 ? "1 file that still exists" : `${count} files that still exist`;
703
+ const named = n.lost.slice(0, NARROWED_PATHS).join(", ");
704
+ const more = count > NARROWED_PATHS ? ", …" : "";
705
+ return finding({
706
+ severity: "warning",
707
+ rule: "decision/scope-narrowed",
708
+ message: `${views.get(n.subject)?.display ?? n.subject}'s scope no longer covers ${files} (${named}${more}); check the change against its Scope section`,
709
+ file: n.file,
710
+ subject: n.subject
711
+ });
712
+ });
713
+ }
714
+ };
715
+ }
716
+ /**
717
+ * Every check for the log: ledger's mechanical and fold checks with adr's actor policy, template
718
+ * lint against the templates in the head tree and, for older versions, the history of
719
+ * `historyRef`, the scope-glob and anchor checks, and the decision checks over code
720
+ * (`decision/governed-by-inactive`, `decision/scope-narrowed`). With `[log] index = "committed"`,
721
+ * also the freshness of `<root>/index.md`.
722
+ */
723
+ function adrChecks(log, historyRef) {
724
+ return [
725
+ ...foldChecks(adrKind, {
726
+ options: { acceptRequiresHuman: log.adr.acceptRequiresHuman },
727
+ templates: gitTemplates(log.git, { historyRef })
728
+ }),
729
+ scopeGlobCheck(),
730
+ anchorCheck({
731
+ keyword: log.adr.anchorKeyword,
732
+ exclude: log.adr.anchorExclude
733
+ }),
734
+ governedByInactive(log),
735
+ scopeNarrowed(log),
736
+ ...log.adr.index === "committed" ? [decisionIndexCheck(log)] : []
737
+ ];
738
+ }
739
+ /** Verifies the decision log in `mode` (staged, range or full) with `adrChecks`. */
740
+ async function verifyDecisions(log, mode) {
741
+ return verifyLog({
742
+ git: log.git,
743
+ kind: adrKind,
744
+ layout: log.layout,
745
+ mode,
746
+ checks: adrChecks(log, await templateHistoryRef(log.git, mode))
747
+ });
748
+ }
749
+ /** The built-in templates by name: the file text `adr new` materializes. */
750
+ const BUILTIN_TEMPLATES = Object.freeze({
751
+ nygard: `---
752
+ template: nygard
753
+ version: 1
754
+ sections:
755
+ - id: context
756
+ heading: Context
757
+ required: true
758
+ guidance: What is the issue that motivates this decision? Describe the forces at play.
759
+ - id: decision
760
+ heading: Decision
761
+ required: true
762
+ guidance: What is the change that we're proposing or doing? Write it in the active voice.
763
+ - id: scope
764
+ heading: Scope
765
+ required: true
766
+ guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
767
+ - id: consequences
768
+ heading: Consequences
769
+ required: true
770
+ guidance: What becomes easier or harder because of this change? Include the risks.
771
+ ---
772
+ # {{title}}
773
+
774
+ ## Context
775
+ {{context}}
776
+
777
+ ## Decision
778
+ {{decision}}
779
+
780
+ ## Scope
781
+ {{scope}}
782
+
783
+ ## Consequences
784
+ {{consequences}}
785
+ `,
786
+ madr: `---
787
+ template: madr
788
+ version: 1
789
+ sections:
790
+ - id: context
791
+ heading: Context and Problem Statement
792
+ required: true
793
+ guidance: Describe the context and the problem in two or three sentences, or as a question.
794
+ - id: drivers
795
+ heading: Decision Drivers
796
+ required: false
797
+ guidance: List the forces and concerns that shape the decision.
798
+ - id: options
799
+ heading: Considered Options
800
+ required: true
801
+ guidance: List the options that were considered, one per line.
802
+ - id: decision
803
+ heading: Decision Outcome
804
+ required: true
805
+ guidance: Name the chosen option and say why it was chosen.
806
+ - id: scope
807
+ heading: Scope
808
+ required: true
809
+ guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
810
+ - id: consequences
811
+ heading: Consequences
812
+ required: true
813
+ guidance: Describe the good and the bad consequences of the chosen option.
814
+ ---
815
+ # {{title}}
816
+
817
+ ## Context and Problem Statement
818
+ {{context}}
819
+
820
+ ## Decision Drivers
821
+ {{drivers}}
822
+
823
+ ## Considered Options
824
+ {{options}}
825
+
826
+ ## Decision Outcome
827
+ {{decision}}
828
+
829
+ ## Scope
830
+ {{scope}}
831
+
832
+ ## Consequences
833
+ {{consequences}}
834
+ `,
835
+ "y-statement": `---
836
+ template: y-statement
837
+ version: 1
838
+ sections:
839
+ - id: statement
840
+ heading: Statement
841
+ required: true
842
+ guidance: In the context of <use case>, facing <concern>, we decided for <option>, to achieve <quality>, accepting <downside>.
843
+ - id: scope
844
+ heading: Scope
845
+ required: true
846
+ guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
847
+ ---
848
+ # {{title}}
849
+
850
+ ## Statement
851
+ {{statement}}
852
+
853
+ ## Scope
854
+ {{scope}}
855
+ `
856
+ });
857
+ // src/write.ts
858
+ const encoder$1 = new TextEncoder();
859
+ const decoder$1 = new TextDecoder();
860
+ const compiled = compileKind(adrKind);
861
+ const TEMPLATE_NAME = /^[a-z][a-z0-9-]*$/;
862
+ const MAX_TEXT_BYTES = 65536;
863
+ const SUBJECT_ATTEMPTS = 5;
864
+ const QUALIFIED_SUPERSESSION = "cross-log supersession isn't supported yet";
865
+ const sorted = (xs) => [...new Set(xs)].sort();
866
+ const templatePath = (read, name) => `${read.root}/templates/${name}.md`;
867
+ /** A text argument within the 64 KiB cap and without NUL characters, else `UsageError`. */
868
+ function checkText(what, text) {
869
+ if (text.includes("\0")) throw new UsageError(`${what} must not contain NUL characters`);
870
+ const size = encoder$1.encode(text).byteLength;
871
+ if (size > MAX_TEXT_BYTES) throw new UsageError(`${what} is ${size} bytes; the limit is ${MAX_TEXT_BYTES} (64 KiB)`);
872
+ }
873
+ function checkReason(reason) {
874
+ if (reason === void 0 || reason === null) return null;
875
+ const r = reason.trim();
876
+ if (r === "") throw new UsageError("--reason must not be empty");
877
+ checkText("--reason", r);
878
+ return r;
879
+ }
880
+ /** The view of a folded decision, else `UsageError` naming why it can't be used. */
881
+ function viewOf(read, id) {
882
+ const view = read.projection.views.get(id);
883
+ if (view !== void 0) return view;
884
+ if (read.projection.unreadable.includes(id)) throw new UsageError(`decision ${id} uses a newer format; upgrade adr`);
885
+ throw new UsageError(`decision ${id} can't be read; run adr verify`);
886
+ }
887
+ /** A refusal finding about decision `subject`, on its body (or events directory). */
888
+ function refusal(read, rule, message, subject) {
889
+ const loaded = read.folded.subjects.get(subject)?.loaded;
890
+ return finding({
891
+ severity: "error",
892
+ rule,
893
+ message,
894
+ file: loaded?.bodyPaths[0] ?? eventDir(read.layout, subject),
895
+ subject,
896
+ kind: adrKind.name
897
+ });
898
+ }
899
+ /** `"<type> is not allowed while <status>[; <status> is terminal]"`, as the fold words it. */
900
+ function illegal(type, status) {
901
+ return `${type} is not allowed while ${status}${adrKind.machine.terminal.includes(status) ? `; ${status} is terminal` : ""}`;
902
+ }
903
+ /** A new event of `subject`, sorting after its existing ones. */
904
+ function newEvent(read, c, o) {
905
+ const existing = (read.folded.subjects.get(o.subject)?.loaded.events ?? []).flatMap((e) => e.event === null ? [] : [e.event]);
906
+ const { at, id } = nextEventIdentity([...existing, ...o.after ?? []], {
907
+ now: c.now,
908
+ ...c.random === void 0 ? {} : { random: c.random }
909
+ });
910
+ return {
911
+ envelope: ENVELOPE_VERSION,
912
+ kind: adrKind.name,
913
+ schema: adrKind.schema,
914
+ id,
915
+ subject: o.subject,
916
+ type: o.type,
917
+ at,
918
+ actor: o.actor,
919
+ ...o.reason === void 0 || o.reason === null ? {} : { reason: o.reason },
920
+ data: o.data
921
+ };
922
+ }
923
+ /** Body, events directory and recorded template of each involved subject (D6). */
924
+ function commitScope(read, involved, extra = {}) {
925
+ const paths = [];
926
+ const dirs = [];
927
+ for (const id of involved) {
928
+ const view = read.projection.views.get(id);
929
+ if (view?.body_path !== null && view?.body_path !== void 0) paths.push(view.body_path);
930
+ if (view !== void 0) paths.push(templatePath(read, view.template.name));
931
+ dirs.push(eventDir(read.layout, id));
932
+ }
933
+ if (extra.body !== void 0) paths.push(extra.body);
934
+ if (extra.template !== void 0) paths.push(extra.template);
935
+ return {
936
+ scope: sorted([...paths, ...dirs]),
937
+ eventDirs: sorted(dirs)
938
+ };
939
+ }
940
+ const findingKey = (f) => [
941
+ f.rule,
942
+ f.subject ?? "",
943
+ f.file ?? "",
944
+ f.message
945
+ ].join("\0");
946
+ /**
947
+ * The precheck (§6.3): folds the would-be log and refuses when the write adds any finding. Problems
948
+ * that were already there don't block; the write can only add findings about what it touches.
949
+ */
950
+ async function precheck(read, files) {
951
+ const after = await readDecisionsFrom(overlayTree(read.tree, files), {
952
+ layout: read.layout,
953
+ adr: read.adr,
954
+ numbering: read.numbering,
955
+ ref: read.ref,
956
+ commit: read.commit,
957
+ notes: read.notes
958
+ });
959
+ const before = new Set(read.findings.map(findingKey));
960
+ return {
961
+ after,
962
+ added: after.findings.filter((f) => !before.has(findingKey(f)))
963
+ };
964
+ }
965
+ function filesOf(events, read) {
966
+ return Object.fromEntries(events.map((e) => [eventPath(read.layout, e), encoder$1.encode(serializeEvent(e))]));
967
+ }
968
+ const pairsOf = (events) => events.flatMap((e) => [["ADR", e.subject], ["ADR-Event", e.type]]);
969
+ /** The template `new` uses: the repo's `<root>/templates/<name>.md`, else a built-in to copy. */
970
+ async function chooseTemplate(read, name, c) {
971
+ if (name.length > 64 || !TEMPLATE_NAME.test(name)) throw new UsageError(`invalid template name ${JSON.stringify(name)}`);
972
+ const path = templatePath(read, name);
973
+ const bytes = await read.tree.read(path);
974
+ let text;
975
+ let materialize = null;
976
+ if (bytes !== null) text = decoder$1.decode(bytes);
977
+ else {
978
+ const builtin = Object.hasOwn(c.builtins, name) ? c.builtins[name] : void 0;
979
+ if (builtin === void 0) {
980
+ const known = Object.keys(c.builtins).sort().join(", ");
981
+ throw new UsageError(`unknown template ${name}; add ${path} or use one of ${known}`);
982
+ }
983
+ text = builtin;
984
+ materialize = builtin;
985
+ }
986
+ const parsed = parseTemplate(text, { path });
987
+ if (!parsed.ok) return {
988
+ ok: false,
989
+ findings: parsed.findings
990
+ };
991
+ const lint = lintTemplate(parsed.template, { requireSections: adrKind.def.templates?.requireSections ?? [] });
992
+ if (lint.length > 0) return {
993
+ ok: false,
994
+ findings: lint
995
+ };
996
+ return {
997
+ ok: true,
998
+ template: parsed.template,
999
+ materialize,
1000
+ path
1001
+ };
1002
+ }
1003
+ /**
1004
+ * Plans `adr new`: a rendered body, a materialized template if needed, and `proposed`. With
1005
+ * `refuseIncomplete` (a commit with no editor to fill the draft in, design §7.2), a draft that
1006
+ * fails the template lint is refused with those findings before any file is planned.
1007
+ */
1008
+ async function planPropose(read, input, c, o = {}) {
1009
+ const title = input.title.trim();
1010
+ if (title === "" || title.length > 200 || /[\r\n]/.test(title)) throw new UsageError("the title must be a single line of 1 to 200 characters");
1011
+ const supersedes = sorted((input.supersedes ?? []).map((s) => {
1012
+ if (s.includes(":")) throw new UsageError(`cannot supersede ${s.trim()}: ${QUALIFIED_SUPERSESSION}`);
1013
+ return resolveDecision(read, s);
1014
+ }));
1015
+ const chosen = await chooseTemplate(read, input.template ?? read.adr.defaultTemplate, c);
1016
+ if (!chosen.ok) return {
1017
+ ok: false,
1018
+ decision: null,
1019
+ findings: chosen.findings
1020
+ };
1021
+ const t = chosen.template;
1022
+ const sections = input.sections ?? {};
1023
+ for (const [id, text] of Object.entries(sections)) {
1024
+ if (!t.sections.some((s) => s.id === id)) {
1025
+ const ids = t.sections.map((s) => s.id).join(", ");
1026
+ throw new UsageError(`template ${t.name} has no section ${id}; its sections are ${ids}`);
1027
+ }
1028
+ checkText(`section ${id}`, text);
1029
+ }
1030
+ const rendered = renderTemplate(t, {
1031
+ title,
1032
+ sections
1033
+ }, { missing: "guidance" });
1034
+ if (!rendered.ok) throw new UsageError(rendered.findings.map((f) => f.message).join("; "));
1035
+ let id = "";
1036
+ let bodyPath = "";
1037
+ for (let attempt = 0;; attempt++) {
1038
+ id = newSubjectId(c.random);
1039
+ bodyPath = `${read.root}/${read.layout.bodies}/${bodyFileName(id, slugify(title))}`;
1040
+ if (!read.folded.subjects.has(id) && await read.tree.read(bodyPath) === null) break;
1041
+ if (attempt + 1 >= SUBJECT_ATTEMPTS) throw new FileExistsError(bodyPath);
1042
+ }
1043
+ const lint = lintDocument(t, rendered.text, {
1044
+ file: bodyPath,
1045
+ subject: id
1046
+ });
1047
+ if (o.refuseIncomplete === true && lint.length > 0) return {
1048
+ ok: false,
1049
+ decision: null,
1050
+ findings: lint
1051
+ };
1052
+ const event = newEvent(read, c, {
1053
+ subject: id,
1054
+ type: "proposed",
1055
+ data: {
1056
+ title,
1057
+ template: t.name,
1058
+ template_version: t.version,
1059
+ ...supersedes.length === 0 ? {} : { supersedes }
1060
+ },
1061
+ actor: input.actor
1062
+ });
1063
+ const body = encoder$1.encode(rendered.text);
1064
+ const files = { [bodyPath]: body };
1065
+ if (chosen.materialize !== null) files[chosen.path] = encoder$1.encode(chosen.materialize);
1066
+ Object.assign(files, filesOf([event], read));
1067
+ const { after, added } = await precheck(read, files);
1068
+ if (added.length > 0) return {
1069
+ ok: false,
1070
+ decision: null,
1071
+ findings: added
1072
+ };
1073
+ const { scope, eventDirs } = commitScope(read, [id], {
1074
+ body: bodyPath,
1075
+ template: chosen.path
1076
+ });
1077
+ return {
1078
+ ok: true,
1079
+ plan: {
1080
+ command: "new",
1081
+ subject: id,
1082
+ files,
1083
+ events: [event],
1084
+ stage: Object.keys(files),
1085
+ involved: [id],
1086
+ commitScope: scope,
1087
+ eventDirs,
1088
+ draft: {
1089
+ path: bodyPath,
1090
+ rendered: body,
1091
+ template: t
1092
+ },
1093
+ commit: {
1094
+ action: ACTIONS.proposed.action,
1095
+ title,
1096
+ id,
1097
+ display: id,
1098
+ body: []
1099
+ },
1100
+ trailers: pairsOf([event]),
1101
+ decision: viewOf(after, id),
1102
+ affected: [],
1103
+ findings: lint
1104
+ }
1105
+ };
1106
+ }
1107
+ /** The draft lint of an `accept`: the body against the template version its proposal recorded. */
1108
+ async function acceptLint(read, view, c) {
1109
+ const body = read.folded.subjects.get(view.id)?.loaded.body ?? null;
1110
+ if (body === null) return {
1111
+ findings: [refusal(read, "body/missing", "body file is missing", view.id)],
1112
+ bytes: null
1113
+ };
1114
+ const { name, version } = view.template;
1115
+ const found = await c.templates.get(name, version);
1116
+ if (!found.ok) {
1117
+ const message = found.reason === "not-found" ? `template ${name} not found at ${found.path}` : `template ${name} has no version ${version} at ${found.path}`;
1118
+ return {
1119
+ findings: [refusal(read, `template/${found.reason}`, message, view.id)],
1120
+ bytes: body.bytes
1121
+ };
1122
+ }
1123
+ let text;
1124
+ try {
1125
+ text = new TextDecoder("utf-8", { fatal: true }).decode(body.bytes);
1126
+ } catch {
1127
+ return {
1128
+ findings: [refusal(read, "body/not-utf8", "body is not valid UTF-8", view.id)],
1129
+ bytes: body.bytes
1130
+ };
1131
+ }
1132
+ return {
1133
+ findings: lintDocument(found.template, text, {
1134
+ file: body.path,
1135
+ subject: view.id
1136
+ }).map((f) => ({
1137
+ ...f,
1138
+ kind: adrKind.name
1139
+ })),
1140
+ bytes: body.bytes
1141
+ };
1142
+ }
1143
+ /** Plans `adr accept`: `accepted` with the body hash, and `superseded` on each declared target. */
1144
+ async function planAccept(read, input, c, o = {}) {
1145
+ const id = resolveDecision(read, input.id);
1146
+ const view = viewOf(read, id);
1147
+ const refused = (findings) => ({
1148
+ ok: false,
1149
+ decision: view,
1150
+ findings
1151
+ });
1152
+ const findings = [];
1153
+ if (view.status !== "proposed") findings.push(refusal(read, "transition/illegal", illegal("accepted", view.status), id));
1154
+ const lint = await acceptLint(read, view, c);
1155
+ findings.push(...lint.findings);
1156
+ if (read.adr.acceptRequiresHuman && input.actor.kind === "agent") findings.push(refusal(read, "decision/agent-accepted", `accepted by agent ${input.actor.name}; accept_requires_human needs a human`, id));
1157
+ const targets = [];
1158
+ for (const target of view.declared_supersedes) {
1159
+ if (target.includes(":")) {
1160
+ findings.push(refusal(read, "decision/reference-missing", `supersedes ${target}: ${QUALIFIED_SUPERSESSION}`, id));
1161
+ continue;
1162
+ }
1163
+ const t = read.projection.views.get(target);
1164
+ if (t === void 0) findings.push(refusal(read, "decision/reference-missing", `supersedes ${target}, which is not in the log`, id));
1165
+ else if (target === id) findings.push(refusal(read, "decision/supersession-cycle", `supersession cycle: ${id} → ${id}`, id));
1166
+ else if (t.status !== "accepted" && t.status !== "deprecated") findings.push(refusal(read, "transition/illegal", illegal("superseded", t.status), target));
1167
+ else targets.push(target);
1168
+ }
1169
+ if (findings.length > 0 || lint.bytes === null) return refused(findings);
1170
+ const accepted = newEvent(read, c, {
1171
+ subject: id,
1172
+ type: "accepted",
1173
+ data: { body_sha256: bodySha256(lint.bytes) },
1174
+ actor: input.actor
1175
+ });
1176
+ const cascade = targets.map((target) => newEvent(read, c, {
1177
+ subject: target,
1178
+ type: "superseded",
1179
+ data: { by: id },
1180
+ actor: input.actor
1181
+ }));
1182
+ const copyGlobs = o.copyGlobs ?? [];
1183
+ const copied = copyGlobs.length === 0 ? null : newEvent(read, c, {
1184
+ subject: id,
1185
+ type: "scoped",
1186
+ data: { set: [...applySetOp(view.applies_to, { add: copyGlobs }, {
1187
+ what: "glob",
1188
+ maxItems: 200
1189
+ }).set] },
1190
+ actor: input.actor,
1191
+ after: [accepted]
1192
+ });
1193
+ if (copied !== null) {
1194
+ const valid = compiled.validate(copied, { file: null });
1195
+ if (!valid.ok) return refused(valid.findings.map((f) => ({
1196
+ ...f,
1197
+ subject: id,
1198
+ kind: adrKind.name
1199
+ })));
1200
+ }
1201
+ const sources = copied === null ? [] : scopeToCopy(read, id).sources;
1202
+ const events = [
1203
+ accepted,
1204
+ ...copied === null ? [] : [copied],
1205
+ ...cascade
1206
+ ];
1207
+ const files = filesOf(events, read);
1208
+ const { after, added } = await precheck(read, files);
1209
+ if (added.length > 0) return refused(added);
1210
+ const involved = sorted([id, ...targets]);
1211
+ const { scope, eventDirs } = commitScope(read, involved);
1212
+ const bodyPath = view.body_path ?? "";
1213
+ return {
1214
+ ok: true,
1215
+ plan: {
1216
+ command: "accept",
1217
+ subject: id,
1218
+ files,
1219
+ events,
1220
+ stage: [...Object.keys(files), bodyPath],
1221
+ involved,
1222
+ commitScope: scope,
1223
+ eventDirs,
1224
+ draft: null,
1225
+ commit: {
1226
+ action: ACTIONS.accepted.action,
1227
+ title: view.title,
1228
+ id,
1229
+ display: view.display,
1230
+ body: [...targets.map((t) => {
1231
+ const v = viewOf(read, t);
1232
+ return `Supersedes ${v.display}: ${sentenceTitle(v.title)}.`;
1233
+ }), ...sources.map((t) => `Copies the scope of ${viewOf(read, t).display}.`)]
1234
+ },
1235
+ trailers: pairsOf(events),
1236
+ decision: viewOf(after, id),
1237
+ affected: targets.map((t) => viewOf(after, t)),
1238
+ findings: []
1239
+ }
1240
+ };
1241
+ }
1242
+ /**
1243
+ * What accepting `id` could copy: the declared targets it will supersede (accepted or
1244
+ * deprecated, as `planAccept` selects them) and the globs of theirs it doesn't have yet.
1245
+ */
1246
+ function scopeToCopy(read, id) {
1247
+ const own = new Set(viewOf(read, id).applies_to);
1248
+ const sources = [];
1249
+ const globs = /* @__PURE__ */ new Set();
1250
+ for (const target of sorted(viewOf(read, id).declared_supersedes)) {
1251
+ const t = target === id ? void 0 : read.projection.views.get(target);
1252
+ if (t === void 0 || t.status !== "accepted" && t.status !== "deprecated") continue;
1253
+ const extra = t.applies_to.filter((g) => !own.has(g));
1254
+ if (extra.length === 0) continue;
1255
+ sources.push(target);
1256
+ for (const g of extra) globs.add(g);
1257
+ }
1258
+ return {
1259
+ id,
1260
+ sources,
1261
+ displays: sources.map((t) => viewOf(read, t).display),
1262
+ globs: sorted(globs)
1263
+ };
1264
+ }
1265
+ const shQuote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
1266
+ const andList = (xs) => xs.length <= 1 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`;
1267
+ /** The note of an offer not taken: the `adr scope` command that copies it. */
1268
+ function copyScopeNote(id, offer) {
1269
+ const n = offer.globs.length;
1270
+ const adds = offer.globs.map((g) => `--add ${shQuote(g)}`).join(" ");
1271
+ return `not copying the scope of ${andList(offer.displays)} (${n} ${n === 1 ? "glob" : "globs"}); to copy it: adr scope ${id} ${adds}`;
1272
+ }
1273
+ /** Plans `adr supersede <id> --by <by>`: one `superseded` event on `id`. */
1274
+ async function planSupersede(read, input, c) {
1275
+ if (input.by.includes(":")) throw new UsageError(`--by ${input.by.trim()}: ${QUALIFIED_SUPERSESSION}`);
1276
+ const reason = checkReason(input.reason);
1277
+ const id = resolveDecision(read, input.id);
1278
+ const by = resolveDecision(read, input.by);
1279
+ const view = viewOf(read, id);
1280
+ const target = viewOf(read, by);
1281
+ const findings = [];
1282
+ if (view.status !== "accepted" && view.status !== "deprecated") findings.push(refusal(read, "transition/illegal", illegal("superseded", view.status), id));
1283
+ if (by === id || target.chain.includes(id)) {
1284
+ const cycle = by === id ? [id, id] : [id, ...target.chain.slice(0, target.chain.indexOf(id) + 1)];
1285
+ findings.push(refusal(read, "decision/supersession-cycle", `supersession cycle: ${cycle.join(" → ")}`, id));
1286
+ } else if (target.status !== "accepted") findings.push(refusal(read, "decision/superseded-by-unaccepted", `superseded by ${by}, which is not accepted`, id));
1287
+ const refused = (fs) => ({
1288
+ ok: false,
1289
+ decision: view,
1290
+ findings: fs
1291
+ });
1292
+ if (findings.length > 0) return refused(findings);
1293
+ const event = newEvent(read, c, {
1294
+ subject: id,
1295
+ type: "superseded",
1296
+ data: { by },
1297
+ reason,
1298
+ actor: input.actor
1299
+ });
1300
+ const files = filesOf([event], read);
1301
+ const { after, added } = await precheck(read, files);
1302
+ if (added.length > 0) return refused(added);
1303
+ const involved = sorted([id, by]);
1304
+ const { scope, eventDirs } = commitScope(read, involved);
1305
+ return {
1306
+ ok: true,
1307
+ plan: {
1308
+ command: "supersede",
1309
+ subject: id,
1310
+ files,
1311
+ events: [event],
1312
+ stage: Object.keys(files),
1313
+ involved,
1314
+ commitScope: scope,
1315
+ eventDirs,
1316
+ draft: null,
1317
+ commit: {
1318
+ action: ACTIONS.superseded.action,
1319
+ title: `${view.display} with ${target.display}`,
1320
+ id,
1321
+ display: view.display,
1322
+ body: [`Superseded by ${target.display}: ${sentenceTitle(target.title)}.`]
1323
+ },
1324
+ trailers: pairsOf([event]),
1325
+ decision: viewOf(after, id),
1326
+ affected: [viewOf(after, id)],
1327
+ findings: []
1328
+ }
1329
+ };
1330
+ }
1331
+ const actionOf = (type) => Object.hasOwn(ACTIONS, type) ? ACTIONS[type] : {
1332
+ action: type,
1333
+ names: "display"
1334
+ };
1335
+ /** A title in a body sentence: whitespace collapsed and trailing dots removed, as in a subject. */
1336
+ const sentenceTitle = (title) => title.replace(/\s+/g, " ").replace(/[. ]+$/, "").trim();
1337
+ /**
1338
+ * Plans any one event on an existing decision: the state machine and the event's validation
1339
+ * decide, then the precheck. Phase 2's reject, withdraw, deprecate, note, link and scope wrap it.
1340
+ */
1341
+ async function planEvent(read, input, c) {
1342
+ const id = resolveDecision(read, input.subject);
1343
+ const view = viewOf(read, id);
1344
+ if (!adrKind.machine.types.has(input.type) || input.type === adrKind.machine.initial.type) throw new InvalidArgumentError(`planEvent can't write a ${input.type} event`);
1345
+ const reason = checkReason(input.reason);
1346
+ const refused = (fs) => ({
1347
+ ok: false,
1348
+ decision: view,
1349
+ findings: fs
1350
+ });
1351
+ if (!adrKind.machine.allowed(view.status, input.type)) return refused([refusal(read, "transition/illegal", illegal(input.type, view.status), id)]);
1352
+ const event = newEvent(read, c, {
1353
+ subject: id,
1354
+ type: input.type,
1355
+ data: input.data,
1356
+ reason,
1357
+ actor: input.actor
1358
+ });
1359
+ const valid = compiled.validate(event, { file: null });
1360
+ if (!valid.ok) return refused(valid.findings.map((f) => ({
1361
+ ...f,
1362
+ subject: id,
1363
+ kind: adrKind.name
1364
+ })));
1365
+ const files = filesOf([event], read);
1366
+ const { after, added } = await precheck(read, files);
1367
+ if (added.length > 0) return refused(added);
1368
+ const by = input.type === "superseded" ? String(input.data.by) : null;
1369
+ const involved = sorted([id, ...by !== null && isSubjectId(by) ? [by] : []]);
1370
+ const { scope, eventDirs } = commitScope(read, involved);
1371
+ return {
1372
+ ok: true,
1373
+ plan: {
1374
+ command: "event",
1375
+ subject: id,
1376
+ files,
1377
+ events: [event],
1378
+ stage: Object.keys(files),
1379
+ involved,
1380
+ commitScope: scope,
1381
+ eventDirs,
1382
+ draft: null,
1383
+ commit: {
1384
+ action: actionOf(input.type).action,
1385
+ title: actionOf(input.type).names === "title" ? view.title : view.display,
1386
+ id,
1387
+ display: view.display,
1388
+ body: reason === null ? [] : [`Reason: ${reason}`]
1389
+ },
1390
+ trailers: pairsOf([event]),
1391
+ decision: viewOf(after, id),
1392
+ affected: input.type === "superseded" ? [viewOf(after, id)] : [],
1393
+ findings: []
1394
+ }
1395
+ };
1396
+ }
1397
+ /** Plans `adr reject`: `rejected` with its required reason, while proposed. */
1398
+ function planReject(read, input, c) {
1399
+ return planEvent(read, {
1400
+ ...input,
1401
+ subject: input.id,
1402
+ type: "rejected",
1403
+ data: {}
1404
+ }, c);
1405
+ }
1406
+ /** Plans `adr withdraw`: `withdrawn`, with a reason when given, while proposed. */
1407
+ function planWithdraw(read, input, c) {
1408
+ return planEvent(read, {
1409
+ ...input,
1410
+ subject: input.id,
1411
+ type: "withdrawn",
1412
+ data: {}
1413
+ }, c);
1414
+ }
1415
+ /** Plans `adr deprecate`: `deprecated` with its required reason, while accepted. */
1416
+ function planDeprecate(read, input, c) {
1417
+ return planEvent(read, {
1418
+ ...input,
1419
+ subject: input.id,
1420
+ type: "deprecated",
1421
+ data: {}
1422
+ }, c);
1423
+ }
1424
+ /** Plans `adr note`: `noted` with the text as given, in every state. */
1425
+ function planNote(read, input, c) {
1426
+ if (input.text.trim() === "") throw new UsageError("the note text must not be empty");
1427
+ checkText("the note text", input.text);
1428
+ return planEvent(read, {
1429
+ subject: input.id,
1430
+ type: "noted",
1431
+ data: { text: input.text },
1432
+ actor: input.actor
1433
+ }, c);
1434
+ }
1435
+ const LINK_RELS = [
1436
+ "relates-to",
1437
+ "refines",
1438
+ "depends-on"
1439
+ ];
1440
+ const SOURCE_NAME = /^[a-z][a-z0-9-]{0,31}$/;
1441
+ /**
1442
+ * Plans `adr link` (design §6.7): `linked` in every state. A qualified target must name one of
1443
+ * `o.sources` (the log's `[[sources]]` names); it isn't looked up in the other log. A link that
1444
+ * already exists plans nothing, with a note.
1445
+ */
1446
+ async function planLink(read, input, c, o = {}) {
1447
+ const rel = input.rel ?? "relates-to";
1448
+ if (!LINK_RELS.includes(rel)) throw new UsageError(`unknown --rel ${rel}; use relates-to, refines or depends-on`);
1449
+ const id = resolveDecision(read, input.id);
1450
+ const view = viewOf(read, id);
1451
+ const target = input.to.trim();
1452
+ let to;
1453
+ let shown;
1454
+ let described;
1455
+ let named;
1456
+ const at = target.indexOf(":");
1457
+ if (at >= 0) {
1458
+ const source = target.slice(0, at);
1459
+ const local = target.slice(at + 1);
1460
+ if (!SOURCE_NAME.test(source) || !isSubjectId(local)) throw new UsageError(`invalid decision ${JSON.stringify(input.to)}`);
1461
+ if (!(o.sources ?? []).includes(source)) throw new UsageError(`unknown source ${source}; add a [[sources]] entry named ${source}`);
1462
+ to = target;
1463
+ shown = target;
1464
+ described = `${rel} ${target}.`;
1465
+ named = `${view.display} ${CROSS_LOG_LINK}`;
1466
+ } else {
1467
+ to = resolveDecision(read, target);
1468
+ if (to === id) throw new UsageError("a decision can't link to itself");
1469
+ const t = viewOf(read, to);
1470
+ shown = t.display;
1471
+ described = `${rel} ${t.display}: ${sentenceTitle(t.title)}.`;
1472
+ named = `${view.display} to ${t.display}`;
1473
+ }
1474
+ if (view.links.some((l) => l.to === to && l.rel === rel)) return {
1475
+ ok: true,
1476
+ plan: {
1477
+ command: "event",
1478
+ subject: id,
1479
+ files: {},
1480
+ events: [],
1481
+ stage: [],
1482
+ involved: [id],
1483
+ commitScope: [],
1484
+ eventDirs: [],
1485
+ draft: null,
1486
+ commit: null,
1487
+ trailers: [],
1488
+ decision: view,
1489
+ affected: [],
1490
+ findings: [],
1491
+ notes: [`already linked: ${view.display} ${rel} ${shown}; nothing written`]
1492
+ }
1493
+ };
1494
+ const r = await planEvent(read, {
1495
+ subject: id,
1496
+ type: "linked",
1497
+ data: {
1498
+ to,
1499
+ rel
1500
+ },
1501
+ actor: input.actor
1502
+ }, c);
1503
+ if (!r.ok || r.plan.commit === null) return r;
1504
+ return {
1505
+ ok: true,
1506
+ plan: {
1507
+ ...r.plan,
1508
+ commit: {
1509
+ ...r.plan.commit,
1510
+ title: named,
1511
+ body: [described]
1512
+ }
1513
+ }
1514
+ };
1515
+ }
1516
+ /** `ADR-0003 (k3f9q8a1)`, or the id alone when the decision has no number. */
1517
+ const namedView = (v) => v.display === v.id ? v.id : `${v.display} (${v.id})`;
1518
+ /** The globs of `globs` that match none of `files`. */
1519
+ function unmatchedGlobs(globs, files) {
1520
+ const matches = globMatcher(globs);
1521
+ const hit = new Set(files.flatMap((f) => matches(f)));
1522
+ return globs.filter((g) => !hit.has(g));
1523
+ }
1524
+ /**
1525
+ * Plans `adr scope` with a set operation (design §6.8): one `scoped` event with the whole new set
1526
+ * while proposed, accepted or deprecated, or nothing when the set doesn't change. Invalid globs,
1527
+ * conflicting operations and more than 200 globs are `UsageError`s. Notes name removed globs that
1528
+ * weren't there, added ones that were, resulting globs matching none of `o.tracked` (when given)
1529
+ * and a narrowed accepted scope.
1530
+ */
1531
+ async function planScope(read, input, c, o = {}) {
1532
+ const id = resolveDecision(read, input.id);
1533
+ const view = viewOf(read, id);
1534
+ const result = applySetOp(view.applies_to, input.op, {
1535
+ what: "glob",
1536
+ validate: (g) => {
1537
+ const parsed = parseGlob(g);
1538
+ return parsed.ok ? null : parsed.message;
1539
+ },
1540
+ maxItems: 200,
1541
+ ...input.names === void 0 ? {} : { names: input.names }
1542
+ });
1543
+ if (!adrKind.machine.allowed(view.status, "scoped")) return {
1544
+ ok: false,
1545
+ decision: view,
1546
+ findings: [refusal(read, "transition/illegal", illegal("scoped", view.status), id)]
1547
+ };
1548
+ const named = namedView(view);
1549
+ const notes = [...result.absent.map((g) => `${g} is not in the scope of ${named}`), ...result.present.map((g) => `${g} is already in the scope of ${named}`)];
1550
+ if (!result.changed) return {
1551
+ ok: true,
1552
+ plan: {
1553
+ command: "event",
1554
+ subject: id,
1555
+ files: {},
1556
+ events: [],
1557
+ stage: [],
1558
+ involved: [id],
1559
+ commitScope: [],
1560
+ eventDirs: [],
1561
+ draft: null,
1562
+ commit: null,
1563
+ trailers: [],
1564
+ decision: view,
1565
+ affected: [],
1566
+ findings: [],
1567
+ notes
1568
+ }
1569
+ };
1570
+ if (o.tracked !== void 0) {
1571
+ const checked = (adrKind.def.scope?.activeIn ?? []).includes(view.status) ? "; verify (hooks and CI) reports scope/glob-unmatched until a file matches" : "";
1572
+ notes.push(...unmatchedGlobs(result.set, o.tracked).map((g) => `${g} matches no tracked file${checked}`));
1573
+ }
1574
+ if (result.removed.length > 0 && (view.status === "accepted" || view.status === "deprecated")) notes.push("this narrows the scope of an accepted decision; if its Scope section no longer reads true, propose a superseding decision instead");
1575
+ const r = await planEvent(read, {
1576
+ subject: id,
1577
+ type: "scoped",
1578
+ data: { set: [...result.set] },
1579
+ actor: input.actor
1580
+ }, c);
1581
+ return r.ok ? {
1582
+ ok: true,
1583
+ plan: {
1584
+ ...r.plan,
1585
+ notes
1586
+ }
1587
+ } : r;
1588
+ }
1589
+ /**
1590
+ * Plans one `scoped` event per change, in one batch (design §6.8 step 4): each decision's status
1591
+ * must allow `scoped` and each event must validate, else the first failing decision is refused.
1592
+ * The plan has no commit message, because `--follow-renames` never commits. `decision` is
1593
+ * `input.id`'s view after the write, or `null` without one; `affected` are the changed views.
1594
+ */
1595
+ async function planScopeEvents(read, changes, input, c) {
1596
+ const given = input.id === null ? null : viewOf(read, input.id);
1597
+ const events = [];
1598
+ for (const change of changes) {
1599
+ const view = viewOf(read, change.id);
1600
+ const refused = (findings) => ({
1601
+ ok: false,
1602
+ decision: given,
1603
+ findings
1604
+ });
1605
+ if (!adrKind.machine.allowed(view.status, "scoped")) return refused([refusal(read, "transition/illegal", illegal("scoped", view.status), change.id)]);
1606
+ const event = newEvent(read, c, {
1607
+ subject: change.id,
1608
+ type: "scoped",
1609
+ data: { set: [...change.set] },
1610
+ actor: input.actor
1611
+ });
1612
+ const valid = compiled.validate(event, { file: null });
1613
+ if (!valid.ok) return refused(valid.findings.map((f) => ({
1614
+ ...f,
1615
+ subject: change.id,
1616
+ kind: adrKind.name
1617
+ })));
1618
+ events.push(event);
1619
+ }
1620
+ const files = filesOf(events, read);
1621
+ const { after, added } = await precheck(read, files);
1622
+ if (added.length > 0) return {
1623
+ ok: false,
1624
+ decision: given,
1625
+ findings: added
1626
+ };
1627
+ const involved = sorted(changes.map((ch) => ch.id));
1628
+ const { scope, eventDirs } = commitScope(read, involved);
1629
+ return {
1630
+ ok: true,
1631
+ plan: {
1632
+ command: "event",
1633
+ subject: input.id ?? involved[0] ?? "",
1634
+ files,
1635
+ events,
1636
+ stage: Object.keys(files),
1637
+ involved,
1638
+ commitScope: scope,
1639
+ eventDirs,
1640
+ draft: null,
1641
+ commit: null,
1642
+ trailers: pairsOf(events),
1643
+ decision: input.id === null ? null : viewOf(after, input.id),
1644
+ affected: changes.map((ch) => viewOf(after, ch.id)),
1645
+ findings: []
1646
+ }
1647
+ };
1648
+ }
1649
+ /** A plan that writes nothing: the views as they are, and `notes`. */
1650
+ function emptyPlan(subject, decision, notes) {
1651
+ return {
1652
+ ok: true,
1653
+ plan: {
1654
+ command: "event",
1655
+ subject,
1656
+ files: {},
1657
+ events: [],
1658
+ stage: [],
1659
+ involved: decision === null ? [] : [decision.id],
1660
+ commitScope: [],
1661
+ eventDirs: [],
1662
+ draft: null,
1663
+ commit: null,
1664
+ trailers: [],
1665
+ decision,
1666
+ affected: [],
1667
+ findings: [],
1668
+ notes
1669
+ }
1670
+ };
1671
+ }
1672
+ /**
1673
+ * Plans `adr scope [<decision>] --follow-renames` (design §6.8 steps 1–4): the given decision's
1674
+ * globs, or every decision's, rewritten by `followRenames`. Decisions whose status forbids
1675
+ * `scoped` are skipped, with a note when the renames would have changed their scope; an explicit
1676
+ * one is refused.
1677
+ */
1678
+ async function planFollowRenames(read, input, staged, c) {
1679
+ const id = input.id === null ? null : resolveDecision(read, input.id);
1680
+ const given = id === null ? null : viewOf(read, id);
1681
+ if (staged.renames.length === 0) return emptyPlan(id ?? "", given, []);
1682
+ if (given !== null && !adrKind.machine.allowed(given.status, "scoped")) return {
1683
+ ok: false,
1684
+ decision: given,
1685
+ findings: [refusal(read, "transition/illegal", illegal("scoped", given.status), given.id)]
1686
+ };
1687
+ const views = given !== null ? [given] : [...read.projection.views.values()];
1688
+ const notes = [];
1689
+ const changes = [];
1690
+ for (const view of [...views].sort((a, b) => a.id < b.id ? -1 : 1)) {
1691
+ const result = followRenames({
1692
+ globs: view.applies_to,
1693
+ ...staged
1694
+ });
1695
+ if (!result.changed) continue;
1696
+ const named = namedView(view);
1697
+ if (!adrKind.machine.allowed(view.status, "scoped")) {
1698
+ const covers = globMatcher(view.applies_to);
1699
+ const leaving = staged.renames.filter((r) => covers(r.from).length > 0).length;
1700
+ notes.push(`${named} is ${view.status}; its scope can't follow renames (${leaving} renamed files leave it)`);
1701
+ continue;
1702
+ }
1703
+ if (result.globs.length > 200) throw new UsageError(`following the renames gives ${named} ${result.globs.length} globs; the limit is 200`);
1704
+ for (const s of result.skipped) notes.push(`${s.to} can't be written as a scope glob (${s.problem}); ${named} no longer covers it`);
1705
+ for (const path of result.gained) notes.push(`${path} is now covered by ${named}'s scope but wasn't before the rename; check it`);
1706
+ changes.push({
1707
+ id: view.id,
1708
+ set: result.globs
1709
+ });
1710
+ }
1711
+ if (changes.length === 0) return emptyPlan(id ?? "", given, notes);
1712
+ const r = await planScopeEvents(read, changes, {
1713
+ id,
1714
+ actor: input.actor
1715
+ }, c);
1716
+ return r.ok ? {
1717
+ ok: true,
1718
+ plan: {
1719
+ ...r.plan,
1720
+ notes
1721
+ }
1722
+ } : r;
1723
+ }
1724
+ /** The paths of every file in `tree`. */
1725
+ async function filePaths(tree) {
1726
+ return (await tree.list("")).filter((e) => e.type === "file").map((e) => e.path);
1727
+ }
1728
+ /** Every tracked file in the index, for the "matches no tracked file" notes. */
1729
+ async function trackedFiles(log) {
1730
+ return filePaths(await indexReader(log.git));
1731
+ }
1732
+ const isYaml = (path) => /\.ya?ml$/.test(path);
1733
+ /**
1734
+ * What the recovery hint says about rerunning a write command after `git commit` failed (design
1735
+ * 2b D5): the batch is already in the log, so a rerun is refused, writes nothing or duplicates it.
1736
+ */
1737
+ const WRITE_RERUN = "Don't run the command again: the log already holds the change, so a rerun would be refused, do nothing, or write it a second time.";
1738
+ /**
1739
+ * Applies a plan (§5.4): refuses a commit whose scope has unstaged or needed untracked paths,
1740
+ * writes the files (removing them again if a write fails), stages them, runs `afterStage`, and
1741
+ * commits `plan.stage` plus the involved subjects' already-staged files with exact trailers. A
1742
+ * failed `git commit` throws `CommitFailedError` with the manual command (design 2b D5) and the
1743
+ * notes made so far.
1744
+ */
1745
+ /**
1746
+ * A write leaves the work tree's render of `index.md` (`staged`) staged, and it lists decision
1747
+ * files that may not be staged. When some aren't and the index's own render differs, the next
1748
+ * plain commit's hook fails; the note says so, as its `index/stale` finding does. `null`
1749
+ * otherwise, or when that render fails.
1750
+ */
1751
+ async function unstagedIndexNote(log, at, staged, history, render) {
1752
+ const files = await unstagedLogFiles(log.git, log.layout);
1753
+ if (files.length === 0) return null;
1754
+ const own = await render(await indexReader(log.git), history).catch(() => null);
1755
+ if (own === null || own === staged) return null;
1756
+ return `${unstagedIndexText(at, files)}; git add them before you commit, or move them out of the work tree and run adr index`;
1757
+ }
1758
+ async function applyPlan(log, plan, o) {
1759
+ const { git } = log;
1760
+ if (o.commit && o.message === void 0) throw new InvalidArgumentError("applyPlan needs a message to commit");
1761
+ const stage = new Set(plan.stage);
1762
+ if (o.commit) {
1763
+ const unstaged = new Set(await git.unstagedPaths(plan.commitScope));
1764
+ const dirs = new Set(plan.eventDirs);
1765
+ const untracked = /* @__PURE__ */ new Set([...await git.untrackedPaths(plan.commitScope.filter((p) => !dirs.has(p))), ...(await git.untrackedPaths(plan.eventDirs)).filter(isYaml)]);
1766
+ const [first] = sorted([...unstaged, ...untracked]).filter((p) => !stage.has(p));
1767
+ if (first !== void 0) throw new UsageError(unstaged.has(first) ? `${first} has unstaged changes; stage or discard them, or run without --commit` : `${first} is not tracked; git add it, or run without --commit`);
1768
+ }
1769
+ const root = log.layout.root;
1770
+ const at = indexPath(log.layout);
1771
+ const notes = [];
1772
+ const head = await git.resolveCommit("HEAD");
1773
+ const extraOf = async () => (await git.stagedPaths(plan.commitScope)).filter((p) => !stage.has(p));
1774
+ const commitTree = async (extra) => {
1775
+ const work = worktreeReader(git);
1776
+ const files = {};
1777
+ const gone = /* @__PURE__ */ new Set();
1778
+ for (const p of sorted([...plan.stage, ...extra])) {
1779
+ const bytes = plan.files[p] ?? await work.read(p);
1780
+ if (bytes === null) gone.add(p);
1781
+ else files[p] = bytes;
1782
+ }
1783
+ return withoutPaths(overlayTree(await headTree(git, head), files), gone);
1784
+ };
1785
+ const extra0 = o.commit && o.index !== void 0 ? await extraOf() : [];
1786
+ let text = null;
1787
+ if (o.index !== void 0) {
1788
+ const tree = o.commit ? await commitTree(extra0) : overlayTree(worktreeReader(git), plan.files);
1789
+ text = await o.index(tree, head);
1790
+ if (text === null) notes.push(`${at} not updated: numbers need history in this shallow clone; run git fetch --unshallow, then adr index`);
1791
+ }
1792
+ const toStage = [];
1793
+ let indexWritten = false;
1794
+ let indexOld = null;
1795
+ let indexInCommit = false;
1796
+ /** Brings the work-tree copy to `t` and stages it (now, or with the batch) where they differ. */
1797
+ const refresh = async (t, now) => {
1798
+ const r = await writeIndexCopy(git, at, t);
1799
+ if (r.wrote && !indexWritten) indexOld = r.old;
1800
+ indexWritten ||= r.wrote;
1801
+ if (r.stage) {
1802
+ if (now) await git.add([at]);
1803
+ else toStage.push(at);
1804
+ }
1805
+ return r.wrote || r.stage;
1806
+ };
1807
+ /** Step 3's rules: committing, HEAD's copy decides; otherwise the work tree and the index. */
1808
+ const place = async (t, committing, now) => {
1809
+ if (!committing) {
1810
+ await refresh(t, now);
1811
+ return;
1812
+ }
1813
+ indexInCommit = !holdsText(await headBytes(git, at), t);
1814
+ if (indexInCommit) await refresh(t, now);
1815
+ };
1816
+ const eventPaths = new Set(plan.events.map((e) => eventPath(log.layout, e)));
1817
+ const written = [];
1818
+ try {
1819
+ for (const [path, bytes] of Object.entries(plan.files)) {
1820
+ if (eventPaths.has(path)) continue;
1821
+ await writeNewFile(git, path, bytes, { stage: false });
1822
+ written.push(path);
1823
+ }
1824
+ if (text !== null) await place(text, o.commit, false);
1825
+ await writeEvents(git, log.layout, plan.events, {
1826
+ kind: log.compiled,
1827
+ stage: false
1828
+ });
1829
+ } catch (err) {
1830
+ const top = git.root ?? "";
1831
+ if (indexWritten) {
1832
+ if (indexOld === null) await rm(join(top, at), { force: true });
1833
+ else await writeFile(join(top, at), indexOld);
1834
+ }
1835
+ for (const path of written) await rm(join(top, path), { force: true });
1836
+ throw err;
1837
+ }
1838
+ await git.add([...plan.stage, ...toStage]);
1839
+ /** The work tree's render that ends up staged, and the commit it builds on. */
1840
+ let stagedIndex = !o.commit && text !== null ? {
1841
+ text,
1842
+ history: head
1843
+ } : null;
1844
+ let findings = plan.findings;
1845
+ let commit = o.commit;
1846
+ if (o.afterStage !== void 0) {
1847
+ const after = await o.afterStage();
1848
+ findings = after.findings ?? plan.findings;
1849
+ commit = commit && after.commit;
1850
+ }
1851
+ const stale = (err) => `${at} may be stale: ${firstLine(err)}; run adr index`;
1852
+ let sha = null;
1853
+ if (commit) {
1854
+ const extra = await extraOf();
1855
+ const staged = extra.filter(isYaml).flatMap((p) => {
1856
+ const c = classifyPath(log.layout, p);
1857
+ const name = c.area === "event" && !c.file.includes("/") ? parseEventFileName(c.file) : null;
1858
+ return c.area === "event" && c.subject !== null && name !== null ? [["ADR", c.subject], ["ADR-Event", name.type]] : [];
1859
+ });
1860
+ const message = o.message ?? "";
1861
+ const mergeTrailers = o.mergeTrailers === true;
1862
+ const trailers = [...staged, ...plan.trailers];
1863
+ const sameEvents = sorted(extra.filter(isYaml)).join("\0") === sorted(extra0.filter(isYaml)).join("\0");
1864
+ if (text !== null && o.index !== void 0 && o.afterStage !== void 0 && !sameEvents) try {
1865
+ const again = await o.index(await commitTree(extra), head);
1866
+ if (again !== null) await place(again, true, true);
1867
+ } catch (err) {
1868
+ const recovery = await saveCommitRecovery(git, {
1869
+ message,
1870
+ mergeTrailers,
1871
+ trailers,
1872
+ only: sorted([
1873
+ ...plan.stage,
1874
+ ...extra,
1875
+ at
1876
+ ])
1877
+ });
1878
+ throw new CommitFailedError({
1879
+ recovery,
1880
+ headline: `${at} couldn't be refreshed: ${firstLine(err)}`,
1881
+ detail: "",
1882
+ rerun: WRITE_RERUN,
1883
+ before: INDEX_BEFORE(root),
1884
+ after: null,
1885
+ notes
1886
+ }, { cause: err });
1887
+ }
1888
+ try {
1889
+ sha = await commitWithRecovery(git, {
1890
+ message,
1891
+ mergeTrailers,
1892
+ trailers,
1893
+ only: sorted([
1894
+ ...plan.stage,
1895
+ ...extra,
1896
+ ...indexInCommit ? [at] : []
1897
+ ]),
1898
+ rerun: WRITE_RERUN,
1899
+ after: indexInCommit ? INDEX_AFTER(root) : null
1900
+ });
1901
+ } catch (err) {
1902
+ throw err instanceof CommitFailedError ? err.withNotes(notes) : err;
1903
+ }
1904
+ if (text !== null && o.index !== void 0) try {
1905
+ const again = await o.index(worktreeReader(git), sha);
1906
+ if (again !== null) {
1907
+ await refresh(again, true);
1908
+ stagedIndex = {
1909
+ text: again,
1910
+ history: sha
1911
+ };
1912
+ }
1913
+ } catch (err) {
1914
+ notes.push(stale(err));
1915
+ }
1916
+ } else if (o.commit && text !== null && o.index !== void 0) try {
1917
+ const again = await o.index(worktreeReader(git), head);
1918
+ if (again !== null) {
1919
+ await refresh(again, true);
1920
+ stagedIndex = {
1921
+ text: again,
1922
+ history: head
1923
+ };
1924
+ }
1925
+ } catch (err) {
1926
+ notes.push(stale(err));
1927
+ }
1928
+ if (stagedIndex !== null && o.index !== void 0) {
1929
+ const { text: t, history } = stagedIndex;
1930
+ const note = await unstagedIndexNote(log, at, t, history, o.index);
1931
+ if (note !== null) notes.push(note);
1932
+ }
1933
+ return {
1934
+ written: [...Object.keys(plan.files), ...indexWritten ? [at] : []],
1935
+ commit: sha,
1936
+ findings,
1937
+ notes
1938
+ };
1939
+ }
1940
+ /** `tree` without the paths in `gone` (a staged deletion in a commit's `only` list). */
1941
+ function withoutPaths(tree, gone) {
1942
+ if (gone.size === 0) return tree;
1943
+ return {
1944
+ label: tree.label,
1945
+ commit: null,
1946
+ list: async (prefix) => (await tree.list(prefix)).filter((e) => !gone.has(e.path)),
1947
+ read: async (path) => gone.has(path) ? null : tree.read(path),
1948
+ readMany: (paths) => tree.readMany(paths.filter((p) => !gone.has(p)))
1949
+ };
1950
+ }
1951
+ const DEFAULT_DEPS = {
1952
+ now: Date.now,
1953
+ apply: applyPlan
1954
+ };
1955
+ /**
1956
+ * The views `adr-write/v1` reports. Without a commit they are the plan's, folded on the overlay.
1957
+ * After a commit they come from a new read of the log, so a commit on the main ref shows the
1958
+ * numbers it assigned, as the CLI text and phase 3's MCP tools do. A commit that failed threw in
1959
+ * `applyPlan`, so nothing is read for it.
1960
+ */
1961
+ async function viewsAfter(log, plan, sha) {
1962
+ if (sha === null) return {
1963
+ decision: plan.decision,
1964
+ affected: plan.affected
1965
+ };
1966
+ const read = await readDecisions({
1967
+ ...log,
1968
+ commit: sha
1969
+ });
1970
+ const view = (v) => read.projection.views.get(v.id) ?? v;
1971
+ return {
1972
+ decision: plan.decision === null ? null : view(plan.decision),
1973
+ affected: plan.affected.map(view)
1974
+ };
1975
+ }
1976
+ const SHORTENED_NOTE = "commit subject shortened to 72 characters; the full title is in the commit body";
1977
+ /**
1978
+ * `--commit` needs an author and a committer identity; checked before anything is written, since
1979
+ * `GIT_AUTHOR_*` alone satisfies the author while `git commit` still fails on the committer.
1980
+ */
1981
+ async function checkCommitIdentity(log) {
1982
+ if (await log.git.identity() === null || await log.git.committerIdentity() === null) throw new ConfigError("--commit needs a git identity; set git user.name and user.email");
1983
+ }
1984
+ /** The plan's commit message from `[commit] subject` (design §4), and whether it was shortened. */
1985
+ function commitMessage(log, command, plan) {
1986
+ const parts = plan.commit;
1987
+ if (parts === null) throw new InvalidArgumentError("this write can't be committed");
1988
+ const r = renderCommitMessage(log.adr.commitSubject, {
1989
+ title: parts.title,
1990
+ vars: {
1991
+ action: parts.action,
1992
+ id: parts.id,
1993
+ display: parts.display,
1994
+ command
1995
+ },
1996
+ body: parts.body
1997
+ });
1998
+ return {
1999
+ message: r.message,
2000
+ shortened: r.shortened
2001
+ };
2002
+ }
2003
+ /**
2004
+ * Plan, then apply, with the single `EventExistsError` retry (§5.4 steps 1–5). `o.message`
2005
+ * replaces the rendered commit message; `o.notes` come before the write's own notes. A plan with
2006
+ * no files and no events is reported as written without touching the repository, and so is
2007
+ * every plan with `dryRun` (its views are the plan's).
2008
+ */
2009
+ async function write(log, command, plan, o, deps, afterStage) {
2010
+ if (log.ref !== null) throw new InvalidArgumentError("writes read the work tree; open the log without a ref");
2011
+ if (log.tree.label !== "worktree") throw new InvalidArgumentError("writes read the work tree; open the log with the worktree reader");
2012
+ const commit = o.commit ?? log.adr.autoCommit;
2013
+ if (commit) await checkCommitIdentity(log);
2014
+ const notes = [...log.notes, ...o.notes ?? []];
2015
+ for (let attempt = 0;; attempt++) {
2016
+ const read = await readDecisions(log);
2017
+ const ref = await templateHistoryRef(log.git, { type: "full" });
2018
+ const r = await plan(read, {
2019
+ now: deps.now,
2020
+ ...deps.random === void 0 ? {} : { random: deps.random },
2021
+ templates: historyTemplates(log.git, log.tree, `${read.root}/templates`, { ref }),
2022
+ builtins: BUILTIN_TEMPLATES
2023
+ });
2024
+ if (!r.ok) return {
2025
+ format: "adr-write/v1",
2026
+ command,
2027
+ ok: false,
2028
+ decision: r.decision,
2029
+ affected: [],
2030
+ written: [],
2031
+ commit: null,
2032
+ findings: r.findings,
2033
+ notes
2034
+ };
2035
+ if (r.plan.events.length === 0 && Object.keys(r.plan.files).length === 0 || o.dryRun === true) return {
2036
+ format: "adr-write/v1",
2037
+ command,
2038
+ ok: true,
2039
+ decision: r.plan.decision,
2040
+ affected: r.plan.affected,
2041
+ written: [],
2042
+ commit: null,
2043
+ findings: r.plan.findings,
2044
+ notes: [...notes, ...r.plan.notes ?? []]
2045
+ };
2046
+ const rendered = commit ? commitMessage(log, command, r.plan) : null;
2047
+ const given = o.message ?? null;
2048
+ try {
2049
+ const applied = await deps.apply(log, r.plan, {
2050
+ commit,
2051
+ ...rendered === null ? {} : {
2052
+ message: given ?? rendered.message,
2053
+ mergeTrailers: given !== null
2054
+ },
2055
+ ...afterStage === void 0 ? {} : { afterStage: afterStage(r.plan, commit) },
2056
+ ...log.adr.index === "committed" && o.index !== "skip" ? { index: (tree, history) => renderDecisionIndex(log.git, {
2057
+ layout: log.layout,
2058
+ adr: log.adr,
2059
+ tree,
2060
+ history
2061
+ }) } : {}
2062
+ });
2063
+ const views = await viewsAfter(log, r.plan, applied.commit);
2064
+ const own = [
2065
+ ...r.plan.notes ?? [],
2066
+ ...applied.notes,
2067
+ ...applied.commit !== null && given === null && rendered?.shortened === true ? [SHORTENED_NOTE] : []
2068
+ ];
2069
+ return {
2070
+ format: "adr-write/v1",
2071
+ command,
2072
+ ok: true,
2073
+ decision: views.decision,
2074
+ affected: views.affected,
2075
+ written: applied.written,
2076
+ commit: applied.commit,
2077
+ findings: applied.findings,
2078
+ notes: [...notes, ...own]
2079
+ };
2080
+ } catch (err) {
2081
+ if (err instanceof CommitFailedError) throw err.withNotes([...notes, ...r.plan.notes ?? []]);
2082
+ if (!(err instanceof EventExistsError) || attempt > 0) throw err;
2083
+ }
2084
+ }
2085
+ }
2086
+ const sameBytes = (a, b) => a.byteLength === b.byteLength && a.every((x, i) => x === b[i]);
2087
+ async function readIfPresent(path) {
2088
+ try {
2089
+ return await readFile(path);
2090
+ } catch (err) {
2091
+ if (err.code === "ENOENT") return null;
2092
+ throw err;
2093
+ }
2094
+ }
2095
+ /**
2096
+ * The editor step (§5.4): runs `edit` on the staged draft, re-stages the edited body when the
2097
+ * editor exited 0, and skips the commit when committing would take content nobody confirmed: the
2098
+ * body changed while the editor failed, the body is gone, or another path in the commit scope (a
2099
+ * materialized template, say) changed while the editor was open (design review NIT 2). The
2100
+ * findings are the lint of the draft as it now is.
2101
+ */
2102
+ function editorStep(log, edit, onNote) {
2103
+ return (plan, commitRequested) => async () => {
2104
+ const d = plan.draft;
2105
+ if (d === null) return {
2106
+ findings: null,
2107
+ commit: true
2108
+ };
2109
+ const abs = join(log.git.root ?? "", d.path);
2110
+ const note = await edit(abs);
2111
+ const bytes = await readIfPresent(abs);
2112
+ let commit;
2113
+ if (bytes === null) {
2114
+ commit = false;
2115
+ onNote?.(`${d.path} was removed while editing; the rendered draft is still staged. Restore it with \`git checkout -- ${d.path}\``);
2116
+ } else if (note === null) {
2117
+ await log.git.add([d.path]);
2118
+ commit = true;
2119
+ } else if (sameBytes(bytes, d.rendered)) commit = true;
2120
+ else {
2121
+ commit = false;
2122
+ if (commitRequested) onNote?.(`${d.path} changed while the editor failed; review it, then git add it and commit`);
2123
+ }
2124
+ if (commit && commitRequested) {
2125
+ const [first] = (await log.git.unstagedPaths(plan.commitScope)).filter((p) => p !== d.path);
2126
+ if (first !== void 0) {
2127
+ commit = false;
2128
+ onNote?.(`${first} changed while the editor was open; review it, then git add it and commit`);
2129
+ }
2130
+ }
2131
+ const text = decoder$1.decode(bytes ?? d.rendered);
2132
+ const findings = lintDocument(d.template, text, {
2133
+ file: d.path,
2134
+ subject: plan.subject
2135
+ });
2136
+ if (commit && commitRequested && findings.length > 0) {
2137
+ commit = false;
2138
+ onNote?.(`${d.path} still has unfilled required sections; fill them in, then commit`);
2139
+ }
2140
+ return {
2141
+ findings,
2142
+ commit
2143
+ };
2144
+ };
2145
+ }
2146
+ /**
2147
+ * `adr new`: renders a draft, writes the body, any materialized template and the `proposed`
2148
+ * event, stages them, and commits with `commit` (default `[policy] auto_commit`).
2149
+ *
2150
+ * `edit` (CLI only; MCP never passes it) runs between staging and committing, on the absolute
2151
+ * body path. It resolves to `null` when the editor exited 0, else to a note for the user (spawn
2152
+ * error, exit status). `onNote` receives the notes about a skipped commit or a removed body,
2153
+ * without the "not committed: " prefix the CLI adds when a commit was requested.
2154
+ *
2155
+ * A draft with unfilled sections is never committed (design §7.2): without `edit` a commit is
2156
+ * refused (`ok: false`, nothing written); after `edit` the commit is skipped with a note.
2157
+ */
2158
+ async function proposeDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
2159
+ return write(log, "new", (read, c) => planPropose(read, input, c, { refuseIncomplete: (o.commit ?? log.adr.autoCommit) && o.edit === void 0 }), o, deps, o.edit === void 0 ? void 0 : editorStep(log, o.edit, o.onNote));
2160
+ }
2161
+ /**
2162
+ * `adr accept`: lints the draft, hashes the body, writes `accepted` and a `superseded` event on
2163
+ * each declared target, stages them with the body, and commits with `commit`. When the targets
2164
+ * have globs the decision lacks, `copyScope` decides whether a `scoped` event copies them
2165
+ * (default `"suggest"`: no copy, and a note with the `adr scope` command that would).
2166
+ */
2167
+ async function acceptDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
2168
+ const copyScope = o.copyScope ?? "suggest";
2169
+ let copy = null;
2170
+ return write(log, "accept", async (read, c) => {
2171
+ const plain = await planAccept(read, input, c);
2172
+ if (!plain.ok) return plain;
2173
+ const id = plain.plan.subject;
2174
+ const offer = scopeToCopy(read, id);
2175
+ if (offer.globs.length === 0) return plain;
2176
+ if (copy === null) copy = typeof copyScope === "function" ? await copyScope(offer) : copyScope === "copy";
2177
+ if (copy) return planAccept(read, input, c, { copyGlobs: offer.globs });
2178
+ if (copyScope === "skip") return plain;
2179
+ const notes = [...plain.plan.notes ?? [], copyScopeNote(id, offer)];
2180
+ return {
2181
+ ok: true,
2182
+ plan: {
2183
+ ...plain.plan,
2184
+ notes
2185
+ }
2186
+ };
2187
+ }, o, deps);
2188
+ }
2189
+ /** `adr supersede <id> --by <by>`: writes `superseded`, stages it, and commits with `commit`. */
2190
+ async function supersedeDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
2191
+ return write(log, "supersede", (read, c) => planSupersede(read, input, c), o, deps);
2192
+ }
2193
+ /**
2194
+ * `adr reject <id> --reason <text>`: writes `rejected`, stages it, and commits with `commit`. The
2195
+ * kind requires the reason; without one the write is refused (findings, nothing written).
2196
+ */
2197
+ async function rejectDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
2198
+ return write(log, "reject", (read, c) => planReject(read, input, c), o, deps);
2199
+ }
2200
+ /** `adr withdraw <id> [--reason <text>]`: writes `withdrawn`, stages it, and commits with `commit`. */
2201
+ async function withdrawDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
2202
+ return write(log, "withdraw", (read, c) => planWithdraw(read, input, c), o, deps);
2203
+ }
2204
+ /**
2205
+ * `adr deprecate <id> --reason <text>`: writes `deprecated`, stages it, and commits with `commit`.
2206
+ * The kind requires the reason, as for `rejectDecision`.
2207
+ */
2208
+ async function deprecateDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
2209
+ return write(log, "deprecate", (read, c) => planDeprecate(read, input, c), o, deps);
2210
+ }
2211
+ /** `adr note <id> <text>`: writes `noted`, stages it, and commits with `commit`. */
2212
+ async function noteDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
2213
+ return write(log, "note", (read, c) => planNote(read, input, c), o, deps);
2214
+ }
2215
+ /**
2216
+ * `adr link <id> <target> [--rel <rel>]`: writes `linked`, stages it, and commits with `commit`.
2217
+ * Qualified targets name the log's `[[sources]]`; a link that exists already writes nothing.
2218
+ */
2219
+ async function linkDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
2220
+ const sources = (log.config?.sources ?? []).map((s) => s.name);
2221
+ return write(log, "link", (read, c) => planLink(read, input, c, { sources }), o, deps);
2222
+ }
2223
+ /**
2224
+ * `adr scope <id> --set|--add|--remove …`: writes one `scoped` event with the new set, stages it,
2225
+ * and commits with `commit`. An unchanged set writes nothing.
2226
+ */
2227
+ async function scopeDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
2228
+ const tracked = await trackedFiles(log);
2229
+ return write(log, "scope", (read, c) => planScope(read, input, c, { tracked }), o, deps);
2230
+ }
2231
+ const FOLLOW_NO_COMMIT_NOTE = "--follow-renames never commits; commit the scoped events with the renames";
2232
+ /**
2233
+ * `adr scope [<decision>] --follow-renames` (design §6.8): rewrites the scope globs of the given
2234
+ * decision, or of every decision, after the renames staged against `HEAD`, and stages one
2235
+ * `scoped` event per changed decision. It never commits, whatever `[policy] auto_commit` says
2236
+ * (the events belong in the commit with the renames), so `o.commit: true` and a message are
2237
+ * `InvalidArgumentError`s. `dryRun` plans the same events and notes without writing anything.
2238
+ */
2239
+ async function followScopeRenames(log, input, o = {}, deps = DEFAULT_DEPS) {
2240
+ if (o.commit === true) throw new InvalidArgumentError("followScopeRenames never commits; don't pass commit: true");
2241
+ if (o.message !== void 0 && o.message !== null) throw new InvalidArgumentError("followScopeRenames never commits, so it takes no message");
2242
+ const renames = await stagedRenames(log.git);
2243
+ const staged = renames.length === 0 ? {
2244
+ renames,
2245
+ before: [],
2246
+ after: []
2247
+ } : {
2248
+ renames,
2249
+ before: await filePaths(await refReader(log.git, "HEAD")),
2250
+ after: await filePaths(await indexReader(log.git))
2251
+ };
2252
+ const doc = await write(log, "scope", (read, c) => planFollowRenames(read, {
2253
+ id: input.id ?? null,
2254
+ actor: input.actor
2255
+ }, staged, c), {
2256
+ ...o,
2257
+ commit: false
2258
+ }, deps);
2259
+ return log.adr.autoCommit && doc.ok && doc.affected.length > 0 ? {
2260
+ ...doc,
2261
+ notes: [...doc.notes, FOLLOW_NO_COMMIT_NOTE]
2262
+ } : doc;
2263
+ }
2264
+ // src/hooks.ts
2265
+ /** The hooks adr registers, in the order the text output lists them. */
2266
+ const HOOKS = [
2267
+ "pre-commit",
2268
+ "commit-msg",
2269
+ "pre-push",
2270
+ "post-merge"
2271
+ ];
2272
+ /**
2273
+ * adr's command per hook (design 2b D4), after `adr`. `--root <root>` comes first only when the
2274
+ * root can't be found without it: no `<repo>/adr.toml` names it and it isn't `docs/adr`. So the
2275
+ * hooks see the same config as a plain `adr` run, and never print the root-override note.
2276
+ */
2277
+ function decisionHookCommands(log) {
2278
+ const r = log.configPath === "adr.toml" || log.layout.root === adrKind.def.defaultRoot ? [] : ["--root", log.layout.root];
2279
+ return {
2280
+ "pre-commit": [
2281
+ ...r,
2282
+ "hooks",
2283
+ "run",
2284
+ "pre-commit"
2285
+ ],
2286
+ "commit-msg": [
2287
+ ...r,
2288
+ "hooks",
2289
+ "trailers",
2290
+ "{args}"
2291
+ ],
2292
+ "pre-push": [
2293
+ ...r,
2294
+ "verify",
2295
+ "--base",
2296
+ "@{upstream}",
2297
+ "--ref",
2298
+ "HEAD"
2299
+ ],
2300
+ "post-merge": [
2301
+ ...r,
2302
+ "list",
2303
+ "--since",
2304
+ "ORIG_HEAD"
2305
+ ]
2306
+ };
2307
+ }
2308
+ function registrations(log) {
2309
+ const commands = decisionHookCommands(log);
2310
+ return HOOKS.flatMap((hook) => {
2311
+ const argv = commands[hook];
2312
+ return argv === void 0 ? [] : [{
2313
+ hook,
2314
+ argv
2315
+ }];
2316
+ });
2317
+ }
2318
+ /** A shim path as the user sees it: repo-relative, with `/`. */
2319
+ function shown(git, path) {
2320
+ return relative(git.root ?? git.gitDir, path).split(sep).join("/");
2321
+ }
2322
+ /** The shims in hook order: `HOOKS`' order, as the text output lists them. */
2323
+ function inHookOrder(git, paths) {
2324
+ const rank = (p) => HOOKS.findIndex((h) => p.endsWith(`/${h}`));
2325
+ return paths.map((p) => shown(git, p)).sort((a, b) => rank(a) - rank(b));
2326
+ }
2327
+ function result(log, action, r, extra = {}) {
2328
+ return {
2329
+ format: "adr-hooks/v1",
2330
+ action,
2331
+ written: r === null ? [] : inHookOrder(log.git, r.written),
2332
+ unchanged: r === null ? [] : inHookOrder(log.git, r.unchanged),
2333
+ registrations: registrations(log),
2334
+ snippet: extra.snippet ?? null,
2335
+ trailers: extra.trailers ?? []
2336
+ };
2337
+ }
2338
+ /**
2339
+ * Registers adr's four commands in ledger's shims, one per hook and shared with other ark tools
2340
+ * (design 2b D4). With `core.hooksPath` set or a foreign hook file present, it writes the rest and
2341
+ * then throws `HookConflictError` (exit 2), naming each conflict and the `print` command.
2342
+ */
2343
+ async function installDecisionHooks(log) {
2344
+ const r = await installHooks(log.git, {
2345
+ tool: "adr",
2346
+ commands: decisionHookCommands(log)
2347
+ });
2348
+ assertNoHookConflicts(r, { tool: "adr" });
2349
+ return result(log, "install", r);
2350
+ }
2351
+ /** Removes adr from every shim; a shim left with no tool is deleted. */
2352
+ async function uninstallDecisionHooks(log) {
2353
+ return result(log, "uninstall", await uninstallHooks(log.git, { tool: "adr" }));
2354
+ }
2355
+ /** The config to paste into a hook manager instead of installing shims. */
2356
+ function printDecisionHooks(log, manager) {
2357
+ const regs = registrations(log).map((r) => ({
2358
+ tool: "adr",
2359
+ ...r
2360
+ }));
2361
+ return result(log, "print", null, { snippet: hookSnippet(manager, regs) });
2362
+ }
2363
+ /** The `ADR-Event` trailer key as git compares keys: case-insensitively. */
2364
+ const ADR_EVENT = /^adr-event[ \t]*:/im;
2365
+ /**
2366
+ * The `commit-msg` step (P1 §7.1): appends one `ADR`/`ADR-Event` pair per staged added event file
2367
+ * of the log, by path, keeping repeats (`ifExists: "add"`). It does nothing for a message that
2368
+ * already has an `ADR-Event:` trailer (an `adr … --commit` message), or during a merge, whose
2369
+ * events came with their own commits' trailers. Throws `UsageError` when the file is missing.
2370
+ */
2371
+ async function addHookTrailers(log, messageFile) {
2372
+ const { git, layout } = log;
2373
+ const file = resolve(git.root ?? git.gitDir, messageFile);
2374
+ try {
2375
+ await access(file, constants.R_OK | constants.W_OK);
2376
+ } catch {
2377
+ throw new UsageError(`no commit message file at ${messageFile}`);
2378
+ }
2379
+ const parsed = await git.run([
2380
+ "interpret-trailers",
2381
+ "--parse",
2382
+ "--",
2383
+ file
2384
+ ]);
2385
+ if (ADR_EVENT.test(Buffer.from(parsed.stdout).toString("utf8"))) return result(log, "trailers", null);
2386
+ if (await git.resolveCommit("MERGE_HEAD") !== null) return result(log, "trailers", null);
2387
+ const trailers = (await git.stagedPaths([`${layout.root}/${layout.events}`], { addedOnly: true })).flatMap((p) => {
2388
+ const c = classifyPath(layout, p);
2389
+ const name = c.area === "event" && !c.file.includes("/") ? parseEventFileName(c.file) : null;
2390
+ return c.area === "event" && c.subject !== null && name !== null ? [["ADR", c.subject], ["ADR-Event", name.type]] : [];
2391
+ });
2392
+ await git.appendTrailers(file, trailers, { ifExists: "add" });
2393
+ return result(log, "trailers", null, { trailers });
2394
+ }
2395
+ /**
2396
+ * Whether the hook may stage: only into the commit's real index (design 2b D8). `git commit
2397
+ * <paths>` and `--only`, adr's own `--commit` included, run hooks on a temporary index, and
2398
+ * staging there leaves the user's index showing the file as deleted.
2399
+ */
2400
+ function stagingSafe(git, env) {
2401
+ const file = env.GIT_INDEX_FILE;
2402
+ if (file === void 0 || file === "") return true;
2403
+ const at = resolve(git.root ?? git.gitDir, file);
2404
+ return at === resolve(git.gitDir, "index") || at === resolve(git.gitDir, "index.lock");
2405
+ }
2406
+ /** `ADR-0003, ADR-0004` for the decisions a rename following changes. */
2407
+ const displays = (views) => views.map((v) => v.display).join(", ");
2408
+ /**
2409
+ * `adr hooks run pre-commit` (design 2b D8): rename following, then the index, then `verify
2410
+ * --staged`, stopping at the first step that fails. `worktree` must be opened with the work-tree
2411
+ * reader, because rename following writes through `write()`; `index` with the index reader, for
2412
+ * the config as staged, the index render and verify. Only the index step stages `index.md`, and
2413
+ * only with `[hooks] index = "stage"`; rename following passes `index: "skip"`. Neither stages
2414
+ * into a temporary index (`stagingSafe`). A step that can't run throws (exit 2).
2415
+ */
2416
+ async function runPreCommitHook(o) {
2417
+ const { worktree, index, env } = o;
2418
+ const config = index.adr;
2419
+ const failOn = config.failOn;
2420
+ const notes = [...index.notes];
2421
+ const done = (exit, findings) => ({
2422
+ exit,
2423
+ findings,
2424
+ notes: [...new Set(notes)],
2425
+ failOn
2426
+ });
2427
+ const safe = stagingSafe(index.git, env);
2428
+ if (config.scopeFollowRenames !== "off" && (await stagedRenames(worktree.git)).length > 0) {
2429
+ const { actor, notes: attribution } = await resolveAttribution(worktree.git, { env });
2430
+ if (config.scopeFollowRenames === "warn") {
2431
+ const dry = await followScopeRenames(worktree, { actor }, { dryRun: true });
2432
+ for (const v of dry.affected) notes.push(`${v.display}'s globs don't follow the renames in this commit (scope_follow_renames = "warn"); run adr scope --follow-renames`);
2433
+ } else if (safe) {
2434
+ const doc = await followScopeRenames(worktree, { actor }, {
2435
+ index: "skip",
2436
+ notes: attribution
2437
+ });
2438
+ notes.push(...doc.notes);
2439
+ if (!doc.ok) return done(1, doc.findings);
2440
+ } else {
2441
+ const dry = await followScopeRenames(worktree, { actor }, { dryRun: true });
2442
+ if (dry.affected.length > 0) {
2443
+ notes.push(`the renames in this commit change the scope of ${displays(dry.affected)}; git commit <paths> can't take the scoped events. Run adr scope --follow-renames, then commit again including the files it lists, or set [hooks] scope_follow_renames = "warn"`);
2444
+ return done(1, []);
2445
+ }
2446
+ }
2447
+ }
2448
+ if (config.index === "committed" && config.hookIndex === "stage" && safe) {
2449
+ const path = indexPath(index.layout);
2450
+ const text = await renderDecisionIndex(index.git, {
2451
+ layout: index.layout,
2452
+ adr: config,
2453
+ tree: index.tree,
2454
+ history: await index.git.resolveCommit("HEAD")
2455
+ });
2456
+ if (text !== null && !holdsText(await index.tree.read(path), text)) {
2457
+ await writeIndexFile(index.git, path, text);
2458
+ await index.git.add([path]);
2459
+ notes.push(`regenerated and staged ${path}`);
2460
+ }
2461
+ }
2462
+ const findings = await verifyDecisions(index, { type: "staged" });
2463
+ return done(exitCode(findings, { failOn }), findings);
2464
+ }
2465
+ // src/init.ts
2466
+ /**
2467
+ * What the recovery hint says about rerunning `adr init` after `git commit` failed (design 2b D5):
2468
+ * init keeps existing files, so it would commit nothing.
2469
+ */
2470
+ const INIT_RERUN = "Running adr init again won't commit them: the files exist now, so it keeps them and commits nothing.";
2471
+ const CONFIG = "adr.toml";
2472
+ const DEFAULT_ROOT = adrKind.def.defaultRoot;
2473
+ const encoder = new TextEncoder();
2474
+ /** The starter config; `root` only for a repo-level file naming a log elsewhere. */
2475
+ function configText(o) {
2476
+ return [
2477
+ "# adr configuration. Every key is optional: https://github.com/nikovirtala/ark/tree/main/packages/adr#config-adrtoml",
2478
+ "[log]",
2479
+ ...o.root === null ? [] : [`root = ${JSON.stringify(o.root)}`],
2480
+ `default_template = ${JSON.stringify(o.template)}`,
2481
+ "",
2482
+ "[commit]",
2483
+ "# subject = \"docs(adr): {action} {title}\"",
2484
+ ""
2485
+ ].join("\n");
2486
+ }
2487
+ /**
2488
+ * `adr init` (design §6.2): writes a config for the log, unless one is found, and each built-in
2489
+ * template in `o.templates` (default `nygard`) to `<root>/templates/`, never overwriting a file.
2490
+ * A `--root` other than `docs/adr` gets a repo-level `adr.toml` naming it, so later commands find
2491
+ * the log without `--root`; when that file exists for another log, the config goes in the root.
2492
+ * With `[log] index = "committed"` it also writes `<root>/index.md` unless one exists. With
2493
+ * `commit` (default `[policy] auto_commit`) the written files are committed on their own;
2494
+ * nothing written means nothing committed. No events are written.
2495
+ */
2496
+ async function initDecisionLog(log, o = {}) {
2497
+ if (log.ref !== null || log.tree.label !== "worktree") throw new InvalidArgumentError("init writes the work tree; open the log without a ref");
2498
+ const known = Object.keys(BUILTIN_TEMPLATES);
2499
+ const templates = [...new Set(o.templates === void 0 ? ["nygard"] : o.templates)];
2500
+ for (const name of templates) if (!known.includes(name)) throw new UsageError(`unknown built-in template ${name}; use nygard, madr or y-statement`);
2501
+ const root = log.layout.root;
2502
+ const notes = [...log.notes];
2503
+ const files = [];
2504
+ const kept = [];
2505
+ let config = log.configPath;
2506
+ if (config !== null) kept.push(config);
2507
+ else {
2508
+ const template = templates[0] ?? "nygard";
2509
+ const repo = await readToolConfig(log.tree, CONFIG);
2510
+ if (root === DEFAULT_ROOT) {
2511
+ config = `${root}/${CONFIG}`;
2512
+ files.push([config, configText({
2513
+ root: null,
2514
+ template
2515
+ })]);
2516
+ } else if (repo === null) {
2517
+ config = CONFIG;
2518
+ files.push([config, configText({
2519
+ root,
2520
+ template
2521
+ })]);
2522
+ } else {
2523
+ const section = repo.raw.log;
2524
+ const other = typeof section?.root === "string" ? section.root : DEFAULT_ROOT;
2525
+ config = `${root}/${CONFIG}`;
2526
+ files.push([config, configText({
2527
+ root: null,
2528
+ template
2529
+ })]);
2530
+ notes.push(`${CONFIG} names ${other}; pass --root ${root} to use this log`);
2531
+ }
2532
+ }
2533
+ for (const name of templates) {
2534
+ const path = `${root}/templates/${name}.md`;
2535
+ if (await log.tree.read(path) !== null) kept.push(path);
2536
+ else files.push([path, BUILTIN_TEMPLATES[name] ?? ""]);
2537
+ }
2538
+ const commitRequested = o.commit ?? log.adr.autoCommit;
2539
+ const at = indexPath(log.layout);
2540
+ let indexed = false;
2541
+ if (log.adr.index === "committed") {
2542
+ if (await log.tree.read(at) !== null) kept.push(at);
2543
+ else {
2544
+ const head = await log.git.resolveCommit("HEAD");
2545
+ const added = Object.fromEntries(files.map(([p, t]) => [p, encoder.encode(t)]));
2546
+ const base = commitRequested ? await headTree(log.git, head) : log.tree;
2547
+ const text = await renderDecisionIndex(log.git, {
2548
+ layout: log.layout,
2549
+ adr: log.adr,
2550
+ tree: overlayTree(base, added),
2551
+ history: head
2552
+ });
2553
+ if (text === null) notes.push(`${at} not updated: numbers need history in this shallow clone; run git fetch --unshallow, then adr index`);
2554
+ else {
2555
+ files.push([at, text]);
2556
+ indexed = true;
2557
+ }
2558
+ }
2559
+ }
2560
+ const commit = files.length > 0 && commitRequested;
2561
+ if (commit && (await log.git.identity() === null || await log.git.committerIdentity() === null)) throw new ConfigError("--commit needs a git identity; set git user.name and user.email");
2562
+ const written = [];
2563
+ try {
2564
+ for (const [path, text] of files) {
2565
+ await writeNewFile(log.git, path, encoder.encode(text));
2566
+ written.push(path);
2567
+ }
2568
+ } catch (err) {
2569
+ const top = log.git.root ?? "";
2570
+ for (const path of written) await rm(join(top, path), { force: true });
2571
+ throw err;
2572
+ }
2573
+ if (written.length > 0) await log.git.add(written);
2574
+ let sha = null;
2575
+ if (commit) {
2576
+ const message = o.message ?? renderCommitMessage(log.adr.commitSubject, {
2577
+ title: "the decision log",
2578
+ vars: {
2579
+ action: "set up",
2580
+ id: "",
2581
+ display: "",
2582
+ command: "init"
2583
+ }
2584
+ }).message;
2585
+ sha = await commitWithRecovery(log.git, {
2586
+ message,
2587
+ only: written,
2588
+ rerun: INIT_RERUN,
2589
+ after: indexed ? INDEX_AFTER(root) : null
2590
+ });
2591
+ if (indexed) try {
2592
+ const again = await renderDecisionIndex(log.git, {
2593
+ layout: log.layout,
2594
+ adr: log.adr,
2595
+ tree: worktreeReader(log.git),
2596
+ history: sha
2597
+ });
2598
+ if (again !== null) {
2599
+ if ((await writeIndexCopy(log.git, at, again)).stage) await log.git.add([at]);
2600
+ }
2601
+ } catch (err) {
2602
+ notes.push(`${at} may be stale: ${firstLine(err)}; run adr index`);
2603
+ }
2604
+ }
2605
+ return {
2606
+ format: "adr-init/v1",
2607
+ root,
2608
+ config,
2609
+ written,
2610
+ kept,
2611
+ commit: sha,
2612
+ notes
2613
+ };
2614
+ }
2615
+ // src/views.ts
2616
+ /** The `numbering` key of `adr-list/v1`, `adr-show/v1` and `adr-for/v1`. */
2617
+ const numberingJson = (read) => ({
2618
+ available: read.numbering.available,
2619
+ reason: read.numbering.reason,
2620
+ ref: read.numbering.ref
2621
+ });
2622
+ /** Numbered decisions by number, then unnumbered ones by `proposed_at`, then id. */
2623
+ function compareViews(a, b) {
2624
+ if (a.number !== null || b.number !== null) {
2625
+ if (a.number === null) return 1;
2626
+ if (b.number === null) return -1;
2627
+ return a.number - b.number;
2628
+ }
2629
+ if (a.proposed_at !== b.proposed_at) return a.proposed_at < b.proposed_at ? -1 : 1;
2630
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
2631
+ }
2632
+ /**
2633
+ * The `adr-list/v1` document: decisions in list order, filtered by `status` and by `since` (only
2634
+ * the decisions with event files added since its revision, from `changedSince`), and truncated to
2635
+ * `limit` after ordering. `findings` are those of the listed decisions and of the log as a whole.
2636
+ */
2637
+ function listDecisions(read, o = {}) {
2638
+ const statuses = o.status === void 0 || o.status.length === 0 ? null : new Set(o.status);
2639
+ const since = o.since ?? null;
2640
+ const changed = since === null ? null : new Set(since.events.map((e) => e.subject));
2641
+ const decisions = [...read.projection.views.values()].filter((v) => statuses === null || statuses.has(v.status)).filter((v) => changed === null || changed.has(v.id)).sort(compareViews).slice(0, o.limit ?? Number.POSITIVE_INFINITY);
2642
+ const listed = new Set(decisions.map((v) => v.id));
2643
+ return {
2644
+ format: "adr-list/v1",
2645
+ root: read.root,
2646
+ ref: read.ref,
2647
+ commit: read.commit,
2648
+ numbering: numberingJson(read),
2649
+ decisions,
2650
+ since,
2651
+ unreadable: read.projection.unreadable,
2652
+ unfoldable: read.projection.unfoldable,
2653
+ findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
2654
+ notes: read.notes
2655
+ };
2656
+ }
2657
+ const decoder = new TextDecoder();
2658
+ /**
2659
+ * The `adr-show/v1` document of the decision `id` (resolve it with `resolveDecision` first).
2660
+ * Throws `UsageError` for an unknown, unreadable or unfoldable decision.
2661
+ */
2662
+ async function showDecision(read, id, o = {}) {
2663
+ const view = read.projection.views.get(id);
2664
+ if (view === void 0) {
2665
+ if (read.projection.unreadable.includes(id)) throw new UsageError(`decision ${id} uses a newer format; upgrade adr`);
2666
+ if (read.projection.unfoldable.includes(id)) throw new UsageError(`decision ${id} can't be read; run adr verify`);
2667
+ throw new UsageError(`unknown decision ${id}`);
2668
+ }
2669
+ const loaded = read.folded.subjects.get(id)?.loaded;
2670
+ const body = loaded?.body ?? null;
2671
+ const events = o.history === true ? (loaded?.events ?? []).flatMap((e) => e.event === null ? [] : [{
2672
+ path: e.path,
2673
+ event: e.event
2674
+ }]).sort((a, b) => compareEvents({
2675
+ ...a.event,
2676
+ path: a.path
2677
+ }, {
2678
+ ...b.event,
2679
+ path: b.path
2680
+ })).map((e) => e.event) : null;
2681
+ return {
2682
+ format: "adr-show/v1",
2683
+ root: read.root,
2684
+ ref: read.ref,
2685
+ commit: read.commit,
2686
+ numbering: numberingJson(read),
2687
+ header: stateHeader(view, read),
2688
+ decision: view,
2689
+ body: body === null ? null : decoder.decode(body.bytes),
2690
+ events,
2691
+ findings: read.findings.filter((f) => f.subject === id),
2692
+ notes: read.notes
2693
+ };
2694
+ }
2695
+ const day = (at) => (at ?? "").slice(0, 10);
2696
+ /** The reason on the applied event that produced the decision's current status, if any. */
2697
+ function statusReason(read, id) {
2698
+ const reason = (read.folded.subjects.get(id)?.applied ?? []).findLast((a) => a.from !== a.to)?.event.reason;
2699
+ return typeof reason === "string" && reason.trim() !== "" ? reason.trim() : null;
2700
+ }
2701
+ /**
2702
+ * The state header (01:258–259): `<display> (<id>) · <status>`, and for a decision that isn't in
2703
+ * force a warning saying what to read instead. `refer` names where to read another decision. CLI
2704
+ * default: `display(id)`. Phase 3 MCP passes `id => "adr://decision/" + display(id)`, e.g.
2705
+ * `adr://decision/ADR-0012`, or the id when unnumbered.
2706
+ */
2707
+ function stateHeader(view, read, o = {}) {
2708
+ const display = (id) => read.projection.views.get(id)?.display ?? id;
2709
+ const refer = o.refer ?? display;
2710
+ const line = view.number === null ? `${view.id} · ${view.status}` : `${view.display} (${view.id}) · ${view.status}`;
2711
+ const parts = [];
2712
+ const reason = statusReason(read, view.id);
2713
+ const when = day(view.status_at);
2714
+ switch (view.status) {
2715
+ case "superseded": {
2716
+ const target = view.effective ?? view.superseded_by ?? view.id;
2717
+ const accepted = read.projection.views.get(target)?.decided_at ?? null;
2718
+ const date = accepted === null ? "" : ` (accepted ${day(accepted)})`;
2719
+ parts.push(`SUPERSEDED by ${display(target)}${date}. Do not apply this decision; read ${refer(target)} instead.`);
2720
+ if (view.chain.length > 2) parts.push(`Chain: ${view.chain.map(display).join(" → ")}.`);
2721
+ break;
2722
+ }
2723
+ case "deprecated":
2724
+ parts.push(`DEPRECATED on ${when}: ${reason ?? "no reason given"}. No decision replaces it.`);
2725
+ break;
2726
+ case "rejected":
2727
+ parts.push(`REJECTED on ${when}: ${reason ?? "no reason given"}. This decision was never in force.`);
2728
+ break;
2729
+ case "withdrawn": parts.push(`WITHDRAWN on ${when}${reason === null ? "" : `: ${reason}`}. This decision was never in force.`);
2730
+ }
2731
+ if (view.body_intact === false) parts.push("WARNING: the body changed after acceptance; run adr verify.");
2732
+ return {
2733
+ line,
2734
+ warning: parts.length === 0 ? null : parts.join(" ")
2735
+ };
2736
+ }
2737
+ // src/scope.ts
2738
+ const MAX_PATHS = 100;
2739
+ const MAX_PATH_LENGTH = 4096;
2740
+ /** Statuses whose globs govern code; rejected and withdrawn decisions were never in force. */
2741
+ const GLOBS_APPLY = /* @__PURE__ */ new Set([
2742
+ "proposed",
2743
+ "accepted",
2744
+ "deprecated",
2745
+ "superseded"
2746
+ ]);
2747
+ const STATUS_ORDER = [
2748
+ "accepted",
2749
+ "proposed",
2750
+ "deprecated",
2751
+ "superseded",
2752
+ "rejected",
2753
+ "withdrawn"
2754
+ ];
2755
+ /** Accepted first (design §6.9 step 5); within a status by number, then by id. */
2756
+ function compareFor(a, b) {
2757
+ const s = STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status);
2758
+ if (s !== 0) return s;
2759
+ if (a.number !== null || b.number !== null) {
2760
+ if (a.number === null) return 1;
2761
+ if (b.number === null) return -1;
2762
+ return a.number - b.number;
2763
+ }
2764
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
2765
+ }
2766
+ /** `path` (relative to `cwd`) as a repo-relative POSIX path; `UsageError` outside the work tree. */
2767
+ async function repoPath(root, cwd, path) {
2768
+ if (path.length > MAX_PATH_LENGTH) throw new UsageError(`a path can be at most ${MAX_PATH_LENGTH} characters`);
2769
+ const base = await realpath(cwd).catch(() => cwd);
2770
+ const rel = relative(root, resolve(base, path));
2771
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new UsageError(`${path} is outside the repository`);
2772
+ return rel.split(sep).join("/");
2773
+ }
2774
+ /**
2775
+ * Which decisions govern each path (design §6.9): globs of decisions that were ever in force, and
2776
+ * comment anchors of any decision, read from the work tree or, with a `--ref` log, that commit.
2777
+ * Paths are relative to `o.cwd` (default the repository root); directories and paths outside the
2778
+ * repository are `UsageError`s. Paths need not be tracked.
2779
+ */
2780
+ async function decisionsFor(read, log, o) {
2781
+ const root = log.git.root;
2782
+ if (root === null) throw new UsageError("adr for needs a work tree");
2783
+ if (o.paths.length === 0 || o.paths.length > MAX_PATHS) throw new UsageError(`adr for takes 1 to ${MAX_PATHS} paths`);
2784
+ let index = null;
2785
+ const indexed = () => {
2786
+ index ??= indexReader(log.git);
2787
+ return index;
2788
+ };
2789
+ const hasUnder = async (tree, path) => (await tree.list(path)).some((e) => e.path !== path);
2790
+ const paths = [];
2791
+ for (const given of o.paths) {
2792
+ const path = await repoPath(root, o.cwd ?? root, given);
2793
+ let directory = path === "";
2794
+ if (!directory && read.ref === null) {
2795
+ const st = await lstat(join(root, path)).catch(() => null);
2796
+ directory = st === null ? await hasUnder(await indexed(), path) : st.isDirectory();
2797
+ } else if (!directory) directory = await hasUnder(read.tree, path);
2798
+ if (directory) throw new UsageError(`${given} is a directory; adr for takes file paths, for example adr for $(git ls-files ${given})`);
2799
+ if (!paths.includes(path)) paths.push(path);
2800
+ }
2801
+ const atRef = read.ref !== null && read.commit !== null;
2802
+ const trackedIn = atRef ? read.tree : await indexed();
2803
+ const tracked = /* @__PURE__ */ new Set();
2804
+ for (const p of paths) if ((await trackedIn.list(p)).some((e) => e.path === p)) tracked.add(p);
2805
+ const anchors = await scanAnchors(log.git, {
2806
+ keyword: read.adr.anchorKeyword,
2807
+ tree: atRef ? { commit: read.commit } : "worktree",
2808
+ paths,
2809
+ excludeDirs: [read.layout.root],
2810
+ exclude: read.adr.anchorExclude
2811
+ });
2812
+ const views = read.projection.views;
2813
+ const lookup = scopeLookup({
2814
+ subjects: [...views.values()].filter((v) => GLOBS_APPLY.has(v.status)).map((v) => ({
2815
+ id: v.id,
2816
+ globs: v.applies_to
2817
+ })),
2818
+ anchors
2819
+ });
2820
+ const listed = /* @__PURE__ */ new Set();
2821
+ const out = paths.map((path) => {
2822
+ const decisions = [];
2823
+ const unresolved = [];
2824
+ for (const m of lookup(path)) {
2825
+ const view = m.source === null ? views.get(m.id) : void 0;
2826
+ if (view === void 0) {
2827
+ for (const v of m.via) {
2828
+ if (v.type !== "anchor") continue;
2829
+ unresolved.push({
2830
+ ref: m.source === null ? m.id : `${m.source}:${m.id}`,
2831
+ line: v.line,
2832
+ reason: m.source === null ? "not-found" : "other-log"
2833
+ });
2834
+ }
2835
+ continue;
2836
+ }
2837
+ decisions.push({
2838
+ view,
2839
+ via: m.via
2840
+ });
2841
+ listed.add(view.id);
2842
+ }
2843
+ decisions.sort((a, b) => compareFor(a.view, b.view));
2844
+ unresolved.sort((a, b) => a.line - b.line);
2845
+ return {
2846
+ path,
2847
+ tracked: tracked.has(path),
2848
+ decisions: decisions.map(({ view: v, via }) => ({
2849
+ id: v.id,
2850
+ display: v.display,
2851
+ title: v.title,
2852
+ status: v.status,
2853
+ effective: v.effective,
2854
+ warning: stateHeader(v, read).warning,
2855
+ via
2856
+ })),
2857
+ unresolved
2858
+ };
2859
+ });
2860
+ return {
2861
+ format: "adr-for/v1",
2862
+ root: read.root,
2863
+ ref: read.ref,
2864
+ commit: read.commit,
2865
+ numbering: numberingJson(read),
2866
+ paths: out,
2867
+ findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
2868
+ notes: read.notes
2869
+ };
2870
+ }
2871
+ export { openDecisionLog as A, adrChecks as C, runDecisionIndex as D, renderDecisionIndex as E, parseAdrConfig as F, readDecisionsFrom as M, resolveDecision as N, changedSince as O, DEFAULT_ADR_CONFIG as P, withdrawDecision as S, indexDecisionLog as T, proposeDecision as _, initDecisionLog as a, scopeToCopy as b, installDecisionHooks as c, uninstallDecisionHooks as d, acceptDecision as f, noteDecision as g, linkDecision as h, stateHeader as i, readDecisions as j, numberingNote as k, printDecisionHooks as l, followScopeRenames as m, listDecisions as n, addHookTrailers as o, deprecateDecision as p, showDecision as r, decisionHookCommands as s, decisionsFor as t, runPreCommitHook as u, rejectDecision as v, verifyDecisions as w, supersedeDecision as x, scopeDecision as y };
2872
+
2873
+ //# sourceMappingURL=scope-CLqH-q1y.js.map