@nikovirtala/adr 0.2.0 → 0.4.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 (45) hide show
  1. package/README.md +77 -13
  2. package/dist/cli.js +348 -46
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.js +5 -3
  5. package/dist/init-YgW7g9Fx.js +588 -0
  6. package/dist/init-YgW7g9Fx.js.map +1 -0
  7. package/dist/{kind-BcHjCHvi.js → kind-DjvA3dyA.js} +2 -6
  8. package/dist/{kind-BcHjCHvi.js.map → kind-DjvA3dyA.js.map} +1 -1
  9. package/dist/kind.js +1 -1
  10. package/dist/list-Dt4KGxvj.js +58 -0
  11. package/dist/list-Dt4KGxvj.js.map +1 -0
  12. package/dist/{write-9IaqHRrY.js → search-CwIPlNGd.js} +1339 -560
  13. package/dist/search-CwIPlNGd.js.map +1 -0
  14. package/dist/server-CXCO5zfp.js +938 -0
  15. package/dist/server-CXCO5zfp.js.map +1 -0
  16. package/dist/types/cli/hooks.d.ts +6 -0
  17. package/dist/types/cli/index-command.d.ts +3 -0
  18. package/dist/types/cli/init.d.ts +5 -2
  19. package/dist/types/cli/list.d.ts +1 -1
  20. package/dist/types/cli/mcp.d.ts +6 -0
  21. package/dist/types/cli/open.d.ts +2 -0
  22. package/dist/types/cli/search.d.ts +3 -0
  23. package/dist/types/cli/text.d.ts +12 -0
  24. package/dist/types/commit-actions.d.ts +74 -0
  25. package/dist/types/config.d.ts +7 -1
  26. package/dist/types/hooks.d.ts +59 -0
  27. package/dist/types/index.d.ts +7 -2
  28. package/dist/types/init.d.ts +23 -2
  29. package/dist/types/log-index.d.ts +117 -0
  30. package/dist/types/log.d.ts +44 -1
  31. package/dist/types/mcp/policy.d.ts +33 -0
  32. package/dist/types/mcp/prompt.d.ts +7 -0
  33. package/dist/types/mcp/resources.d.ts +16 -0
  34. package/dist/types/mcp/server.d.ts +16 -0
  35. package/dist/types/mcp/text.d.ts +42 -0
  36. package/dist/types/mcp/tools.d.ts +27 -0
  37. package/dist/types/scope.d.ts +3 -1
  38. package/dist/types/search.d.ts +61 -0
  39. package/dist/types/skill.d.ts +5 -0
  40. package/dist/types/templates/list.d.ts +31 -0
  41. package/dist/types/verify.d.ts +6 -4
  42. package/dist/types/views.d.ts +9 -3
  43. package/dist/types/write.d.ts +65 -9
  44. package/package.json +5 -2
  45. package/dist/write-9IaqHRrY.js.map +0 -1
@@ -1,30 +1,87 @@
1
- import { t as adrKind } from "./kind-BcHjCHvi.js";
2
- import { ConfigError, ENVELOPE_VERSION, EventExistsError, FileExistsError, InvalidArgumentError, UsageError, applySetOp, bodyFileName, bodySha256, classifyPath, compareEvents, compileKind, eventDir, eventPath, finding, foldChecks, foldLog, followRenames, gitTemplates, globMatcher, historyTemplates, indexReader, invariantsCheck, isSubjectId, lintDocument, lintTemplate, loadFindings, loadLog, newSubjectId, nextEventIdentity, numberLog, openToolLog, overlayTree, parseCommitTemplate, parseEventFileName, parseGlob, parseTemplate, projectLog, readCommitTemplate, readToolConfig, refReader, relateCheck, renderCommitMessage, renderTemplate, resolveSubjectInput, scanAnchors, scopeLookup, serializeEvent, slugify, sortFindings, stagedRenames, templateHistoryRef, transitions, verifyLog, writeEvents, writeNewFile } from "@nikovirtala/ledger";
3
- import { lstat, readFile, realpath, rm } from "node:fs/promises";
4
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
5
- // src/config.ts
1
+ import { t as adrKind } from "./kind-DjvA3dyA.js";
2
+ import { CommitFailedError, ConfigError, ENVELOPE_VERSION, EventExistsError, FileExistsError, InvalidArgumentError, MemoryTree, UsageError, applySetOp, bodyFileName, bodySha256, classifyPath, commitWithRecovery, compareEvents, compileKind, diffTrees, escapeMarkdown, eventDir, eventPath, extractSections, finding, foldLog, followRenames, globMatcher, historyTemplates, indexCheck, indexReader, invariantsCheck, isSubjectId, lintDocument, lintTemplate, loadFindings, loadLog, newSubjectId, nextEventIdentity, numberLog, openToolLog, overlayTree, parseCommitTemplate, parseEventFileName, parseGlob, parseTemplate, projectLog, projectNumbering, readCommitTemplate, readIndexNumbers, refReader, relateCheck, renderCommitMessage, renderIndex, renderTemplate, replaceFile, replaceSections, resolveSubjectInput, saveCommitRecovery, scanAnchors, scopeLookup, serializeEvent, slugify, sortFindings, stagedRenames, templateHistoryRef, transitions, verifyLog, worktreeReader, writeEvents, writeIndexFile, writeNewFile } from "@nikovirtala/ledger";
3
+ import { 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
+ /** `{action}` of a draft update (`adr_update_draft`), whose title slot holds the display. */
49
+ const DRAFT_ACTION = "update the draft of";
50
+ /** The longest `{action}`, which `[commit] subject` must leave room for. */
51
+ const LONGEST_ACTION = Math.max(...Object.values(ACTIONS).map((a) => a.action.length), 10, 19);
52
+ /** What a link's title slot names after the display when the target is in another log. */
53
+ const CROSS_LOG_LINK = "to a decision in another log";
6
54
  /** adr's commit-subject placeholders, each with its longest value (design §4.2). */
7
55
  const COMMIT_VARS = Object.freeze({
8
- action: 24,
56
+ action: LONGEST_ACTION,
9
57
  id: 8,
10
58
  display: 13,
11
59
  command: 9
12
60
  });
13
- /** The default `[commit] subject`. */
14
- const DEFAULT_COMMIT_SUBJECT = "docs(adr): {action} {title}";
15
61
  /**
16
62
  * The names the title placeholder holds instead of a title, which are never shortened, each with
17
63
  * its action: supersede's `<display> with <display>` and a link's `<display> to <display>` or
18
- * `<display> to a decision in another log` (`planLink` in write.ts). The shorter names (a display,
19
- * at most 13 characters) always fit in the room a title gets.
64
+ * `<display> to a decision in another log` (`planLink` in write.ts), and `adr index`'s
65
+ * `the decision index`. The shorter names (a display, at most 13 characters) always fit in the
66
+ * room a title gets.
20
67
  */
21
- const COMMIT_NAMES = Object.freeze([{
22
- length: COMMIT_VARS.display * 2 + 6,
23
- vars: { action: 9 }
24
- }, {
25
- length: COMMIT_VARS.display + 29,
26
- vars: { action: 4 }
27
- }]);
68
+ const COMMIT_NAMES = Object.freeze([
69
+ {
70
+ length: 32,
71
+ vars: { action: ACTIONS.superseded.action.length }
72
+ },
73
+ {
74
+ length: 42,
75
+ vars: { action: ACTIONS.linked.action.length }
76
+ },
77
+ {
78
+ length: 18,
79
+ vars: { action: 10 }
80
+ }
81
+ ]);
82
+ // src/config.ts
83
+ /** The default `[commit] subject`. */
84
+ const DEFAULT_COMMIT_SUBJECT = "docs(adr): {action} {title}";
28
85
  const DEFAULT_ADR_CONFIG = Object.freeze({
29
86
  defaultTemplate: "nygard",
30
87
  mainBranch: null,
@@ -33,6 +90,9 @@ const DEFAULT_ADR_CONFIG = Object.freeze({
33
90
  autoCommit: false,
34
91
  mcpCanDecide: false,
35
92
  failOn: "error",
93
+ governedByInactive: "warning",
94
+ scopeFollowRenames: "stage",
95
+ hookIndex: "check",
36
96
  commitSubject: parseCommitTemplate(DEFAULT_COMMIT_SUBJECT, {
37
97
  vars: COMMIT_VARS,
38
98
  where: "commit.subject",
@@ -66,6 +126,7 @@ function parseAdrConfig(config, file) {
66
126
  const policy = table("policy");
67
127
  const verify = table("verify");
68
128
  const scope = table("scope");
129
+ const hooks = table("hooks");
69
130
  const bool = (t, section, key, fallback) => {
70
131
  const v = t[key];
71
132
  if (v === void 0) return fallback;
@@ -101,6 +162,13 @@ function parseAdrConfig(config, file) {
101
162
  autoCommit: bool(policy, "policy", "auto_commit", false),
102
163
  mcpCanDecide: bool(policy, "policy", "mcp_can_decide", false),
103
164
  failOn: oneOf(verify, "verify", "fail_on", ["error", "warning"], "error"),
165
+ governedByInactive: oneOf(verify, "verify", "governed_by_inactive", ["warning", "error"], "warning"),
166
+ scopeFollowRenames: oneOf(hooks, "hooks", "scope_follow_renames", [
167
+ "stage",
168
+ "warn",
169
+ "off"
170
+ ], "stage"),
171
+ hookIndex: oneOf(hooks, "hooks", "index", ["check", "stage"], "check"),
104
172
  commitSubject: readCommitTemplate(config, {
105
173
  file: where,
106
174
  vars: COMMIT_VARS,
@@ -111,221 +179,6 @@ function parseAdrConfig(config, file) {
111
179
  anchorExclude: Array.isArray(exclude) ? exclude : []
112
180
  };
113
181
  }
114
- /** The built-in templates by name: the file text `adr new` materializes. */
115
- const BUILTIN_TEMPLATES = Object.freeze({
116
- nygard: `---
117
- template: nygard
118
- version: 1
119
- sections:
120
- - id: context
121
- heading: Context
122
- required: true
123
- guidance: What is the issue that motivates this decision? Describe the forces at play.
124
- - id: decision
125
- heading: Decision
126
- required: true
127
- guidance: What is the change that we're proposing or doing? Write it in the active voice.
128
- - id: scope
129
- heading: Scope
130
- required: true
131
- guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
132
- - id: consequences
133
- heading: Consequences
134
- required: true
135
- guidance: What becomes easier or harder because of this change? Include the risks.
136
- ---
137
- # {{title}}
138
-
139
- ## Context
140
- {{context}}
141
-
142
- ## Decision
143
- {{decision}}
144
-
145
- ## Scope
146
- {{scope}}
147
-
148
- ## Consequences
149
- {{consequences}}
150
- `,
151
- madr: `---
152
- template: madr
153
- version: 1
154
- sections:
155
- - id: context
156
- heading: Context and Problem Statement
157
- required: true
158
- guidance: Describe the context and the problem in two or three sentences, or as a question.
159
- - id: drivers
160
- heading: Decision Drivers
161
- required: false
162
- guidance: List the forces and concerns that shape the decision.
163
- - id: options
164
- heading: Considered Options
165
- required: true
166
- guidance: List the options that were considered, one per line.
167
- - id: decision
168
- heading: Decision Outcome
169
- required: true
170
- guidance: Name the chosen option and say why it was chosen.
171
- - id: scope
172
- heading: Scope
173
- required: true
174
- guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
175
- - id: consequences
176
- heading: Consequences
177
- required: true
178
- guidance: Describe the good and the bad consequences of the chosen option.
179
- ---
180
- # {{title}}
181
-
182
- ## Context and Problem Statement
183
- {{context}}
184
-
185
- ## Decision Drivers
186
- {{drivers}}
187
-
188
- ## Considered Options
189
- {{options}}
190
-
191
- ## Decision Outcome
192
- {{decision}}
193
-
194
- ## Scope
195
- {{scope}}
196
-
197
- ## Consequences
198
- {{consequences}}
199
- `,
200
- "y-statement": `---
201
- template: y-statement
202
- version: 1
203
- sections:
204
- - id: statement
205
- heading: Statement
206
- required: true
207
- guidance: In the context of <use case>, facing <concern>, we decided for <option>, to achieve <quality>, accepting <downside>.
208
- - id: scope
209
- heading: Scope
210
- required: true
211
- guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
212
- ---
213
- # {{title}}
214
-
215
- ## Statement
216
- {{statement}}
217
-
218
- ## Scope
219
- {{scope}}
220
- `
221
- });
222
- // src/init.ts
223
- const CONFIG = "adr.toml";
224
- const DEFAULT_ROOT = adrKind.def.defaultRoot;
225
- const encoder$1 = new TextEncoder();
226
- /** The starter config; `root` only for a repo-level file naming a log elsewhere. */
227
- function configText(o) {
228
- return [
229
- "# adr configuration. Every key is optional: https://github.com/nikovirtala/ark/tree/main/packages/adr#config-adrtoml",
230
- "[log]",
231
- ...o.root === null ? [] : [`root = ${JSON.stringify(o.root)}`],
232
- `default_template = ${JSON.stringify(o.template)}`,
233
- "",
234
- "[commit]",
235
- "# subject = \"docs(adr): {action} {title}\"",
236
- ""
237
- ].join("\n");
238
- }
239
- /**
240
- * `adr init` (design §6.2): writes a config for the log, unless one is found, and each built-in
241
- * template in `o.templates` (default `nygard`) to `<root>/templates/`, never overwriting a file.
242
- * A `--root` other than `docs/adr` gets a repo-level `adr.toml` naming it, so later commands find
243
- * the log without `--root`; when that file exists for another log, the config goes in the root.
244
- * With `commit` (default `[policy] auto_commit`) the written files are committed on their own;
245
- * nothing written means nothing committed. No events are written.
246
- */
247
- async function initDecisionLog(log, o = {}) {
248
- if (log.ref !== null || log.tree.label !== "worktree") throw new InvalidArgumentError("init writes the work tree; open the log without a ref");
249
- const known = Object.keys(BUILTIN_TEMPLATES);
250
- const templates = [...new Set(o.templates === void 0 ? ["nygard"] : o.templates)];
251
- for (const name of templates) if (!known.includes(name)) throw new UsageError(`unknown built-in template ${name}; use nygard, madr or y-statement`);
252
- const root = log.layout.root;
253
- const notes = [...log.notes];
254
- const files = [];
255
- const kept = [];
256
- let config = log.configPath;
257
- if (config !== null) kept.push(config);
258
- else {
259
- const template = templates[0] ?? "nygard";
260
- const repo = await readToolConfig(log.tree, CONFIG);
261
- if (root === DEFAULT_ROOT) {
262
- config = `${root}/${CONFIG}`;
263
- files.push([config, configText({
264
- root: null,
265
- template
266
- })]);
267
- } else if (repo === null) {
268
- config = CONFIG;
269
- files.push([config, configText({
270
- root,
271
- template
272
- })]);
273
- } else {
274
- const section = repo.raw.log;
275
- const other = typeof section?.root === "string" ? section.root : DEFAULT_ROOT;
276
- config = `${root}/${CONFIG}`;
277
- files.push([config, configText({
278
- root: null,
279
- template
280
- })]);
281
- notes.push(`${CONFIG} names ${other}; pass --root ${root} to use this log`);
282
- }
283
- }
284
- for (const name of templates) {
285
- const path = `${root}/templates/${name}.md`;
286
- if (await log.tree.read(path) !== null) kept.push(path);
287
- else files.push([path, BUILTIN_TEMPLATES[name] ?? ""]);
288
- }
289
- const commit = files.length > 0 && (o.commit ?? log.adr.autoCommit);
290
- 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");
291
- const written = [];
292
- try {
293
- for (const [path, text] of files) {
294
- await writeNewFile(log.git, path, encoder$1.encode(text));
295
- written.push(path);
296
- }
297
- } catch (err) {
298
- const top = log.git.root ?? "";
299
- for (const path of written) await rm(join(top, path), { force: true });
300
- throw err;
301
- }
302
- if (written.length > 0) await log.git.add(written);
303
- let sha = null;
304
- if (commit) {
305
- const message = o.message ?? renderCommitMessage(log.adr.commitSubject, {
306
- title: "the decision log",
307
- vars: {
308
- action: "set up",
309
- id: "",
310
- display: "",
311
- command: "init"
312
- }
313
- }).message;
314
- sha = await log.git.commit({
315
- message,
316
- only: written
317
- });
318
- }
319
- return {
320
- format: "adr-init/v1",
321
- root,
322
- config,
323
- written,
324
- kept,
325
- commit: sha,
326
- notes
327
- };
328
- }
329
182
  // src/log.ts
330
183
  /**
331
184
  * Opens the decision log (design D3): `--root`, else `adr.toml`'s `[log] root`, else `docs/adr`;
@@ -342,20 +195,33 @@ async function openDecisionLog(o) {
342
195
  adr: parseAdrConfig(log.config, log.configPath)
343
196
  };
344
197
  }
345
- /** `numberLog` on the repository, then `readDecisionsFrom(log.tree, …)`. */
198
+ /**
199
+ * `numberLog` on the repository, then `readDecisionsFrom(log.tree, …)`. In a shallow clone of a
200
+ * log with `[log] index = "committed"`, the numbers come from `<root>/index.md` in the commit
201
+ * being read (`--ref`, else HEAD, resolved when the numbers are needed), never from the work tree's
202
+ * copy, which may number acceptances that exist only there (design 2b D1, §4.6).
203
+ */
346
204
  async function readDecisions(log) {
347
- const numbering = await numberLog(log.git, {
348
- kind: adrKind,
349
- layout: log.layout,
350
- mainRef: log.adr.mainBranch
351
- });
352
205
  return readDecisionsFrom(log.tree, {
353
206
  layout: log.layout,
354
207
  adr: log.adr,
355
- numbering,
208
+ numbering: await numberDecisions(log),
356
209
  ref: log.ref,
357
210
  commit: log.commit,
358
- notes: log.notes
211
+ notes: log.notes,
212
+ sources: (log.config?.sources ?? []).map((s) => s.name)
213
+ });
214
+ }
215
+ /** The display numbers `readDecisions` uses, with its shallow-clone fallback to `index.md`. */
216
+ async function numberDecisions(log) {
217
+ return numberLog(log.git, {
218
+ kind: adrKind,
219
+ layout: log.layout,
220
+ mainRef: log.adr.mainBranch,
221
+ ...log.adr.index === "committed" ? { fallback: async () => {
222
+ const c = await log.git.resolveCommit(log.ref ?? "HEAD");
223
+ return c === null ? null : readIndexNumbers(await refReader(log.git, c), `${log.layout.root}/index.md`);
224
+ } } : {}
359
225
  });
360
226
  }
361
227
  const compiled$1 = compileKind(adrKind);
@@ -402,9 +268,33 @@ async function readDecisionsFrom(tree, o) {
402
268
  ...f,
403
269
  kind: adrKind.name
404
270
  }))),
405
- notes: o.notes ?? []
271
+ notes: o.notes ?? [],
272
+ sources: o.sources ?? []
406
273
  };
407
274
  }
275
+ /**
276
+ * The note listing reads add while only the local log is read: the logs of `[[sources]]` aren't
277
+ * listed or searched until ark's step 4. `null` without sources.
278
+ */
279
+ function sourcesNote(read) {
280
+ if (read.sources.length === 0) return null;
281
+ return `only this repository's decision log is read; the logs in [[sources]] (${read.sources.join(", ")}) aren't listed or searched until ark's step 4`;
282
+ }
283
+ /** `read.notes`, then the sources note when there is one. */
284
+ function listingNotes(read) {
285
+ const note = sourcesNote(read);
286
+ return note === null ? read.notes : [...read.notes, note];
287
+ }
288
+ /**
289
+ * The note a read command adds about where numbers come from: the shallow-clone index, or why
290
+ * they are unavailable (`unavailable: false` leaves that one out); `null` for none.
291
+ */
292
+ function numberingNote(read, o) {
293
+ const n = read.numbering;
294
+ if (n.reason === "index") return `numbers come from ${read.root}/index.md (shallow clone)`;
295
+ if (n.available || !o.unavailable) return null;
296
+ 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`;
297
+ }
408
298
  /** Why numbers are unavailable, in words: `shallow clone` or `no main branch`. */
409
299
  function unavailableReason(n) {
410
300
  return n.reason === "shallow" ? "shallow clone" : "no main branch";
@@ -428,289 +318,467 @@ function resolveDecision(read, input) {
428
318
  case "not-found": throw new UsageError(`unknown decision ${input.trim()}`);
429
319
  }
430
320
  }
431
- // src/views.ts
432
- /** The `numbering` key of `adr-list/v1`, `adr-show/v1` and `adr-for/v1`. */
433
- const numberingJson = (read) => ({
434
- available: read.numbering.available,
435
- reason: read.numbering.reason,
436
- ref: read.numbering.ref
437
- });
438
- /** Numbered decisions by number, then unnumbered ones by `proposed_at`, then id. */
439
- function compareViews(a, b) {
440
- if (a.number !== null || b.number !== null) {
441
- if (a.number === null) return 1;
442
- if (b.number === null) return -1;
443
- return a.number - b.number;
444
- }
445
- if (a.proposed_at !== b.proposed_at) return a.proposed_at < b.proposed_at ? -1 : 1;
446
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
447
- }
448
321
  /**
449
- * The `adr-list/v1` document: decisions in list order, filtered by `status` and truncated to
450
- * `limit` after ordering. `findings` are those of the listed decisions and of the log as a whole.
322
+ * The event files in `--ref`'s tree (else HEAD's) that `rev`'s tree lacks (design 2b D7). Diffing
323
+ * trees answers "what did this pull bring" for fast-forwards and merges (`ORIG_HEAD` is the tip
324
+ * before them), and lists nothing for a rebase of your own work, whose events are in both trees.
325
+ * Throws `RefNotFoundError` when either revision doesn't resolve.
451
326
  */
452
- function listDecisions(read, o = {}) {
453
- const statuses = o.status === void 0 || o.status.length === 0 ? null : new Set(o.status);
454
- const decisions = [...read.projection.views.values()].filter((v) => statuses === null || statuses.has(v.status)).sort(compareViews).slice(0, o.limit ?? Number.POSITIVE_INFINITY);
455
- const listed = new Set(decisions.map((v) => v.id));
327
+ async function changedSince(log, rev) {
328
+ const base = await refReader(log.git, rev);
329
+ const head = await refReader(log.git, log.ref ?? "HEAD");
330
+ const events = (await diffTrees(base, head, `${log.layout.root}/${log.layout.events}`)).flatMap((c) => {
331
+ if (c.status !== "added") return [];
332
+ const p = classifyPath(log.layout, c.path);
333
+ if (p.area !== "event" || p.subject === null || p.file.includes("/")) return [];
334
+ const name = parseEventFileName(p.file);
335
+ return name === null ? [] : [{
336
+ subject: p.subject,
337
+ type: name.type,
338
+ path: c.path
339
+ }];
340
+ });
341
+ events.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
456
342
  return {
457
- format: "adr-list/v1",
458
- root: read.root,
459
- ref: read.ref,
460
- commit: read.commit,
461
- numbering: numberingJson(read),
462
- decisions,
463
- unreadable: read.projection.unreadable,
464
- unfoldable: read.projection.unfoldable,
465
- findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
466
- notes: read.notes
343
+ rev,
344
+ commit: base.commit ?? "",
345
+ events
467
346
  };
468
347
  }
469
- const decoder$1 = new TextDecoder();
348
+ // src/log-index.ts
470
349
  /**
471
- * The `adr-show/v1` document of the decision `id` (resolve it with `resolveDecision` first).
472
- * Throws `UsageError` for an unknown, unreadable or unfoldable decision.
350
+ * What the recovery hint says about rerunning `adr index --commit` after `git commit` failed
351
+ * (design 2b D5). It names the file, not the path, which the hint lists above it.
473
352
  */
474
- async function showDecision(read, id, o = {}) {
475
- const view = read.projection.views.get(id);
476
- if (view === void 0) {
477
- if (read.projection.unreadable.includes(id)) throw new UsageError(`decision ${id} uses a newer format; upgrade adr`);
478
- if (read.projection.unfoldable.includes(id)) throw new UsageError(`decision ${id} can't be read; run adr verify`);
479
- throw new UsageError(`unknown decision ${id}`);
353
+ 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.";
354
+ /**
355
+ * The step before the manual commit when a write's second index render failed after staging
356
+ * (design 2b §4.5 step 4).
357
+ */
358
+ 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.`;
359
+ /**
360
+ * The step after the manual commit when the commit carried the index: the commit's render leaves
361
+ * out other decisions' uncommitted entries (design 2b D5, §4.5 step 5).
362
+ */
363
+ const INDEX_AFTER = (root) => `Then run adr index --no-commit to restore other decisions' entries in ${root}/index.md.`;
364
+ /** The repo-relative path of a log's generated index. */
365
+ const indexPath = (layout) => `${layout.root}/index.md`;
366
+ const GROUPS = [
367
+ {
368
+ heading: "Accepted",
369
+ statuses: ["accepted"]
370
+ },
371
+ {
372
+ heading: "Proposed",
373
+ statuses: ["proposed"]
374
+ },
375
+ {
376
+ heading: "Superseded and deprecated",
377
+ statuses: ["superseded", "deprecated"]
378
+ },
379
+ {
380
+ heading: "Rejected and withdrawn",
381
+ statuses: ["rejected", "withdrawn"]
480
382
  }
481
- const loaded = read.folded.subjects.get(id)?.loaded;
482
- const body = loaded?.body ?? null;
483
- const events = o.history === true ? (loaded?.events ?? []).flatMap((e) => e.event === null ? [] : [{
484
- path: e.path,
485
- event: e.event
486
- }]).sort((a, b) => compareEvents({
487
- ...a.event,
488
- path: a.path
489
- }, {
490
- ...b.event,
491
- path: b.path
492
- })).map((e) => e.event) : null;
493
- return {
494
- format: "adr-show/v1",
495
- root: read.root,
496
- ref: read.ref,
497
- commit: read.commit,
498
- numbering: numberingJson(read),
499
- header: stateHeader(view, read),
500
- decision: view,
501
- body: body === null ? null : decoder$1.decode(body.bytes),
502
- events,
503
- findings: read.findings.filter((f) => f.subject === id),
504
- notes: read.notes
505
- };
383
+ ];
384
+ const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
385
+ /**
386
+ * adr's index spec (01:209: active decisions first), built per render so that a superseded
387
+ * decision's status can name its successor's display from `views`.
388
+ */
389
+ const DECISION_INDEX = (root, views) => ({
390
+ tool: "adr",
391
+ title: "Architecture decisions",
392
+ groups: GROUPS.map((g) => ({
393
+ heading: g.heading,
394
+ filter: (v) => g.statuses.includes(v.status)
395
+ })),
396
+ columns: [
397
+ {
398
+ heading: "Decision",
399
+ cell: (v) => escapeMarkdown(v.display)
400
+ },
401
+ {
402
+ heading: "Title",
403
+ cell: (v) => v.body_path === null ? escapeMarkdown(v.title) : `[${escapeMarkdown(v.title)}](${encodeURI(posix.relative(root, v.body_path))})`
404
+ },
405
+ {
406
+ heading: "Status",
407
+ cell: (v) => v.status === "superseded" && v.superseded_by !== null ? `superseded by ${escapeMarkdown(views.get(v.superseded_by)?.display ?? v.superseded_by)}` : v.status
408
+ },
409
+ {
410
+ heading: "Since",
411
+ cell: (v) => v.status_at.slice(0, 10)
412
+ }
413
+ ],
414
+ 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)
415
+ });
416
+ /**
417
+ * The index `tree` must hold: its decisions, numbered as they will be once the commit holding
418
+ * `tree` (which builds on `history`: HEAD, a commit, or null on an unborn HEAD) is on main
419
+ * (design 2b D2), with `[log] main_branch`'s numbers first. A tree that isn't a commit's (the work
420
+ * tree, the index) also merges `MERGE_HEAD` while a merge is in progress. In a shallow clone the
421
+ * numbers continue from those `history`'s index recorded; `null` when there are none, so the
422
+ * numbers can't be projected.
423
+ */
424
+ async function renderDecisionIndex(git, o) {
425
+ const { layout, history } = o;
426
+ const mergeHead = o.tree.commit === null && history !== null ? await git.resolveCommit("MERGE_HEAD") : null;
427
+ const numbering = await projectNumbering(git, {
428
+ kind: adrKind,
429
+ layout,
430
+ history,
431
+ tree: o.tree,
432
+ mainRef: o.adr.mainBranch,
433
+ merging: mergeHead === null ? [] : [mergeHead],
434
+ recorded: async () => history === null ? null : readIndexNumbers(await refReader(git, history), indexPath(layout))
435
+ });
436
+ if (!numbering.available) return null;
437
+ const views = (await readDecisionsFrom(o.tree, {
438
+ layout,
439
+ adr: o.adr,
440
+ numbering
441
+ })).projection.views;
442
+ return renderIndex(DECISION_INDEX(layout.root, views), [...views.values()], numbering);
506
443
  }
507
- const day = (at) => (at ?? "").slice(0, 10);
508
- /** The reason on the applied event that produced the decision's current status, if any. */
509
- function statusReason(read, id) {
510
- const reason = (read.folded.subjects.get(id)?.applied ?? []).findLast((a) => a.from !== a.to)?.event.reason;
511
- return typeof reason === "string" && reason.trim() !== "" ? reason.trim() : null;
444
+ /** At most this many paths are named in the unstaged-files `index/stale` message. */
445
+ const UNSTAGED_NAMED = 5;
446
+ /**
447
+ * What the unstaged-files `index/stale` finding and the matching write note both start with:
448
+ * `path` lists `files` (some of them), which aren't staged.
449
+ */
450
+ function unstagedIndexText(path, files) {
451
+ return `${path} lists decision files that aren't staged (${files.slice(0, UNSTAGED_NAMED).join(", ")}${files.length > UNSTAGED_NAMED ? ", …" : ""})`;
512
452
  }
513
453
  /**
514
- * The state header (01:258–259): `<display> (<id>) · <status>`, and for a decision that isn't in
515
- * force a warning saying what to read instead. `refer` names where to read another decision. CLI
516
- * default: `display(id)`. Phase 3 MCP passes `id => "adr://decision/" + display(id)`, e.g.
517
- * `adr://decision/ADR-0012`, or the id when unnumbered.
454
+ * The log's event and body files whose work-tree content isn't staged (untracked or modified),
455
+ * sorted: the cause of a staged `index.md` that only the work tree's render matches.
518
456
  */
519
- function stateHeader(view, read, o = {}) {
520
- const display = (id) => read.projection.views.get(id)?.display ?? id;
521
- const refer = o.refer ?? display;
522
- const line = view.number === null ? `${view.id} · ${view.status}` : `${view.display} (${view.id}) · ${view.status}`;
523
- const parts = [];
524
- const reason = statusReason(read, view.id);
525
- const when = day(view.status_at);
526
- switch (view.status) {
527
- case "superseded": {
528
- const target = view.effective ?? view.superseded_by ?? view.id;
529
- const accepted = read.projection.views.get(target)?.decided_at ?? null;
530
- const date = accepted === null ? "" : ` (accepted ${day(accepted)})`;
531
- parts.push(`SUPERSEDED by ${display(target)}${date}. Do not apply this decision; read ${refer(target)} instead.`);
532
- if (view.chain.length > 2) parts.push(`Chain: ${view.chain.map(display).join(" → ")}.`);
533
- break;
457
+ async function unstagedLogFiles(git, layout) {
458
+ const paths = [...await git.unstagedPaths([layout.root]), ...await git.untrackedPaths([layout.root])];
459
+ return [...new Set(paths)].filter((p) => {
460
+ const area = classifyPath(layout, p).area;
461
+ return area === "event" || area === "body";
462
+ }).sort();
463
+ }
464
+ /**
465
+ * The verify check that `<root>/index.md` is current (design 2b §4.5, "Freshness in verify"). In
466
+ * staged mode (the pre-commit hook), a stale staged copy that equals the work tree's render
467
+ * means another decision's files aren't staged: writes stage the work tree's render (D2), and
468
+ * `adr index` would change nothing. The finding then names those files and says to stage them.
469
+ */
470
+ function decisionIndexCheck(log) {
471
+ const path = indexPath(log.layout);
472
+ const check = indexCheck({
473
+ path,
474
+ fix: "run adr index",
475
+ async expected(ctx) {
476
+ const head = ctx.repo?.head;
477
+ const history = head === void 0 ? null : typeof head === "object" ? head.commit : await log.git.resolveCommit("HEAD");
478
+ return renderDecisionIndex(log.git, {
479
+ layout: log.layout,
480
+ adr: log.adr,
481
+ tree: ctx.head.tree,
482
+ history
483
+ });
534
484
  }
535
- case "deprecated":
536
- parts.push(`DEPRECATED on ${when}: ${reason ?? "no reason given"}. No decision replaces it.`);
537
- break;
538
- case "rejected":
539
- parts.push(`REJECTED on ${when}: ${reason ?? "no reason given"}. This decision was never in force.`);
540
- break;
541
- case "withdrawn": parts.push(`WITHDRAWN on ${when}${reason === null ? "" : `: ${reason}`}. This decision was never in force.`);
542
- }
543
- if (view.body_intact === false) parts.push("WARNING: the body changed after acceptance; run adr verify.");
485
+ });
544
486
  return {
545
- line,
546
- warning: parts.length === 0 ? null : parts.join(" ")
487
+ id: check.id,
488
+ async run(ctx) {
489
+ const findings = await check.run(ctx);
490
+ const stale = findings.find((f) => f.rule === "index/stale");
491
+ if (stale === void 0 || ctx.repo?.head !== "index") return findings;
492
+ const files = await unstagedLogFiles(log.git, log.layout);
493
+ if (files.length === 0) return findings;
494
+ const worktree = await renderDecisionIndex(log.git, {
495
+ layout: log.layout,
496
+ adr: log.adr,
497
+ tree: worktreeReader(log.git),
498
+ history: await log.git.resolveCommit("HEAD")
499
+ });
500
+ if (worktree === null || !holdsText(await ctx.head.tree.read(path), worktree)) return findings;
501
+ const message = `${unstagedIndexText(path, files)}; git add them and commit again, or move them out of the work tree and run adr index`;
502
+ return findings.map((f) => f === stale ? {
503
+ ...f,
504
+ message
505
+ } : f);
506
+ }
547
507
  };
548
508
  }
549
- // src/scope.ts
550
- const MAX_PATHS = 100;
551
- const MAX_PATH_LENGTH = 4096;
552
- /** Statuses whose globs govern code; rejected and withdrawn decisions were never in force. */
553
- const GLOBS_APPLY = /* @__PURE__ */ new Set([
554
- "proposed",
555
- "accepted",
556
- "deprecated",
557
- "superseded"
558
- ]);
559
- const STATUS_ORDER = [
560
- "accepted",
561
- "proposed",
562
- "deprecated",
563
- "superseded",
564
- "rejected",
565
- "withdrawn"
566
- ];
567
- /** Accepted first (design §6.9 step 5); within a status by number, then by id. */
568
- function compareFor(a, b) {
569
- const s = STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status);
570
- if (s !== 0) return s;
571
- if (a.number !== null || b.number !== null) {
572
- if (a.number === null) return 1;
573
- if (b.number === null) return -1;
574
- return a.number - b.number;
575
- }
576
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
509
+ const encoder$1 = new TextEncoder();
510
+ /** Whether `bytes` (null: absent) hold exactly `text`. */
511
+ function holdsText(bytes, text) {
512
+ if (bytes === null) return false;
513
+ const want = encoder$1.encode(text);
514
+ return bytes.byteLength === want.byteLength && bytes.every((x, i) => x === want[i]);
577
515
  }
578
- /** `path` (relative to `cwd`) as a repo-relative POSIX path; `UsageError` outside the work tree. */
579
- async function repoPath(root, cwd, path) {
580
- if (path.length > MAX_PATH_LENGTH) throw new UsageError(`a path can be at most ${MAX_PATH_LENGTH} characters`);
581
- const base = await realpath(cwd).catch(() => cwd);
582
- const rel = relative(root, resolve(base, path));
583
- if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new UsageError(`${path} is outside the repository`);
584
- return rel.split(sep).join("/");
516
+ /** HEAD's copy of `path`; `null` when it is absent or HEAD is unborn. */
517
+ async function headBytes(git, path) {
518
+ const head = await git.resolveCommit("HEAD");
519
+ return head === null ? null : (await refReader(git, head)).read(path);
520
+ }
521
+ /** HEAD's tree, or an empty tree on an unborn HEAD. */
522
+ async function headTree(git, head) {
523
+ return head === null ? new MemoryTree({}) : refReader(git, head);
585
524
  }
586
525
  /**
587
- * Which decisions govern each path (design §6.9): globs of decisions that were ever in force, and
588
- * comment anchors of any decision, read from the work tree or, with a `--ref` log, that commit.
589
- * Paths are relative to `o.cwd` (default the repository root); directories and paths outside the
590
- * repository are `UsageError`s. Paths need not be tracked.
526
+ * Brings the work-tree copy of `path` to `text` (atomically, when it differs) and reports whether
527
+ * the staged copy differs, so the caller stages it. `old` is the work-tree copy before the write
528
+ * (`null` when absent), for rollback.
591
529
  */
592
- async function decisionsFor(read, log, o) {
593
- const root = log.git.root;
594
- if (root === null) throw new UsageError("adr for needs a work tree");
595
- if (o.paths.length === 0 || o.paths.length > MAX_PATHS) throw new UsageError(`adr for takes 1 to ${MAX_PATHS} paths`);
596
- let index = null;
597
- const indexed = () => {
598
- index ??= indexReader(log.git);
599
- return index;
530
+ async function writeIndexCopy(git, path, text) {
531
+ const old = await worktreeReader(git).read(path);
532
+ return {
533
+ wrote: !holdsText(old, text) && await writeIndexFile(git, path, text),
534
+ old,
535
+ stage: !holdsText(await (await indexReader(git)).read(path), text)
600
536
  };
601
- const hasUnder = async (tree, path) => (await tree.list(path)).some((e) => e.path !== path);
602
- const paths = [];
603
- for (const given of o.paths) {
604
- const path = await repoPath(root, o.cwd ?? root, given);
605
- let directory = path === "";
606
- if (!directory && read.ref === null) {
607
- const st = await lstat(join(root, path)).catch(() => null);
608
- directory = st === null ? await hasUnder(await indexed(), path) : st.isDirectory();
609
- } else if (!directory) directory = await hasUnder(read.tree, path);
610
- if (directory) throw new UsageError(`${given} is a directory; adr for takes file paths, for example adr for $(git ls-files ${given})`);
611
- if (!paths.includes(path)) paths.push(path);
537
+ }
538
+ /** The first line of an error's message, for notes and headlines. */
539
+ const firstLine = (err) => (err instanceof Error ? err.message : String(err)).split("\n")[0] ?? "";
540
+ /**
541
+ * `adr index` (design 2b §4.5) with what the text output needs; `indexDecisionLog` is the
542
+ * library entry.
543
+ */
544
+ async function runDecisionIndex(log, o = {}) {
545
+ const path = indexPath(log.layout);
546
+ const root = log.layout.root;
547
+ const check = o.check === true;
548
+ const notes = [...log.notes, ...o.notes ?? []];
549
+ if (log.adr.index !== "committed") throw new UsageError("adr index needs [log] index = \"committed\" in adr.toml");
550
+ if (check) {
551
+ if (o.commit === true || o.message !== void 0 && o.message !== null) throw new UsageError("--check writes nothing; drop --commit and --message");
552
+ const findings = await verifyLog({
553
+ git: log.git,
554
+ kind: adrKind,
555
+ layout: log.layout,
556
+ mode: {
557
+ type: "full",
558
+ ...log.ref === null ? {} : { head: log.ref }
559
+ },
560
+ checks: [decisionIndexCheck(log)]
561
+ });
562
+ return {
563
+ doc: {
564
+ format: "adr-index/v1",
565
+ path,
566
+ check: true,
567
+ fresh: findings.length === 0,
568
+ written: [],
569
+ commit: null,
570
+ findings,
571
+ notes
572
+ },
573
+ refreshed: false
574
+ };
612
575
  }
613
- const atRef = read.ref !== null && read.commit !== null;
614
- const trackedIn = atRef ? read.tree : await indexed();
615
- const tracked = /* @__PURE__ */ new Set();
616
- for (const p of paths) if ((await trackedIn.list(p)).some((e) => e.path === p)) tracked.add(p);
617
- const anchors = await scanAnchors(log.git, {
618
- keyword: read.adr.anchorKeyword,
619
- tree: atRef ? { commit: read.commit } : "worktree",
620
- paths,
621
- excludeDirs: [read.layout.root],
622
- exclude: read.adr.anchorExclude
623
- });
624
- const views = read.projection.views;
625
- const lookup = scopeLookup({
626
- subjects: [...views.values()].filter((v) => GLOBS_APPLY.has(v.status)).map((v) => ({
627
- id: v.id,
628
- globs: v.applies_to
629
- })),
630
- anchors
576
+ if (log.ref !== null || log.tree.label !== "worktree") throw new UsageError("adr index writes the work tree; use --ref only with --check");
577
+ const { git } = log;
578
+ const commit = o.commit ?? log.adr.autoCommit;
579
+ 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");
580
+ const head = await git.resolveCommit("HEAD");
581
+ const render = (tree, history) => renderDecisionIndex(git, {
582
+ layout: log.layout,
583
+ adr: log.adr,
584
+ tree,
585
+ history
631
586
  });
632
- const listed = /* @__PURE__ */ new Set();
633
- const out = paths.map((path) => {
634
- const decisions = [];
635
- const unresolved = [];
636
- for (const m of lookup(path)) {
637
- const view = m.source === null ? views.get(m.id) : void 0;
638
- if (view === void 0) {
639
- for (const v of m.via) {
640
- if (v.type !== "anchor") continue;
641
- unresolved.push({
642
- ref: m.source === null ? m.id : `${m.source}:${m.id}`,
643
- line: v.line,
644
- reason: m.source === null ? "not-found" : "other-log"
645
- });
646
- }
647
- continue;
648
- }
649
- decisions.push({
650
- view,
651
- via: m.via
652
- });
653
- listed.add(view.id);
654
- }
655
- decisions.sort((a, b) => compareFor(a.view, b.view));
656
- unresolved.sort((a, b) => a.line - b.line);
587
+ const shallow = () => new UsageError("numbers need history in this shallow clone; run git fetch --unshallow");
588
+ let written = false;
589
+ const place = async (text) => {
590
+ const r = await writeIndexCopy(git, path, text);
591
+ if (r.stage) await git.add([path]);
592
+ written ||= r.wrote;
593
+ return r.wrote || r.stage;
594
+ };
595
+ if (!commit) {
596
+ const text = await render(worktreeReader(git), head);
597
+ if (text === null) throw shallow();
657
598
  return {
658
- path,
659
- tracked: tracked.has(path),
660
- decisions: decisions.map(({ view: v, via }) => ({
661
- id: v.id,
662
- display: v.display,
663
- title: v.title,
664
- status: v.status,
665
- effective: v.effective,
666
- warning: stateHeader(v, read).warning,
667
- via
668
- })),
669
- unresolved
599
+ doc: {
600
+ format: "adr-index/v1",
601
+ path,
602
+ check: false,
603
+ fresh: !await place(text),
604
+ written: written ? [path] : [],
605
+ commit: null,
606
+ findings: [],
607
+ notes
608
+ },
609
+ refreshed: false
670
610
  };
671
- });
611
+ }
612
+ const text = await render(await headTree(git, head), head);
613
+ if (text === null) throw shallow();
614
+ const fresh = holdsText(await headBytes(git, path), text);
615
+ let sha = null;
616
+ if (!fresh) {
617
+ await place(text);
618
+ const message = o.message ?? renderCommitMessage(log.adr.commitSubject, {
619
+ title: "the decision index",
620
+ vars: {
621
+ action: "regenerate",
622
+ id: "index.md",
623
+ display: "index.md",
624
+ command: "index"
625
+ }
626
+ }).message;
627
+ sha = await commitWithRecovery(git, {
628
+ message,
629
+ only: [path],
630
+ rerun: INDEX_RERUN,
631
+ after: INDEX_AFTER(root)
632
+ });
633
+ }
634
+ let refreshed = false;
635
+ try {
636
+ const again = await render(worktreeReader(git), sha ?? head);
637
+ if (again !== null) refreshed = await place(again);
638
+ } catch (err) {
639
+ notes.push(`${path} may be stale: ${firstLine(err)}; run adr index`);
640
+ }
672
641
  return {
673
- format: "adr-for/v1",
674
- root: read.root,
675
- ref: read.ref,
676
- commit: read.commit,
677
- numbering: numberingJson(read),
678
- paths: out,
679
- findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
680
- notes: read.notes
642
+ doc: {
643
+ format: "adr-index/v1",
644
+ path,
645
+ check: false,
646
+ fresh,
647
+ written: written ? [path] : [],
648
+ commit: sha,
649
+ findings: [],
650
+ notes
651
+ },
652
+ refreshed
681
653
  };
682
654
  }
683
- // src/verify.ts
684
655
  /**
685
- * Every phase-1 check for the log: ledger's mechanical and fold checks with adr's actor policy,
686
- * and template lint against the templates in the head tree and, for older versions, the history
687
- * of `historyRef`.
656
+ * `adr index [--check] [--commit | --no-commit] [-m <message>]` (design 2b §4.5): renders
657
+ * `<root>/index.md` and writes and stages it where the work tree or the index differ. With
658
+ * `commit` (default `[policy] auto_commit`) it renders HEAD's tree and commits the file alone when
659
+ * HEAD's copy differs, then refreshes the work tree's copy, which may include other decisions'
660
+ * uncommitted entries. `check` writes nothing and reports `index/*` findings.
688
661
  */
689
- function adrChecks(log, historyRef) {
690
- return foldChecks(adrKind, {
691
- options: { acceptRequiresHuman: log.adr.acceptRequiresHuman },
692
- templates: gitTemplates(log.git, { historyRef })
693
- });
694
- }
695
- /** Verifies the decision log in `mode` (staged, range or full) with `adrChecks`. */
696
- async function verifyDecisions(log, mode) {
697
- return verifyLog({
698
- git: log.git,
699
- kind: adrKind,
700
- layout: log.layout,
701
- mode,
702
- checks: adrChecks(log, await templateHistoryRef(log.git, mode))
703
- });
662
+ async function indexDecisionLog(log, o = {}) {
663
+ return (await runDecisionIndex(log, o)).doc;
704
664
  }
705
- // src/write.ts
706
- const encoder = new TextEncoder();
707
- const decoder = new TextDecoder();
708
- const compiled = compileKind(adrKind);
709
- const TEMPLATE_NAME = /^[a-z][a-z0-9-]*$/;
710
- const MAX_TEXT_BYTES = 65536;
711
- const SUBJECT_ATTEMPTS = 5;
712
- const QUALIFIED_SUPERSESSION = "cross-log supersession isn't supported yet";
713
- const sorted = (xs) => [...new Set(xs)].sort();
665
+ /** The built-in templates by name: the file text `adr new` materializes. */
666
+ const BUILTIN_TEMPLATES = Object.freeze({
667
+ nygard: `---
668
+ template: nygard
669
+ version: 1
670
+ sections:
671
+ - id: context
672
+ heading: Context
673
+ required: true
674
+ guidance: What is the issue that motivates this decision? Describe the forces at play.
675
+ - id: decision
676
+ heading: Decision
677
+ required: true
678
+ guidance: What is the change that we're proposing or doing? Write it in the active voice.
679
+ - id: scope
680
+ heading: Scope
681
+ required: true
682
+ guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
683
+ - id: consequences
684
+ heading: Consequences
685
+ required: true
686
+ guidance: What becomes easier or harder because of this change? Include the risks.
687
+ ---
688
+ # {{title}}
689
+
690
+ ## Context
691
+ {{context}}
692
+
693
+ ## Decision
694
+ {{decision}}
695
+
696
+ ## Scope
697
+ {{scope}}
698
+
699
+ ## Consequences
700
+ {{consequences}}
701
+ `,
702
+ madr: `---
703
+ template: madr
704
+ version: 1
705
+ sections:
706
+ - id: context
707
+ heading: Context and Problem Statement
708
+ required: true
709
+ guidance: Describe the context and the problem in two or three sentences, or as a question.
710
+ - id: drivers
711
+ heading: Decision Drivers
712
+ required: false
713
+ guidance: List the forces and concerns that shape the decision.
714
+ - id: options
715
+ heading: Considered Options
716
+ required: true
717
+ guidance: List the options that were considered, one per line.
718
+ - id: decision
719
+ heading: Decision Outcome
720
+ required: true
721
+ guidance: Name the chosen option and say why it was chosen.
722
+ - id: scope
723
+ heading: Scope
724
+ required: true
725
+ guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
726
+ - id: consequences
727
+ heading: Consequences
728
+ required: true
729
+ guidance: Describe the good and the bad consequences of the chosen option.
730
+ ---
731
+ # {{title}}
732
+
733
+ ## Context and Problem Statement
734
+ {{context}}
735
+
736
+ ## Decision Drivers
737
+ {{drivers}}
738
+
739
+ ## Considered Options
740
+ {{options}}
741
+
742
+ ## Decision Outcome
743
+ {{decision}}
744
+
745
+ ## Scope
746
+ {{scope}}
747
+
748
+ ## Consequences
749
+ {{consequences}}
750
+ `,
751
+ "y-statement": `---
752
+ template: y-statement
753
+ version: 1
754
+ sections:
755
+ - id: statement
756
+ heading: Statement
757
+ required: true
758
+ guidance: In the context of <use case>, facing <concern>, we decided for <option>, to achieve <quality>, accepting <downside>.
759
+ - id: scope
760
+ heading: Scope
761
+ required: true
762
+ guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
763
+ ---
764
+ # {{title}}
765
+
766
+ ## Statement
767
+ {{statement}}
768
+
769
+ ## Scope
770
+ {{scope}}
771
+ `
772
+ });
773
+ // src/write.ts
774
+ const encoder = new TextEncoder();
775
+ const decoder$2 = new TextDecoder();
776
+ const compiled = compileKind(adrKind);
777
+ const TEMPLATE_NAME = /^[a-z][a-z0-9-]*$/;
778
+ const MAX_TEXT_BYTES = 65536;
779
+ const SUBJECT_ATTEMPTS = 5;
780
+ const QUALIFIED_SUPERSESSION = "cross-log supersession isn't supported yet";
781
+ const sorted = (xs) => [...new Set(xs)].sort();
714
782
  const templatePath = (read, name) => `${read.root}/templates/${name}.md`;
715
783
  /** A text argument within the 64 KiB cap and without NUL characters, else `UsageError`. */
716
784
  function checkText(what, text) {
@@ -795,14 +863,18 @@ const findingKey = (f) => [
795
863
  * The precheck (§6.3): folds the would-be log and refuses when the write adds any finding. Problems
796
864
  * that were already there don't block; the write can only add findings about what it touches.
797
865
  */
798
- async function precheck(read, files) {
799
- const after = await readDecisionsFrom(overlayTree(read.tree, files), {
866
+ async function precheck(read, files, replace = {}) {
867
+ const after = await readDecisionsFrom(overlayTree(read.tree, {
868
+ ...files,
869
+ ...replace
870
+ }), {
800
871
  layout: read.layout,
801
872
  adr: read.adr,
802
873
  numbering: read.numbering,
803
874
  ref: read.ref,
804
875
  commit: read.commit,
805
- notes: read.notes
876
+ notes: read.notes,
877
+ sources: read.sources
806
878
  });
807
879
  const before = new Set(read.findings.map(findingKey));
808
880
  return {
@@ -821,7 +893,7 @@ async function chooseTemplate(read, name, c) {
821
893
  const bytes = await read.tree.read(path);
822
894
  let text;
823
895
  let materialize = null;
824
- if (bytes !== null) text = decoder.decode(bytes);
896
+ if (bytes !== null) text = decoder$2.decode(bytes);
825
897
  else {
826
898
  const builtin = Object.hasOwn(c.builtins, name) ? c.builtins[name] : void 0;
827
899
  if (builtin === void 0) {
@@ -988,6 +1060,126 @@ async function acceptLint(read, view, c) {
988
1060
  bytes: body.bytes
989
1061
  };
990
1062
  }
1063
+ const MAX_SECTIONS = 50;
1064
+ /**
1065
+ * Plans a draft update (design §6.6): the given sections of a proposed decision's body replaced in
1066
+ * place, every other line kept, written only if the body still holds the bytes read. Refused
1067
+ * (`ok: false`) when the decision isn't proposed or its body or template version is missing or
1068
+ * unreadable, and with `refuseIncomplete` when the new draft fails its template lint. An unknown
1069
+ * section, a missing heading or a heading in the content is a `UsageError`.
1070
+ */
1071
+ async function planUpdateDraft(read, input, c, o = {}) {
1072
+ const id = resolveDecision(read, input.id);
1073
+ const view = viewOf(read, id);
1074
+ const refused = (fs) => ({
1075
+ ok: false,
1076
+ decision: view,
1077
+ findings: fs
1078
+ });
1079
+ if (view.status !== "proposed") return refused([refusal(read, "decision/not-a-draft", `${namedView(view)} is ${view.status}; only a proposed decision's draft can change`, id)]);
1080
+ const ids = Object.keys(input.sections);
1081
+ if (ids.length === 0 || ids.length > MAX_SECTIONS) throw new UsageError(`a draft update takes 1 to ${MAX_SECTIONS} sections`);
1082
+ for (const [section, text] of Object.entries(input.sections)) checkText(`section ${section}`, text);
1083
+ const { findings: problems, template, text, body } = await draftOf(read, view, c);
1084
+ if (problems.length > 0 || template === null || text === null || body === null) return refused(problems);
1085
+ const r = replaceSections(template, text, input.sections);
1086
+ if (!r.ok) {
1087
+ const [first] = r.findings;
1088
+ if (first?.rule === "render/unknown-section") {
1089
+ const known = template.sections.map((s) => s.id).join(", ");
1090
+ const unknown = ids.find((s) => !template.sections.some((t) => t.id === s)) ?? "";
1091
+ throw new UsageError(`template ${template.name} has no section ${unknown}; its sections are ${known}`);
1092
+ }
1093
+ if (first?.rule === "render/section-missing") {
1094
+ const gone = new Set(extractSections(template, text).missing);
1095
+ const missing = template.sections.find((s) => ids.includes(s.id) && gone.has(s.id));
1096
+ throw new UsageError(`${body.path} has no '${missing?.heading ?? ""}' heading; edit the file by hand`);
1097
+ }
1098
+ throw new UsageError(r.findings.map((f) => f.message).join("; "));
1099
+ }
1100
+ if (r.text === text) return emptyPlan(id, view, ["the draft already has this content; nothing written"]);
1101
+ const bytes = encoder.encode(r.text);
1102
+ const lint = lintDocument(template, r.text, {
1103
+ file: body.path,
1104
+ subject: id
1105
+ }).map((f) => ({
1106
+ ...f,
1107
+ kind: adrKind.name
1108
+ }));
1109
+ if (o.refuseIncomplete === true && lint.length > 0) return refused(lint);
1110
+ const { after, added } = await precheck(read, {}, { [body.path]: bytes });
1111
+ if (added.length > 0) return refused(added);
1112
+ const { scope, eventDirs } = commitScope(read, [id]);
1113
+ const order = template.sections.map((s) => s.id).filter((s) => ids.includes(s));
1114
+ return {
1115
+ ok: true,
1116
+ plan: {
1117
+ command: "event",
1118
+ subject: id,
1119
+ files: {},
1120
+ replace: { [body.path]: {
1121
+ bytes,
1122
+ expected: body.bytes
1123
+ } },
1124
+ events: [],
1125
+ stage: [body.path],
1126
+ involved: [id],
1127
+ commitScope: scope,
1128
+ eventDirs,
1129
+ draft: null,
1130
+ commit: {
1131
+ action: DRAFT_ACTION,
1132
+ title: view.display,
1133
+ id,
1134
+ display: view.display,
1135
+ body: [`Sections: ${order.join(", ")}.`]
1136
+ },
1137
+ trailers: [["ADR", id]],
1138
+ decision: after.projection.views.get(id) ?? view,
1139
+ affected: [],
1140
+ findings: lint
1141
+ }
1142
+ };
1143
+ }
1144
+ /**
1145
+ * A proposed decision's body, its text and the template version its proposal recorded, or the
1146
+ * refusal findings when one of them is missing or the body isn't UTF-8.
1147
+ */
1148
+ async function draftOf(read, view, c) {
1149
+ const none = {
1150
+ template: null,
1151
+ text: null,
1152
+ body: null
1153
+ };
1154
+ const body = read.folded.subjects.get(view.id)?.loaded.body ?? null;
1155
+ if (body === null) return {
1156
+ findings: [refusal(read, "body/missing", "body file is missing", view.id)],
1157
+ ...none
1158
+ };
1159
+ const { name, version } = view.template;
1160
+ const found = await c.templates.get(name, version);
1161
+ if (!found.ok) {
1162
+ const message = found.reason === "not-found" ? `template ${name} not found at ${found.path}` : `template ${name} has no version ${version} at ${found.path}`;
1163
+ return {
1164
+ findings: [refusal(read, `template/${found.reason}`, message, view.id)],
1165
+ ...none
1166
+ };
1167
+ }
1168
+ try {
1169
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(body.bytes);
1170
+ return {
1171
+ findings: [],
1172
+ template: found.template,
1173
+ text,
1174
+ body
1175
+ };
1176
+ } catch {
1177
+ return {
1178
+ findings: [refusal(read, "body/not-utf8", "body is not valid UTF-8", view.id)],
1179
+ ...none
1180
+ };
1181
+ }
1182
+ }
991
1183
  /** Plans `adr accept`: `accepted` with the body hash, and `superseded` on each declared target. */
992
1184
  async function planAccept(read, input, c, o = {}) {
993
1185
  const id = resolveDecision(read, input.id);
@@ -1176,48 +1368,6 @@ async function planSupersede(read, input, c) {
1176
1368
  }
1177
1369
  };
1178
1370
  }
1179
- /**
1180
- * `{action}` of a commit that writes one event of each type (design §4.3), and what the title slot
1181
- * names: the decision's title for the events that change whether it is decided, else its display.
1182
- */
1183
- const ACTIONS = {
1184
- proposed: {
1185
- action: "propose to",
1186
- names: "title"
1187
- },
1188
- accepted: {
1189
- action: "decide to",
1190
- names: "title"
1191
- },
1192
- rejected: {
1193
- action: "decide not to",
1194
- names: "title"
1195
- },
1196
- withdrawn: {
1197
- action: "withdraw the proposal to",
1198
- names: "title"
1199
- },
1200
- deprecated: {
1201
- action: "deprecate",
1202
- names: "display"
1203
- },
1204
- superseded: {
1205
- action: "supersede",
1206
- names: "display"
1207
- },
1208
- noted: {
1209
- action: "add a note to",
1210
- names: "display"
1211
- },
1212
- linked: {
1213
- action: "link",
1214
- names: "display"
1215
- },
1216
- scoped: {
1217
- action: "set the scope of",
1218
- names: "display"
1219
- }
1220
- };
1221
1371
  const actionOf = (type) => Object.hasOwn(ACTIONS, type) ? ACTIONS[type] : {
1222
1372
  action: type,
1223
1373
  names: "display"
@@ -1352,7 +1502,7 @@ async function planLink(read, input, c, o = {}) {
1352
1502
  to = target;
1353
1503
  shown = target;
1354
1504
  described = `${rel} ${target}.`;
1355
- named = `${view.display} to a decision in another log`;
1505
+ named = `${view.display} ${CROSS_LOG_LINK}`;
1356
1506
  } else {
1357
1507
  to = resolveDecision(read, target);
1358
1508
  if (to === id) throw new UsageError("a decision can't link to itself");
@@ -1457,7 +1607,10 @@ async function planScope(read, input, c, o = {}) {
1457
1607
  notes
1458
1608
  }
1459
1609
  };
1460
- if (o.tracked !== void 0) notes.push(...unmatchedGlobs(result.set, o.tracked).map((g) => `${g} matches no tracked file`));
1610
+ if (o.tracked !== void 0) {
1611
+ const checked = (adrKind.def.scope?.activeIn ?? []).includes(view.status) ? "; verify (hooks and CI) reports scope/glob-unmatched until a file matches" : "";
1612
+ notes.push(...unmatchedGlobs(result.set, o.tracked).map((g) => `${g} matches no tracked file${checked}`));
1613
+ }
1461
1614
  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");
1462
1615
  const r = await planEvent(read, {
1463
1616
  subject: id,
@@ -1618,9 +1771,29 @@ async function trackedFiles(log) {
1618
1771
  }
1619
1772
  const isYaml = (path) => /\.ya?ml$/.test(path);
1620
1773
  /**
1774
+ * What the recovery hint says about rerunning a write command after `git commit` failed (design
1775
+ * 2b D5): the batch is already in the log, so a rerun is refused, writes nothing or duplicates it.
1776
+ */
1777
+ 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.";
1778
+ /**
1779
+ * A write leaves the work tree's render of `index.md` (`staged`) staged, and it lists decision
1780
+ * files that may not be staged. When some aren't and the index's own render differs, the next
1781
+ * plain commit's hook fails; the note says so, as its `index/stale` finding does. `null`
1782
+ * otherwise, or when that render fails.
1783
+ */
1784
+ async function unstagedIndexNote(log, at, staged, history, render) {
1785
+ const files = await unstagedLogFiles(log.git, log.layout);
1786
+ if (files.length === 0) return null;
1787
+ const own = await render(await indexReader(log.git), history).catch(() => null);
1788
+ if (own === null || own === staged) return null;
1789
+ return `${unstagedIndexText(at, files)}; git add them before you commit, or move them out of the work tree and run adr index`;
1790
+ }
1791
+ /**
1621
1792
  * Applies a plan (§5.4): refuses a commit whose scope has unstaged or needed untracked paths,
1622
1793
  * writes the files (removing them again if a write fails), stages them, runs `afterStage`, and
1623
- * commits `plan.stage` plus the involved subjects' already-staged files with exact trailers.
1794
+ * commits `plan.stage` plus the involved subjects' already-staged files with exact trailers. A
1795
+ * failed `git commit` throws `CommitFailedError` with the manual command (design 2b D5) and the
1796
+ * notes made so far.
1624
1797
  */
1625
1798
  async function applyPlan(log, plan, o) {
1626
1799
  const { git } = log;
@@ -1633,24 +1806,93 @@ async function applyPlan(log, plan, o) {
1633
1806
  const [first] = sorted([...unstaged, ...untracked]).filter((p) => !stage.has(p));
1634
1807
  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`);
1635
1808
  }
1809
+ const root = log.layout.root;
1810
+ const at = indexPath(log.layout);
1811
+ const notes = [];
1812
+ const head = await git.resolveCommit("HEAD");
1813
+ const extraOf = async () => (await git.stagedPaths(plan.commitScope)).filter((p) => !stage.has(p));
1814
+ const commitTree = async (extra) => {
1815
+ const work = worktreeReader(git);
1816
+ const files = {};
1817
+ const gone = /* @__PURE__ */ new Set();
1818
+ for (const p of sorted([...plan.stage, ...extra])) {
1819
+ const bytes = plan.files[p] ?? plan.replace?.[p]?.bytes ?? await work.read(p);
1820
+ if (bytes === null) gone.add(p);
1821
+ else files[p] = bytes;
1822
+ }
1823
+ return withoutPaths(overlayTree(await headTree(git, head), files), gone);
1824
+ };
1825
+ const extra0 = o.commit && o.index !== void 0 ? await extraOf() : [];
1826
+ let text = null;
1827
+ if (o.index !== void 0) {
1828
+ const tree = o.commit ? await commitTree(extra0) : overlayTree(worktreeReader(git), {
1829
+ ...plan.files,
1830
+ ...replaceBytes(plan)
1831
+ });
1832
+ text = await o.index(tree, head);
1833
+ if (text === null) notes.push(`${at} not updated: numbers need history in this shallow clone; run git fetch --unshallow, then adr index`);
1834
+ }
1835
+ const toStage = [];
1836
+ let indexWritten = false;
1837
+ let indexOld = null;
1838
+ let indexInCommit = false;
1839
+ /** Brings the work-tree copy to `t` and stages it (now, or with the batch) where they differ. */
1840
+ const refresh = async (t, now) => {
1841
+ const r = await writeIndexCopy(git, at, t);
1842
+ if (r.wrote && !indexWritten) indexOld = r.old;
1843
+ indexWritten ||= r.wrote;
1844
+ if (r.stage) {
1845
+ if (now) await git.add([at]);
1846
+ else toStage.push(at);
1847
+ }
1848
+ return r.wrote || r.stage;
1849
+ };
1850
+ /** Step 3's rules: committing, HEAD's copy decides; otherwise the work tree and the index. */
1851
+ const place = async (t, committing, now) => {
1852
+ if (!committing) {
1853
+ await refresh(t, now);
1854
+ return;
1855
+ }
1856
+ indexInCommit = !holdsText(await headBytes(git, at), t);
1857
+ if (indexInCommit) await refresh(t, now);
1858
+ };
1636
1859
  const eventPaths = new Set(plan.events.map((e) => eventPath(log.layout, e)));
1637
1860
  const written = [];
1861
+ const replaced = [];
1638
1862
  try {
1863
+ for (const [path, r] of Object.entries(plan.replace ?? {})) {
1864
+ await replaceFile(git, path, r.bytes, { expected: r.expected });
1865
+ replaced.push(path);
1866
+ }
1639
1867
  for (const [path, bytes] of Object.entries(plan.files)) {
1640
1868
  if (eventPaths.has(path)) continue;
1641
1869
  await writeNewFile(git, path, bytes, { stage: false });
1642
1870
  written.push(path);
1643
1871
  }
1872
+ if (text !== null) await place(text, o.commit, false);
1644
1873
  await writeEvents(git, log.layout, plan.events, {
1645
1874
  kind: log.compiled,
1646
1875
  stage: false
1647
1876
  });
1648
1877
  } catch (err) {
1649
- const root = git.root ?? "";
1650
- for (const path of written) await rm(join(root, path), { force: true });
1878
+ const top = git.root ?? "";
1879
+ if (indexWritten) {
1880
+ if (indexOld === null) await rm(join(top, at), { force: true });
1881
+ else await writeFile(join(top, at), indexOld);
1882
+ }
1883
+ for (const path of written) await rm(join(top, path), { force: true });
1884
+ for (const path of replaced) {
1885
+ const r = plan.replace?.[path];
1886
+ if (r !== void 0) await replaceFile(git, path, r.expected, { expected: r.bytes }).catch(() => {});
1887
+ }
1651
1888
  throw err;
1652
1889
  }
1653
- await git.add(plan.stage);
1890
+ await git.add([...plan.stage, ...toStage]);
1891
+ /** The work tree's render that ends up staged, and the commit it builds on. */
1892
+ let stagedIndex = !o.commit && text !== null ? {
1893
+ text,
1894
+ history: head
1895
+ } : null;
1654
1896
  let findings = plan.findings;
1655
1897
  let commit = o.commit;
1656
1898
  if (o.afterStage !== void 0) {
@@ -1658,25 +1900,110 @@ async function applyPlan(log, plan, o) {
1658
1900
  findings = after.findings ?? plan.findings;
1659
1901
  commit = commit && after.commit;
1660
1902
  }
1903
+ const stale = (err) => `${at} may be stale: ${firstLine(err)}; run adr index`;
1661
1904
  let sha = null;
1662
1905
  if (commit) {
1663
- const extra = (await git.stagedPaths(plan.commitScope)).filter((p) => !stage.has(p));
1906
+ const extra = await extraOf();
1664
1907
  const staged = extra.filter(isYaml).flatMap((p) => {
1665
1908
  const c = classifyPath(log.layout, p);
1666
1909
  const name = c.area === "event" && !c.file.includes("/") ? parseEventFileName(c.file) : null;
1667
1910
  return c.area === "event" && c.subject !== null && name !== null ? [["ADR", c.subject], ["ADR-Event", name.type]] : [];
1668
1911
  });
1669
- sha = await git.commit({
1670
- message: o.message ?? "",
1671
- mergeTrailers: o.mergeTrailers === true,
1672
- trailers: [...staged, ...plan.trailers],
1673
- only: sorted([...plan.stage, ...extra])
1674
- });
1912
+ const message = o.message ?? "";
1913
+ const mergeTrailers = o.mergeTrailers === true;
1914
+ const trailers = [...staged, ...plan.trailers];
1915
+ const sameEvents = sorted(extra.filter(isYaml)).join("\0") === sorted(extra0.filter(isYaml)).join("\0");
1916
+ if (text !== null && o.index !== void 0 && o.afterStage !== void 0 && !sameEvents) try {
1917
+ const again = await o.index(await commitTree(extra), head);
1918
+ if (again !== null) await place(again, true, true);
1919
+ } catch (err) {
1920
+ const recovery = await saveCommitRecovery(git, {
1921
+ message,
1922
+ mergeTrailers,
1923
+ trailers,
1924
+ only: sorted([
1925
+ ...plan.stage,
1926
+ ...extra,
1927
+ at
1928
+ ])
1929
+ });
1930
+ throw new CommitFailedError({
1931
+ recovery,
1932
+ headline: `${at} couldn't be refreshed: ${firstLine(err)}`,
1933
+ detail: "",
1934
+ rerun: WRITE_RERUN,
1935
+ before: INDEX_BEFORE(root),
1936
+ after: null,
1937
+ notes
1938
+ }, { cause: err });
1939
+ }
1940
+ try {
1941
+ sha = await commitWithRecovery(git, {
1942
+ message,
1943
+ mergeTrailers,
1944
+ trailers,
1945
+ only: sorted([
1946
+ ...plan.stage,
1947
+ ...extra,
1948
+ ...indexInCommit ? [at] : []
1949
+ ]),
1950
+ rerun: WRITE_RERUN,
1951
+ after: indexInCommit ? INDEX_AFTER(root) : null
1952
+ });
1953
+ } catch (err) {
1954
+ throw err instanceof CommitFailedError ? err.withNotes(notes) : err;
1955
+ }
1956
+ if (text !== null && o.index !== void 0) try {
1957
+ const again = await o.index(worktreeReader(git), sha);
1958
+ if (again !== null) {
1959
+ await refresh(again, true);
1960
+ stagedIndex = {
1961
+ text: again,
1962
+ history: sha
1963
+ };
1964
+ }
1965
+ } catch (err) {
1966
+ notes.push(stale(err));
1967
+ }
1968
+ } else if (o.commit && text !== null && o.index !== void 0) try {
1969
+ const again = await o.index(worktreeReader(git), head);
1970
+ if (again !== null) {
1971
+ await refresh(again, true);
1972
+ stagedIndex = {
1973
+ text: again,
1974
+ history: head
1975
+ };
1976
+ }
1977
+ } catch (err) {
1978
+ notes.push(stale(err));
1979
+ }
1980
+ if (stagedIndex !== null && o.index !== void 0) {
1981
+ const { text: t, history } = stagedIndex;
1982
+ const note = await unstagedIndexNote(log, at, t, history, o.index);
1983
+ if (note !== null) notes.push(note);
1675
1984
  }
1676
1985
  return {
1677
- written: Object.keys(plan.files),
1986
+ written: [
1987
+ ...Object.keys(plan.replace ?? {}),
1988
+ ...Object.keys(plan.files),
1989
+ ...indexWritten ? [at] : []
1990
+ ],
1678
1991
  commit: sha,
1679
- findings
1992
+ findings,
1993
+ notes
1994
+ };
1995
+ }
1996
+ /** The new bytes of each file a plan replaces. */
1997
+ const replaceBytes = (plan) => Object.fromEntries(Object.entries(plan.replace ?? {}).map(([p, r]) => [p, r.bytes]));
1998
+ /** `tree` without the paths in `gone` (a staged deletion in a commit's `only` list). */
1999
+ function withoutPaths(tree, gone) {
2000
+ if (gone.size === 0) return tree;
2001
+ return {
2002
+ label: tree.label,
2003
+ commit: null,
2004
+ list: async (prefix) => (await tree.list(prefix)).filter((e) => !gone.has(e.path)),
2005
+ read: async (path) => gone.has(path) ? null : tree.read(path),
2006
+ readMany: (paths) => tree.readMany(paths.filter((p) => !gone.has(p)))
1680
2007
  };
1681
2008
  }
1682
2009
  const DEFAULT_DEPS = {
@@ -1763,7 +2090,7 @@ async function write(log, command, plan, o, deps, afterStage) {
1763
2090
  findings: r.findings,
1764
2091
  notes
1765
2092
  };
1766
- if (r.plan.events.length === 0 && Object.keys(r.plan.files).length === 0 || o.dryRun === true) return {
2093
+ if (r.plan.events.length === 0 && Object.keys(r.plan.files).length === 0 && Object.keys(r.plan.replace ?? {}).length === 0 || o.dryRun === true) return {
1767
2094
  format: "adr-write/v1",
1768
2095
  command,
1769
2096
  ok: true,
@@ -1783,10 +2110,20 @@ async function write(log, command, plan, o, deps, afterStage) {
1783
2110
  message: given ?? rendered.message,
1784
2111
  mergeTrailers: given !== null
1785
2112
  },
1786
- ...afterStage === void 0 ? {} : { afterStage: afterStage(r.plan, commit) }
2113
+ ...afterStage === void 0 ? {} : { afterStage: afterStage(r.plan, commit) },
2114
+ ...log.adr.index === "committed" && o.index !== "skip" ? { index: (tree, history) => renderDecisionIndex(log.git, {
2115
+ layout: log.layout,
2116
+ adr: log.adr,
2117
+ tree,
2118
+ history
2119
+ }) } : {}
1787
2120
  });
1788
2121
  const views = await viewsAfter(log, r.plan, applied.commit);
1789
- const own = [...r.plan.notes ?? [], ...applied.commit !== null && given === null && rendered?.shortened === true ? [SHORTENED_NOTE] : []];
2122
+ const own = [
2123
+ ...r.plan.notes ?? [],
2124
+ ...applied.notes,
2125
+ ...applied.commit !== null && given === null && rendered?.shortened === true ? [SHORTENED_NOTE] : []
2126
+ ];
1790
2127
  return {
1791
2128
  format: "adr-write/v1",
1792
2129
  command,
@@ -1799,6 +2136,7 @@ async function write(log, command, plan, o, deps, afterStage) {
1799
2136
  notes: [...notes, ...own]
1800
2137
  };
1801
2138
  } catch (err) {
2139
+ if (err instanceof CommitFailedError) throw err.withNotes([...notes, ...r.plan.notes ?? []]);
1802
2140
  if (!(err instanceof EventExistsError) || attempt > 0) throw err;
1803
2141
  }
1804
2142
  }
@@ -1848,7 +2186,7 @@ function editorStep(log, edit, onNote) {
1848
2186
  onNote?.(`${first} changed while the editor was open; review it, then git add it and commit`);
1849
2187
  }
1850
2188
  }
1851
- const text = decoder.decode(bytes ?? d.rendered);
2189
+ const text = decoder$2.decode(bytes ?? d.rendered);
1852
2190
  const findings = lintDocument(d.template, text, {
1853
2191
  file: d.path,
1854
2192
  subject: plan.subject
@@ -1948,6 +2286,19 @@ async function scopeDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
1948
2286
  const tracked = await trackedFiles(log);
1949
2287
  return write(log, "scope", (read, c) => planScope(read, input, c, { tracked }), o, deps);
1950
2288
  }
2289
+ /**
2290
+ * Replaces sections of a proposed decision's draft (design §6.6), stages the body, and commits
2291
+ * with `commit` (default `[policy] auto_commit`). A body with unstaged changes is a `UsageError`,
2292
+ * so nobody's unsaved edit is folded into this write; a body changed between plan and write is
2293
+ * `FileChangedError`. A commit is never made of a draft that fails its template lint: it is
2294
+ * refused (`ok: false`, nothing written), as for `proposeDecision` without an editor.
2295
+ */
2296
+ async function updateDraft(log, input, o = {}, deps = DEFAULT_DEPS) {
2297
+ const read = await readDecisions(log);
2298
+ const view = viewOf(read, resolveDecision(read, input.id));
2299
+ if (view.body_path !== null && (await log.git.unstagedPaths([view.body_path])).length > 0) throw new UsageError(`${view.body_path} has unstaged changes; stage or discard them, or edit the draft by hand`);
2300
+ return write(log, "update", (read, c) => planUpdateDraft(read, input, c, { refuseIncomplete: o.commit ?? log.adr.autoCommit }), o, deps);
2301
+ }
1951
2302
  const FOLLOW_NO_COMMIT_NOTE = "--follow-renames never commits; commit the scoped events with the renames";
1952
2303
  /**
1953
2304
  * `adr scope [<decision>] --follow-renames` (design §6.8): rewrites the scope globs of the given
@@ -1981,6 +2332,434 @@ async function followScopeRenames(log, input, o = {}, deps = DEFAULT_DEPS) {
1981
2332
  notes: [...doc.notes, FOLLOW_NO_COMMIT_NOTE]
1982
2333
  } : doc;
1983
2334
  }
1984
- export { initDecisionLog as C, unavailableReason as S, parseAdrConfig as T, stateHeader as _, noteDecision as a, readDecisionsFrom as b, scopeDecision as c, withdrawDecision as d, adrChecks as f, showDecision as g, listDecisions as h, linkDecision as i, scopeToCopy as l, decisionsFor as m, deprecateDecision as n, proposeDecision as o, verifyDecisions as p, followScopeRenames as r, rejectDecision as s, acceptDecision as t, supersedeDecision as u, openDecisionLog as v, DEFAULT_ADR_CONFIG as w, resolveDecision as x, readDecisions as y };
2335
+ // src/views.ts
2336
+ /** The `numbering` key of `adr-list/v1`, `adr-show/v1` and `adr-for/v1`. */
2337
+ const numberingJson = (read) => ({
2338
+ available: read.numbering.available,
2339
+ reason: read.numbering.reason,
2340
+ ref: read.numbering.ref
2341
+ });
2342
+ /** Numbered decisions by number, then unnumbered ones by `proposed_at`, then id. */
2343
+ function compareViews(a, b) {
2344
+ if (a.number !== null || b.number !== null) {
2345
+ if (a.number === null) return 1;
2346
+ if (b.number === null) return -1;
2347
+ return a.number - b.number;
2348
+ }
2349
+ if (a.proposed_at !== b.proposed_at) return a.proposed_at < b.proposed_at ? -1 : 1;
2350
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
2351
+ }
2352
+ /**
2353
+ * The `adr-list/v1` document: decisions in list order, filtered by `status` and by `since` (only
2354
+ * the decisions with event files added since its revision, from `changedSince`), and truncated to
2355
+ * `limit` after ordering. `findings` are those of the listed decisions and of the log as a whole.
2356
+ */
2357
+ function listDecisions(read, o = {}) {
2358
+ const statuses = o.status === void 0 || o.status.length === 0 ? null : new Set(o.status);
2359
+ const since = o.since ?? null;
2360
+ const changed = since === null ? null : new Set(since.events.map((e) => e.subject));
2361
+ 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);
2362
+ const listed = new Set(decisions.map((v) => v.id));
2363
+ return {
2364
+ format: "adr-list/v1",
2365
+ root: read.root,
2366
+ ref: read.ref,
2367
+ commit: read.commit,
2368
+ numbering: numberingJson(read),
2369
+ decisions,
2370
+ since,
2371
+ unreadable: read.projection.unreadable,
2372
+ unfoldable: read.projection.unfoldable,
2373
+ findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
2374
+ notes: listingNotes(read)
2375
+ };
2376
+ }
2377
+ const decoder$1 = new TextDecoder();
2378
+ /**
2379
+ * The `adr-show/v1` document of the decision `id` (resolve it with `resolveDecision` first).
2380
+ * Throws `UsageError` for an unknown, unreadable or unfoldable decision.
2381
+ */
2382
+ async function showDecision(read, id, o = {}) {
2383
+ const view = read.projection.views.get(id);
2384
+ if (view === void 0) {
2385
+ if (read.projection.unreadable.includes(id)) throw new UsageError(`decision ${id} uses a newer format; upgrade adr`);
2386
+ if (read.projection.unfoldable.includes(id)) throw new UsageError(`decision ${id} can't be read; run adr verify`);
2387
+ throw new UsageError(`unknown decision ${id}`);
2388
+ }
2389
+ const loaded = read.folded.subjects.get(id)?.loaded;
2390
+ const body = loaded?.body ?? null;
2391
+ const events = o.history === true ? (loaded?.events ?? []).flatMap((e) => e.event === null ? [] : [{
2392
+ path: e.path,
2393
+ event: e.event
2394
+ }]).sort((a, b) => compareEvents({
2395
+ ...a.event,
2396
+ path: a.path
2397
+ }, {
2398
+ ...b.event,
2399
+ path: b.path
2400
+ })).map((e) => e.event) : null;
2401
+ return {
2402
+ format: "adr-show/v1",
2403
+ root: read.root,
2404
+ ref: read.ref,
2405
+ commit: read.commit,
2406
+ numbering: numberingJson(read),
2407
+ header: stateHeader(view, read),
2408
+ decision: view,
2409
+ body: body === null ? null : decoder$1.decode(body.bytes),
2410
+ events,
2411
+ findings: read.findings.filter((f) => f.subject === id),
2412
+ notes: read.notes
2413
+ };
2414
+ }
2415
+ const day = (at) => (at ?? "").slice(0, 10);
2416
+ /** The reason on the applied event that produced the decision's current status, if any. */
2417
+ function statusReason(read, id) {
2418
+ const reason = (read.folded.subjects.get(id)?.applied ?? []).findLast((a) => a.from !== a.to)?.event.reason;
2419
+ return typeof reason === "string" && reason.trim() !== "" ? reason.trim() : null;
2420
+ }
2421
+ /**
2422
+ * The state header (01:258–259): `<display> (<id>) · <status>`, and for a decision that isn't in
2423
+ * force a warning saying what to read instead. `refer` names where to read another decision. CLI
2424
+ * default: `display(id)`. Phase 3 MCP passes `id => "adr://decision/" + display(id)`, e.g.
2425
+ * `adr://decision/ADR-0012`, or the id when unnumbered.
2426
+ */
2427
+ function stateHeader(view, read, o = {}) {
2428
+ const display = (id) => read.projection.views.get(id)?.display ?? id;
2429
+ const refer = o.refer ?? display;
2430
+ const line = view.number === null ? `${view.id} · ${view.status}` : `${view.display} (${view.id}) · ${view.status}`;
2431
+ const parts = [];
2432
+ const reason = statusReason(read, view.id);
2433
+ const when = day(view.status_at);
2434
+ switch (view.status) {
2435
+ case "superseded": {
2436
+ const target = view.effective ?? view.superseded_by ?? view.id;
2437
+ const accepted = read.projection.views.get(target)?.decided_at ?? null;
2438
+ const date = accepted === null ? "" : ` (accepted ${day(accepted)})`;
2439
+ parts.push(`SUPERSEDED by ${display(target)}${date}. Do not apply this decision; read ${refer(target)} instead.`);
2440
+ if (view.chain.length > 2) parts.push(`Chain: ${view.chain.map(display).join(" → ")}.`);
2441
+ break;
2442
+ }
2443
+ case "deprecated":
2444
+ parts.push(`DEPRECATED on ${when}: ${reason ?? "no reason given"}. No decision replaces it.`);
2445
+ break;
2446
+ case "rejected":
2447
+ parts.push(`REJECTED on ${when}: ${reason ?? "no reason given"}. This decision was never in force.`);
2448
+ break;
2449
+ case "withdrawn": parts.push(`WITHDRAWN on ${when}${reason === null ? "" : `: ${reason}`}. This decision was never in force.`);
2450
+ }
2451
+ if (view.body_intact === false) parts.push("WARNING: the body changed after acceptance; run adr verify.");
2452
+ return {
2453
+ line,
2454
+ warning: parts.length === 0 ? null : parts.join(" ")
2455
+ };
2456
+ }
2457
+ // src/scope.ts
2458
+ const MAX_PATHS = 100;
2459
+ const MAX_PATH_LENGTH = 4096;
2460
+ /** Statuses whose globs govern code; rejected and withdrawn decisions were never in force. */
2461
+ const GLOBS_APPLY = /* @__PURE__ */ new Set([
2462
+ "proposed",
2463
+ "accepted",
2464
+ "deprecated",
2465
+ "superseded"
2466
+ ]);
2467
+ /** The order `adr for` and `adr search` list statuses in: in force first. */
2468
+ const STATUS_ORDER = [
2469
+ "accepted",
2470
+ "proposed",
2471
+ "deprecated",
2472
+ "superseded",
2473
+ "rejected",
2474
+ "withdrawn"
2475
+ ];
2476
+ /** Accepted first (design §6.9 step 5); within a status by number, then by id. */
2477
+ function compareFor(a, b) {
2478
+ const s = STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status);
2479
+ if (s !== 0) return s;
2480
+ if (a.number !== null || b.number !== null) {
2481
+ if (a.number === null) return 1;
2482
+ if (b.number === null) return -1;
2483
+ return a.number - b.number;
2484
+ }
2485
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
2486
+ }
2487
+ /** `path` (relative to `cwd`) as a repo-relative POSIX path; `UsageError` outside the work tree. */
2488
+ async function repoPath(root, cwd, path) {
2489
+ if (path.length > MAX_PATH_LENGTH) throw new UsageError(`a path can be at most ${MAX_PATH_LENGTH} characters`);
2490
+ const base = await realpath(cwd).catch(() => cwd);
2491
+ const rel = relative(root, resolve(base, path));
2492
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new UsageError(`${path} is outside the repository`);
2493
+ return rel.split(sep).join("/");
2494
+ }
2495
+ /**
2496
+ * Which decisions govern each path (design §6.9): globs of decisions that were ever in force, and
2497
+ * comment anchors of any decision, read from the work tree or, with a `--ref` log, that commit.
2498
+ * Paths are relative to `o.cwd` (default the repository root); directories and paths outside the
2499
+ * repository are `UsageError`s. Paths need not be tracked.
2500
+ */
2501
+ async function decisionsFor(read, log, o) {
2502
+ const root = log.git.root;
2503
+ if (root === null) throw new UsageError("adr for needs a work tree");
2504
+ if (o.paths.length === 0 || o.paths.length > MAX_PATHS) throw new UsageError(`adr for takes 1 to ${MAX_PATHS} paths`);
2505
+ let index = null;
2506
+ const indexed = () => {
2507
+ index ??= indexReader(log.git);
2508
+ return index;
2509
+ };
2510
+ const hasUnder = async (tree, path) => (await tree.list(path)).some((e) => e.path !== path);
2511
+ const paths = [];
2512
+ for (const given of o.paths) {
2513
+ const path = await repoPath(root, o.cwd ?? root, given);
2514
+ let directory = path === "";
2515
+ if (!directory && read.ref === null) {
2516
+ const st = await lstat(join(root, path)).catch(() => null);
2517
+ directory = st === null ? await hasUnder(await indexed(), path) : st.isDirectory();
2518
+ } else if (!directory) directory = await hasUnder(read.tree, path);
2519
+ if (directory) throw new UsageError(`${given} is a directory; adr for takes file paths, for example adr for $(git ls-files ${given})`);
2520
+ if (!paths.includes(path)) paths.push(path);
2521
+ }
2522
+ const atRef = read.ref !== null && read.commit !== null;
2523
+ const trackedIn = atRef ? read.tree : await indexed();
2524
+ const tracked = /* @__PURE__ */ new Set();
2525
+ for (const p of paths) if ((await trackedIn.list(p)).some((e) => e.path === p)) tracked.add(p);
2526
+ const anchors = await scanAnchors(log.git, {
2527
+ keyword: read.adr.anchorKeyword,
2528
+ tree: atRef ? { commit: read.commit } : "worktree",
2529
+ paths,
2530
+ excludeDirs: [read.layout.root],
2531
+ exclude: read.adr.anchorExclude
2532
+ });
2533
+ const views = read.projection.views;
2534
+ const lookup = scopeLookup({
2535
+ subjects: [...views.values()].filter((v) => GLOBS_APPLY.has(v.status)).map((v) => ({
2536
+ id: v.id,
2537
+ globs: v.applies_to
2538
+ })),
2539
+ anchors
2540
+ });
2541
+ const listed = /* @__PURE__ */ new Set();
2542
+ const out = paths.map((path) => {
2543
+ const decisions = [];
2544
+ const unresolved = [];
2545
+ for (const m of lookup(path)) {
2546
+ const view = m.source === null ? views.get(m.id) : void 0;
2547
+ if (view === void 0) {
2548
+ for (const v of m.via) {
2549
+ if (v.type !== "anchor") continue;
2550
+ unresolved.push({
2551
+ ref: m.source === null ? m.id : `${m.source}:${m.id}`,
2552
+ line: v.line,
2553
+ reason: m.source === null ? "not-found" : "other-log"
2554
+ });
2555
+ }
2556
+ continue;
2557
+ }
2558
+ decisions.push({
2559
+ view,
2560
+ via: m.via
2561
+ });
2562
+ listed.add(view.id);
2563
+ }
2564
+ decisions.sort((a, b) => compareFor(a.view, b.view));
2565
+ unresolved.sort((a, b) => a.line - b.line);
2566
+ return {
2567
+ path,
2568
+ tracked: tracked.has(path),
2569
+ decisions: decisions.map(({ view: v, via }) => ({
2570
+ id: v.id,
2571
+ display: v.display,
2572
+ title: v.title,
2573
+ status: v.status,
2574
+ effective: v.effective,
2575
+ warning: stateHeader(v, read).warning,
2576
+ via
2577
+ })),
2578
+ unresolved
2579
+ };
2580
+ });
2581
+ return {
2582
+ format: "adr-for/v1",
2583
+ root: read.root,
2584
+ ref: read.ref,
2585
+ commit: read.commit,
2586
+ numbering: numberingJson(read),
2587
+ paths: out,
2588
+ findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
2589
+ notes: listingNotes(read)
2590
+ };
2591
+ }
2592
+ // src/search.ts
2593
+ const MAX_QUERY = 200;
2594
+ const MAX_TERMS = 10;
2595
+ const EXCERPT = 160;
2596
+ const LEAD = 60;
2597
+ const WORD_SLACK = 15;
2598
+ const ACTIVE = /* @__PURE__ */ new Set(["proposed", "accepted"]);
2599
+ const decoder = new TextDecoder("utf-8", { fatal: true });
2600
+ /**
2601
+ * Case- and accent-insensitive form: per code point NFKD, combining marks dropped, lower-cased.
2602
+ * `Café` → `cafe` and `fi` → `fi`; `ß` and `ø` have no decomposition and stay as they are.
2603
+ */
2604
+ function fold(s) {
2605
+ let text = "";
2606
+ const at = [];
2607
+ let offset = 0;
2608
+ for (const c of s) {
2609
+ const f = c.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase();
2610
+ text += f;
2611
+ for (let i = 0; i < f.length; i++) at.push(offset);
2612
+ offset += c.length;
2613
+ }
2614
+ at.push(offset);
2615
+ return {
2616
+ text,
2617
+ at
2618
+ };
2619
+ }
2620
+ const count = (hay, needle) => {
2621
+ let n = 0;
2622
+ for (let i = hay.indexOf(needle); i >= 0; i = hay.indexOf(needle, i + needle.length)) n++;
2623
+ return n;
2624
+ };
2625
+ /** The query's terms, or a `UsageError` for an empty, long, control-character or many-word query. */
2626
+ function queryTerms(query) {
2627
+ const q = query.trim();
2628
+ if (q === "" || q.length > MAX_QUERY || /[\u0000-\u001f\u007f]/.test(q)) throw new UsageError(`the search query must be 1 to ${MAX_QUERY} characters without control characters`);
2629
+ const terms = [...new Set(fold(q).text.split(/\s+/).filter((t) => t !== ""))];
2630
+ if (terms.length > MAX_TERMS) throw new UsageError(`a search takes at most ${MAX_TERMS} words`);
2631
+ if (terms.length === 0) throw new UsageError("the search query must contain a letter, digit or symbol");
2632
+ return terms;
2633
+ }
2634
+ /** The body text without its first `# ` title line; `""` when missing or not UTF-8. */
2635
+ function bodyText(read, id) {
2636
+ const bytes = read.folded.subjects.get(id)?.loaded?.body?.bytes;
2637
+ if (bytes === void 0) return "";
2638
+ let text;
2639
+ try {
2640
+ text = decoder.decode(bytes).replace(/\r\n/g, "\n");
2641
+ } catch {
2642
+ return "";
2643
+ }
2644
+ const lines = text.split("\n");
2645
+ const title = lines.findIndex((l) => l.startsWith("# "));
2646
+ if (title >= 0) lines.splice(title, 1);
2647
+ return lines.join("\n");
2648
+ }
2649
+ function noteTexts(read, id) {
2650
+ return (read.folded.subjects.get(id)?.applied ?? []).flatMap((a) => {
2651
+ const text = a.event.data?.text;
2652
+ return a.event.type === "noted" && typeof text === "string" ? [text] : [];
2653
+ });
2654
+ }
2655
+ /** The excerpt of the first line with the most distinct terms, windowed around the first hit. */
2656
+ function excerpt(lines, terms) {
2657
+ let best = null;
2658
+ for (const raw of lines) {
2659
+ const line = raw.replace(/\s+/g, " ").trim();
2660
+ const folded = fold(line).text;
2661
+ const hits = terms.filter((t) => folded.includes(t)).length;
2662
+ if (hits > 0 && (best === null || hits > best.hits)) best = {
2663
+ line,
2664
+ hits
2665
+ };
2666
+ }
2667
+ if (best === null) return null;
2668
+ const { line } = best;
2669
+ if (line.length <= EXCERPT) return line;
2670
+ const f = fold(line);
2671
+ const first = Math.min(...terms.map((t) => f.text.indexOf(t)).filter((i) => i >= 0));
2672
+ let start = Math.max(0, (f.at[first] ?? 0) - LEAD);
2673
+ if (start > 0 && line[start - 1] !== " ") {
2674
+ const space = line.indexOf(" ", start);
2675
+ if (space >= 0 && space - start <= WORD_SLACK) start = space + 1;
2676
+ }
2677
+ let end = Math.min(line.length, start + EXCERPT);
2678
+ if (end < line.length && line[end] !== " ") {
2679
+ const space = line.lastIndexOf(" ", end);
2680
+ if (space > start && end - space <= WORD_SLACK) end = space;
2681
+ }
2682
+ const cut = line.slice(start, end).trim();
2683
+ return `${start > 0 ? "…" : ""}${cut}${end < line.length ? "…" : ""}`;
2684
+ }
2685
+ /**
2686
+ * Full-text search of the log (design §8.1): a decision matches when every term of the folded
2687
+ * query is a substring of its folded id, display number, title, body or note texts. Ranked by
2688
+ * score (10 for a term equal to the id or number, 5 in the title, up to 3 for body hits and 2
2689
+ * for note hits), then status as `adr for` orders them, then list order. Only proposed and
2690
+ * accepted decisions unless `includeInactive`. Throws `UsageError` for an invalid query.
2691
+ */
2692
+ function searchDecisions(read, o) {
2693
+ const terms = queryTerms(o.query);
2694
+ if (o.limit !== void 0 && (!Number.isInteger(o.limit) || o.limit < 1)) throw new UsageError("the search limit must be a positive integer");
2695
+ const includeInactive = o.includeInactive === true;
2696
+ const found = [];
2697
+ let inactive = 0;
2698
+ for (const view of read.projection.views.values()) {
2699
+ const body = bodyText(read, view.id);
2700
+ const notes = noteTexts(read, view.id);
2701
+ const id = fold(view.id).text;
2702
+ const display = fold(view.display).text;
2703
+ const title = fold(view.title).text;
2704
+ const foldedBody = fold(body).text;
2705
+ const foldedNotes = notes.map((n) => fold(n).text).join("\n");
2706
+ const hit = /* @__PURE__ */ new Set();
2707
+ let score = 0;
2708
+ let all = true;
2709
+ for (const t of terms) {
2710
+ const inId = id.includes(t) || display.includes(t);
2711
+ const inTitle = title.includes(t);
2712
+ const inBody = count(foldedBody, t);
2713
+ const inNotes = count(foldedNotes, t);
2714
+ if (!inId && !inTitle && inBody === 0 && inNotes === 0) {
2715
+ all = false;
2716
+ break;
2717
+ }
2718
+ if (inId) hit.add("id");
2719
+ if (inTitle) hit.add("title");
2720
+ if (inBody > 0) hit.add("body");
2721
+ if (inNotes > 0) hit.add("notes");
2722
+ score += (t === id || t === display ? 10 : 0) + (inTitle ? 5 : 0) + Math.min(3, inBody) + Math.min(2, inNotes);
2723
+ }
2724
+ if (!all) continue;
2725
+ if (!includeInactive && !ACTIVE.has(view.status)) {
2726
+ inactive++;
2727
+ continue;
2728
+ }
2729
+ const lines = [...body.split("\n").filter((l) => l.trim() !== "" && !l.startsWith("#")), ...notes.flatMap((n) => n.split("\n"))];
2730
+ found.push({
2731
+ view,
2732
+ decision: view,
2733
+ header: stateHeader(view, read),
2734
+ score,
2735
+ matched: [
2736
+ "id",
2737
+ "title",
2738
+ "body",
2739
+ "notes"
2740
+ ].filter((f) => hit.has(f)),
2741
+ excerpt: excerpt(lines, terms)
2742
+ });
2743
+ }
2744
+ found.sort((a, b) => b.score - a.score || STATUS_ORDER.indexOf(a.view.status) - STATUS_ORDER.indexOf(b.view.status) || compareViews(a.view, b.view));
2745
+ const results = found.slice(0, o.limit ?? Number.POSITIVE_INFINITY).map(({ view: _, ...r }) => r);
2746
+ const listed = new Set(results.map((r) => r.decision.id));
2747
+ return {
2748
+ format: "adr-search/v1",
2749
+ root: read.root,
2750
+ ref: read.ref,
2751
+ commit: read.commit,
2752
+ numbering: numberingJson(read),
2753
+ query: o.query.trim(),
2754
+ terms,
2755
+ include_inactive: includeInactive,
2756
+ total: found.length,
2757
+ inactive_omitted: inactive,
2758
+ results,
2759
+ findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
2760
+ notes: listingNotes(read)
2761
+ };
2762
+ }
2763
+ export { changedSince as A, headTree as C, renderDecisionIndex as D, indexPath as E, readDecisionsFrom as F, resolveDecision as I, sourcesNote as L, numberingNote as M, openDecisionLog as N, runDecisionIndex as O, readDecisions as P, DEFAULT_ADR_CONFIG as R, firstLine as S, indexDecisionLog as T, withdrawDecision as _, stateHeader as a, INDEX_AFTER as b, followScopeRenames as c, proposeDecision as d, rejectDecision as f, updateDraft as g, supersedeDecision as h, showDecision as i, numberDecisions as j, writeIndexCopy as k, linkDecision as l, scopeToCopy as m, decisionsFor as n, acceptDecision as o, scopeDecision as p, listDecisions as r, deprecateDecision as s, searchDecisions as t, noteDecision as u, BUILTIN_TEMPLATES as v, holdsText as w, decisionIndexCheck as x, DECISION_INDEX as y, parseAdrConfig as z };
1985
2764
 
1986
- //# sourceMappingURL=write-9IaqHRrY.js.map
2765
+ //# sourceMappingURL=search-CwIPlNGd.js.map