@nikovirtala/adr 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +70 -9
  2. package/dist/cli.js +821 -61
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.js +3 -3
  5. package/dist/{kind-DqFhApu7.js → kind-DjvA3dyA.js} +10 -23
  6. package/dist/kind-DjvA3dyA.js.map +1 -0
  7. package/dist/kind.js +1 -1
  8. package/dist/scope-CLqH-q1y.js +2873 -0
  9. package/dist/scope-CLqH-q1y.js.map +1 -0
  10. package/dist/types/cli/accept.d.ts +2 -2
  11. package/dist/types/cli/deprecate.d.ts +3 -0
  12. package/dist/types/cli/for.d.ts +3 -0
  13. package/dist/types/cli/hooks.d.ts +6 -0
  14. package/dist/types/cli/index-command.d.ts +3 -0
  15. package/dist/types/cli/init.d.ts +3 -0
  16. package/dist/types/cli/link.d.ts +3 -0
  17. package/dist/types/cli/list.d.ts +1 -1
  18. package/dist/types/cli/new.d.ts +1 -1
  19. package/dist/types/cli/note.d.ts +3 -0
  20. package/dist/types/cli/open.d.ts +2 -2
  21. package/dist/types/cli/reject.d.ts +3 -0
  22. package/dist/types/cli/scope.d.ts +7 -0
  23. package/dist/types/cli/supersede.d.ts +1 -1
  24. package/dist/types/cli/text.d.ts +39 -3
  25. package/dist/types/cli/withdraw.d.ts +3 -0
  26. package/dist/types/commit-actions.d.ts +72 -0
  27. package/dist/types/config.d.ts +16 -2
  28. package/dist/types/hooks.d.ts +59 -0
  29. package/dist/types/index.d.ts +6 -2
  30. package/dist/types/init.d.ts +33 -0
  31. package/dist/types/log-index.d.ts +117 -0
  32. package/dist/types/log.d.ts +34 -1
  33. package/dist/types/scope.d.ts +51 -0
  34. package/dist/types/verify.d.ts +6 -4
  35. package/dist/types/views.d.ts +9 -3
  36. package/dist/types/write.d.ts +206 -26
  37. package/package.json +2 -2
  38. package/dist/kind-DqFhApu7.js.map +0 -1
  39. package/dist/write-C3IW5WrA.js +0 -1011
  40. package/dist/write-C3IW5WrA.js.map +0 -1
@@ -1,1011 +0,0 @@
1
- import { t as adrKind } from "./kind-DqFhApu7.js";
2
- import { ConfigError, ENVELOPE_VERSION, EventExistsError, FileExistsError, InvalidArgumentError, UsageError, bodyFileName, bodySha256, classifyPath, compareEvents, compileKind, eventDir, eventPath, finding, foldChecks, foldLog, gitTemplates, historyTemplates, invariantsCheck, lintDocument, lintTemplate, loadFindings, loadLog, newSubjectId, nextEventIdentity, numberLog, openToolLog, overlayTree, parseEventFileName, parseTemplate, projectLog, relateCheck, renderTemplate, resolveSubjectInput, serializeEvent, slugify, sortFindings, templateHistoryRef, transitions, verifyLog, writeEvents, writeNewFile } from "@nikovirtala/ledger";
3
- import { readFile, rm } from "node:fs/promises";
4
- import { join } from "node:path";
5
- // src/config.ts
6
- const DEFAULT_ADR_CONFIG = Object.freeze({
7
- defaultTemplate: "nygard",
8
- mainBranch: null,
9
- index: "none",
10
- acceptRequiresHuman: true,
11
- autoCommit: false,
12
- mcpCanDecide: false,
13
- failOn: "error"
14
- });
15
- const TEMPLATE = /^[a-z][a-z0-9-]*$/;
16
- function isTable(v) {
17
- return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date);
18
- }
19
- /**
20
- * Validates `adr.toml` (ledger's parsed `ToolConfig`; `null` when there is none). A wrong type or
21
- * value throws `ConfigError` naming the file and key. Unknown keys are ignored, so newer files
22
- * still load. `[log] root` is ledger's (`openToolLog` already checked it).
23
- */
24
- function parseAdrConfig(config, file) {
25
- if (config === null) return DEFAULT_ADR_CONFIG;
26
- const where = file ?? config.file ?? "adr.toml";
27
- const fail = (message) => new ConfigError(`${where}: ${message}`, { file: where });
28
- const table = (name) => {
29
- const t = config.raw[name];
30
- if (t === void 0) return {};
31
- if (!isTable(t)) throw fail(`${name} must be a table`);
32
- return t;
33
- };
34
- const log = table("log");
35
- const policy = table("policy");
36
- const verify = table("verify");
37
- const bool = (t, section, key, fallback) => {
38
- const v = t[key];
39
- if (v === void 0) return fallback;
40
- if (typeof v !== "boolean") throw fail(`${section}.${key} must be true or false`);
41
- return v;
42
- };
43
- const oneOf = (t, section, key, values, fallback) => {
44
- const v = t[key];
45
- if (v === void 0) return fallback;
46
- if (typeof v !== "string" || !values.includes(v)) throw fail(`${section}.${key} must be ${values.map((x) => JSON.stringify(x)).join(" or ")}`);
47
- return v;
48
- };
49
- const template = log.default_template;
50
- if (template !== void 0 && (typeof template !== "string" || template.length > 64 || !TEMPLATE.test(template))) throw fail(`log.default_template must be a template name matching ${TEMPLATE.source}`);
51
- const main = log.main_branch;
52
- if (main !== void 0 && (typeof main !== "string" || main.trim() === "" || main.startsWith("-") || main.includes("\0"))) throw fail("log.main_branch must be a ref name that doesn't start with -");
53
- return {
54
- defaultTemplate: typeof template === "string" ? template : DEFAULT_ADR_CONFIG.defaultTemplate,
55
- mainBranch: typeof main === "string" ? main : null,
56
- index: oneOf(log, "log", "index", ["committed", "none"], "none"),
57
- acceptRequiresHuman: bool(policy, "policy", "accept_requires_human", true),
58
- autoCommit: bool(policy, "policy", "auto_commit", false),
59
- mcpCanDecide: bool(policy, "policy", "mcp_can_decide", false),
60
- failOn: oneOf(verify, "verify", "fail_on", ["error", "warning"], "error")
61
- };
62
- }
63
- // src/log.ts
64
- /**
65
- * Opens the decision log (design D3): `--root`, else `adr.toml`'s `[log] root`, else `docs/adr`;
66
- * read from the `ref` tree, the index or the work tree.
67
- */
68
- async function openDecisionLog(o) {
69
- const log = await openToolLog({
70
- ...o,
71
- tool: "adr",
72
- kind: adrKind
73
- });
74
- return {
75
- ...log,
76
- adr: parseAdrConfig(log.config, log.configPath)
77
- };
78
- }
79
- /** `numberLog` on the repository, then `readDecisionsFrom(log.tree, …)`. */
80
- async function readDecisions(log) {
81
- const numbering = await numberLog(log.git, {
82
- kind: adrKind,
83
- layout: log.layout,
84
- mainRef: log.adr.mainBranch
85
- });
86
- return readDecisionsFrom(log.tree, {
87
- layout: log.layout,
88
- adr: log.adr,
89
- numbering,
90
- ref: log.ref,
91
- commit: log.commit,
92
- notes: log.notes
93
- });
94
- }
95
- const compiled$1 = compileKind(adrKind);
96
- /** The git-free core of `readDecisions`; tests pass a MemoryTree and a hand-made `Numbering`. */
97
- async function readDecisionsFrom(tree, o) {
98
- const { layout, adr, numbering } = o;
99
- const snapshot = await loadLog(tree, {
100
- kind: compiled$1,
101
- layout
102
- });
103
- const folded = foldLog(adrKind, snapshot);
104
- const ctx = {
105
- kind: compiled$1,
106
- layout,
107
- base: null,
108
- head: snapshot,
109
- changes: [],
110
- scope: "all",
111
- inScope: new Set(snapshot.subjects.keys()),
112
- folded: {
113
- head: folded,
114
- base: null
115
- }
116
- };
117
- const options = { acceptRequiresHuman: adr.acceptRequiresHuman };
118
- const findings = [];
119
- for (const check of [
120
- loadFindings,
121
- transitions,
122
- invariantsCheck(options),
123
- relateCheck(options)
124
- ]) findings.push(...await check.run(ctx));
125
- return {
126
- tree,
127
- layout,
128
- adr,
129
- root: layout.root,
130
- ref: o.ref ?? null,
131
- commit: o.commit ?? null,
132
- folded,
133
- projection: projectLog(adrKind, folded, { numbering }),
134
- numbering,
135
- findings: sortFindings(findings.map((f) => ({
136
- ...f,
137
- kind: adrKind.name
138
- }))),
139
- notes: o.notes ?? []
140
- };
141
- }
142
- /** Why numbers are unavailable, in words: `shallow clone` or `no main branch`. */
143
- function unavailableReason(n) {
144
- return n.reason === "shallow" ? "shallow clone" : "no main branch";
145
- }
146
- /**
147
- * The id a decision argument names: an id, or a number in any form `parseDecisionNumber` takes.
148
- * Throws `UsageError` (exit 2) naming the reason when it names nothing.
149
- */
150
- function resolveDecision(read, input) {
151
- const r = resolveSubjectInput(input, {
152
- subjects: new Set(read.folded.subjects.keys()),
153
- kind: adrKind,
154
- numbering: read.numbering
155
- });
156
- if (r.ok) return r.id;
157
- const shown = JSON.stringify(input.trim());
158
- switch (r.reason) {
159
- case "malformed": throw new UsageError(`invalid decision ${JSON.stringify(input)}`);
160
- case "qualified": throw new UsageError(`decision ${shown} is in another log; cross-log references arrive in phase 2`);
161
- case "numbers-unavailable": throw new UsageError(`decision numbers are unavailable (${unavailableReason(read.numbering)}); use the decision's id instead of ${shown}`);
162
- case "not-found": throw new UsageError(`unknown decision ${input.trim()}`);
163
- }
164
- }
165
- // src/verify.ts
166
- /**
167
- * Every phase-1 check for the log: ledger's mechanical and fold checks with adr's actor policy,
168
- * and template lint against the templates in the head tree and, for older versions, the history
169
- * of `historyRef`.
170
- */
171
- function adrChecks(log, historyRef) {
172
- return foldChecks(adrKind, {
173
- options: { acceptRequiresHuman: log.adr.acceptRequiresHuman },
174
- templates: gitTemplates(log.git, { historyRef })
175
- });
176
- }
177
- /** Verifies the decision log in `mode` (staged, range or full) with `adrChecks`. */
178
- async function verifyDecisions(log, mode) {
179
- return verifyLog({
180
- git: log.git,
181
- kind: adrKind,
182
- layout: log.layout,
183
- mode,
184
- checks: adrChecks(log, await templateHistoryRef(log.git, mode))
185
- });
186
- }
187
- // src/views.ts
188
- const numberingJson = (read) => ({
189
- available: read.numbering.available,
190
- reason: read.numbering.reason,
191
- ref: read.numbering.ref
192
- });
193
- /** Numbered decisions by number, then unnumbered ones by `proposed_at`, then id. */
194
- function compareViews(a, b) {
195
- if (a.number !== null || b.number !== null) {
196
- if (a.number === null) return 1;
197
- if (b.number === null) return -1;
198
- return a.number - b.number;
199
- }
200
- if (a.proposed_at !== b.proposed_at) return a.proposed_at < b.proposed_at ? -1 : 1;
201
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
202
- }
203
- /**
204
- * The `adr-list/v1` document: decisions in list order, filtered by `status` and truncated to
205
- * `limit` after ordering. `findings` are those of the listed decisions and of the log as a whole.
206
- */
207
- function listDecisions(read, o = {}) {
208
- const statuses = o.status === void 0 || o.status.length === 0 ? null : new Set(o.status);
209
- const decisions = [...read.projection.views.values()].filter((v) => statuses === null || statuses.has(v.status)).sort(compareViews).slice(0, o.limit ?? Number.POSITIVE_INFINITY);
210
- const listed = new Set(decisions.map((v) => v.id));
211
- return {
212
- format: "adr-list/v1",
213
- root: read.root,
214
- ref: read.ref,
215
- commit: read.commit,
216
- numbering: numberingJson(read),
217
- decisions,
218
- unreadable: read.projection.unreadable,
219
- unfoldable: read.projection.unfoldable,
220
- findings: read.findings.filter((f) => f.subject === null || listed.has(f.subject)),
221
- notes: read.notes
222
- };
223
- }
224
- const decoder$1 = new TextDecoder();
225
- /**
226
- * The `adr-show/v1` document of the decision `id` (resolve it with `resolveDecision` first).
227
- * Throws `UsageError` for an unknown, unreadable or unfoldable decision.
228
- */
229
- async function showDecision(read, id, o = {}) {
230
- const view = read.projection.views.get(id);
231
- if (view === void 0) {
232
- if (read.projection.unreadable.includes(id)) throw new UsageError(`decision ${id} uses a newer format; upgrade adr`);
233
- if (read.projection.unfoldable.includes(id)) throw new UsageError(`decision ${id} can't be read; run adr verify`);
234
- throw new UsageError(`unknown decision ${id}`);
235
- }
236
- const loaded = read.folded.subjects.get(id)?.loaded;
237
- const body = loaded?.body ?? null;
238
- const events = o.history === true ? (loaded?.events ?? []).flatMap((e) => e.event === null ? [] : [{
239
- path: e.path,
240
- event: e.event
241
- }]).sort((a, b) => compareEvents({
242
- ...a.event,
243
- path: a.path
244
- }, {
245
- ...b.event,
246
- path: b.path
247
- })).map((e) => e.event) : null;
248
- return {
249
- format: "adr-show/v1",
250
- root: read.root,
251
- ref: read.ref,
252
- commit: read.commit,
253
- numbering: numberingJson(read),
254
- header: stateHeader(view, read),
255
- decision: view,
256
- body: body === null ? null : decoder$1.decode(body.bytes),
257
- events,
258
- findings: read.findings.filter((f) => f.subject === id),
259
- notes: read.notes
260
- };
261
- }
262
- const day = (at) => (at ?? "").slice(0, 10);
263
- /** The reason on the applied event that produced the decision's current status, if any. */
264
- function statusReason(read, id) {
265
- const reason = (read.folded.subjects.get(id)?.applied ?? []).findLast((a) => a.from !== a.to)?.event.reason;
266
- return typeof reason === "string" && reason.trim() !== "" ? reason.trim() : null;
267
- }
268
- /**
269
- * The state header (01:258–259): `<display> (<id>) · <status>`, and for a decision that isn't in
270
- * force a warning saying what to read instead. `refer` names where to read another decision. CLI
271
- * default: `display(id)`. Phase 3 MCP passes `id => "adr://decision/" + display(id)`, e.g.
272
- * `adr://decision/ADR-0012`, or the id when unnumbered.
273
- */
274
- function stateHeader(view, read, o = {}) {
275
- const display = (id) => read.projection.views.get(id)?.display ?? id;
276
- const refer = o.refer ?? display;
277
- const line = view.number === null ? `${view.id} · ${view.status}` : `${view.display} (${view.id}) · ${view.status}`;
278
- const parts = [];
279
- const reason = statusReason(read, view.id);
280
- const when = day(view.status_at);
281
- switch (view.status) {
282
- case "superseded": {
283
- const target = view.effective ?? view.superseded_by ?? view.id;
284
- const accepted = read.projection.views.get(target)?.decided_at ?? null;
285
- const date = accepted === null ? "" : ` (accepted ${day(accepted)})`;
286
- parts.push(`SUPERSEDED by ${display(target)}${date}. Do not apply this decision; read ${refer(target)} instead.`);
287
- if (view.chain.length > 2) parts.push(`Chain: ${view.chain.map(display).join(" → ")}.`);
288
- break;
289
- }
290
- case "deprecated":
291
- parts.push(`DEPRECATED on ${when}: ${reason ?? "no reason given"}. No decision replaces it.`);
292
- break;
293
- case "rejected":
294
- parts.push(`REJECTED on ${when}: ${reason ?? "no reason given"}. This decision was never in force.`);
295
- break;
296
- case "withdrawn": parts.push(`WITHDRAWN on ${when}${reason === null ? "" : `: ${reason}`}. This decision was never in force.`);
297
- }
298
- if (view.body_intact === false) parts.push("WARNING: the body changed after acceptance; run adr verify.");
299
- return {
300
- line,
301
- warning: parts.length === 0 ? null : parts.join(" ")
302
- };
303
- }
304
- /** The built-in templates by name: the file text `adr new` materializes. */
305
- const BUILTIN_TEMPLATES = Object.freeze({
306
- nygard: `---
307
- template: nygard
308
- version: 1
309
- sections:
310
- - id: context
311
- heading: Context
312
- required: true
313
- guidance: What is the issue that motivates this decision? Describe the forces at play.
314
- - id: decision
315
- heading: Decision
316
- required: true
317
- guidance: What is the change that we're proposing or doing? Write it in the active voice.
318
- - id: scope
319
- heading: Scope
320
- required: true
321
- guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
322
- - id: consequences
323
- heading: Consequences
324
- required: true
325
- guidance: What becomes easier or harder because of this change? Include the risks.
326
- ---
327
- # {{title}}
328
-
329
- ## Context
330
- {{context}}
331
-
332
- ## Decision
333
- {{decision}}
334
-
335
- ## Scope
336
- {{scope}}
337
-
338
- ## Consequences
339
- {{consequences}}
340
- `,
341
- madr: `---
342
- template: madr
343
- version: 1
344
- sections:
345
- - id: context
346
- heading: Context and Problem Statement
347
- required: true
348
- guidance: Describe the context and the problem in two or three sentences, or as a question.
349
- - id: drivers
350
- heading: Decision Drivers
351
- required: false
352
- guidance: List the forces and concerns that shape the decision.
353
- - id: options
354
- heading: Considered Options
355
- required: true
356
- guidance: List the options that were considered, one per line.
357
- - id: decision
358
- heading: Decision Outcome
359
- required: true
360
- guidance: Name the chosen option and say why it was chosen.
361
- - id: scope
362
- heading: Scope
363
- required: true
364
- guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
365
- - id: consequences
366
- heading: Consequences
367
- required: true
368
- guidance: Describe the good and the bad consequences of the chosen option.
369
- ---
370
- # {{title}}
371
-
372
- ## Context and Problem Statement
373
- {{context}}
374
-
375
- ## Decision Drivers
376
- {{drivers}}
377
-
378
- ## Considered Options
379
- {{options}}
380
-
381
- ## Decision Outcome
382
- {{decision}}
383
-
384
- ## Scope
385
- {{scope}}
386
-
387
- ## Consequences
388
- {{consequences}}
389
- `,
390
- "y-statement": `---
391
- template: y-statement
392
- version: 1
393
- sections:
394
- - id: statement
395
- heading: Statement
396
- required: true
397
- guidance: In the context of <use case>, facing <concern>, we decided for <option>, to achieve <quality>, accepting <downside>.
398
- - id: scope
399
- heading: Scope
400
- required: true
401
- guidance: What does this decision govern? Name the systems, code paths or teams it applies to.
402
- ---
403
- # {{title}}
404
-
405
- ## Statement
406
- {{statement}}
407
-
408
- ## Scope
409
- {{scope}}
410
- `
411
- });
412
- // src/write.ts
413
- const encoder = new TextEncoder();
414
- const decoder = new TextDecoder();
415
- compileKind(adrKind);
416
- const TEMPLATE_NAME = /^[a-z][a-z0-9-]*$/;
417
- const MAX_TEXT_BYTES = 65536;
418
- const SUBJECT_ATTEMPTS = 5;
419
- const QUALIFIED_SUPERSESSION = "cross-log supersession arrives in phase 2";
420
- const sorted = (xs) => [...new Set(xs)].sort();
421
- const templatePath = (read, name) => `${read.root}/templates/${name}.md`;
422
- /** A text argument within the 64 KiB cap and without NUL characters, else `UsageError`. */
423
- function checkText(what, text) {
424
- if (text.includes("\0")) throw new UsageError(`${what} must not contain NUL characters`);
425
- const size = encoder.encode(text).byteLength;
426
- if (size > MAX_TEXT_BYTES) throw new UsageError(`${what} is ${size} bytes; the limit is ${MAX_TEXT_BYTES} (64 KiB)`);
427
- }
428
- function checkReason(reason) {
429
- if (reason === void 0 || reason === null) return null;
430
- const r = reason.trim();
431
- if (r === "") throw new UsageError("--reason must not be empty");
432
- checkText("--reason", r);
433
- return r;
434
- }
435
- /** The view of a folded decision, else `UsageError` naming why it can't be used. */
436
- function viewOf(read, id) {
437
- const view = read.projection.views.get(id);
438
- if (view !== void 0) return view;
439
- if (read.projection.unreadable.includes(id)) throw new UsageError(`decision ${id} uses a newer format; upgrade adr`);
440
- throw new UsageError(`decision ${id} can't be read; run adr verify`);
441
- }
442
- /** A refusal finding about decision `subject`, on its body (or events directory). */
443
- function refusal(read, rule, message, subject) {
444
- const loaded = read.folded.subjects.get(subject)?.loaded;
445
- return finding({
446
- severity: "error",
447
- rule,
448
- message,
449
- file: loaded?.bodyPaths[0] ?? eventDir(read.layout, subject),
450
- subject,
451
- kind: adrKind.name
452
- });
453
- }
454
- /** `"<type> is not allowed while <status>[; <status> is terminal]"`, as the fold words it. */
455
- function illegal(type, status) {
456
- return `${type} is not allowed while ${status}${adrKind.machine.terminal.includes(status) ? `; ${status} is terminal` : ""}`;
457
- }
458
- /** A new event of `subject`, sorting after its existing ones. */
459
- function newEvent(read, c, o) {
460
- const existing = (read.folded.subjects.get(o.subject)?.loaded.events ?? []).flatMap((e) => e.event === null ? [] : [e.event]);
461
- const { at, id } = nextEventIdentity([...existing, ...o.after ?? []], {
462
- now: c.now,
463
- ...c.random === void 0 ? {} : { random: c.random }
464
- });
465
- return {
466
- envelope: ENVELOPE_VERSION,
467
- kind: adrKind.name,
468
- schema: adrKind.schema,
469
- id,
470
- subject: o.subject,
471
- type: o.type,
472
- at,
473
- actor: o.actor,
474
- ...o.reason === void 0 || o.reason === null ? {} : { reason: o.reason },
475
- data: o.data
476
- };
477
- }
478
- /** Body, events directory and recorded template of each involved subject (D6). */
479
- function commitScope(read, involved, extra = {}) {
480
- const paths = [];
481
- const dirs = [];
482
- for (const id of involved) {
483
- const view = read.projection.views.get(id);
484
- if (view?.body_path !== null && view?.body_path !== void 0) paths.push(view.body_path);
485
- if (view !== void 0) paths.push(templatePath(read, view.template.name));
486
- dirs.push(eventDir(read.layout, id));
487
- }
488
- if (extra.body !== void 0) paths.push(extra.body);
489
- if (extra.template !== void 0) paths.push(extra.template);
490
- return {
491
- scope: sorted([...paths, ...dirs]),
492
- eventDirs: sorted(dirs)
493
- };
494
- }
495
- const findingKey = (f) => [
496
- f.rule,
497
- f.subject ?? "",
498
- f.file ?? "",
499
- f.message
500
- ].join("\0");
501
- /**
502
- * The precheck (§6.3): folds the would-be log and refuses when the write adds any finding. Problems
503
- * that were already there don't block; the write can only add findings about what it touches.
504
- */
505
- async function precheck(read, files) {
506
- const after = await readDecisionsFrom(overlayTree(read.tree, files), {
507
- layout: read.layout,
508
- adr: read.adr,
509
- numbering: read.numbering,
510
- ref: read.ref,
511
- commit: read.commit,
512
- notes: read.notes
513
- });
514
- const before = new Set(read.findings.map(findingKey));
515
- return {
516
- after,
517
- added: after.findings.filter((f) => !before.has(findingKey(f)))
518
- };
519
- }
520
- function filesOf(events, read) {
521
- return Object.fromEntries(events.map((e) => [eventPath(read.layout, e), encoder.encode(serializeEvent(e))]));
522
- }
523
- const pairsOf = (events) => events.flatMap((e) => [["ADR", e.subject], ["ADR-Event", e.type]]);
524
- /** The template `new` uses: the repo's `<root>/templates/<name>.md`, else a built-in to copy. */
525
- async function chooseTemplate(read, name, c) {
526
- if (name.length > 64 || !TEMPLATE_NAME.test(name)) throw new UsageError(`invalid template name ${JSON.stringify(name)}`);
527
- const path = templatePath(read, name);
528
- const bytes = await read.tree.read(path);
529
- let text;
530
- let materialize = null;
531
- if (bytes !== null) text = decoder.decode(bytes);
532
- else {
533
- const builtin = Object.hasOwn(c.builtins, name) ? c.builtins[name] : void 0;
534
- if (builtin === void 0) {
535
- const known = Object.keys(c.builtins).sort().join(", ");
536
- throw new UsageError(`unknown template ${name}; add ${path} or use one of ${known}`);
537
- }
538
- text = builtin;
539
- materialize = builtin;
540
- }
541
- const parsed = parseTemplate(text, { path });
542
- if (!parsed.ok) return {
543
- ok: false,
544
- findings: parsed.findings
545
- };
546
- const lint = lintTemplate(parsed.template, { requireSections: adrKind.def.templates?.requireSections ?? [] });
547
- if (lint.length > 0) return {
548
- ok: false,
549
- findings: lint
550
- };
551
- return {
552
- ok: true,
553
- template: parsed.template,
554
- materialize,
555
- path
556
- };
557
- }
558
- /** Plans `adr new`: a rendered body, a materialized template if needed, and `proposed`. */
559
- async function planPropose(read, input, c) {
560
- const title = input.title.trim();
561
- if (title === "" || title.length > 200 || /[\r\n]/.test(title)) throw new UsageError("the title must be a single line of 1 to 200 characters");
562
- const supersedes = sorted((input.supersedes ?? []).map((s) => {
563
- if (s.includes(":")) throw new UsageError(`cannot supersede ${s.trim()}: ${QUALIFIED_SUPERSESSION}`);
564
- return resolveDecision(read, s);
565
- }));
566
- const chosen = await chooseTemplate(read, input.template ?? read.adr.defaultTemplate, c);
567
- if (!chosen.ok) return {
568
- ok: false,
569
- decision: null,
570
- findings: chosen.findings
571
- };
572
- const t = chosen.template;
573
- const sections = input.sections ?? {};
574
- for (const [id, text] of Object.entries(sections)) {
575
- if (!t.sections.some((s) => s.id === id)) {
576
- const ids = t.sections.map((s) => s.id).join(", ");
577
- throw new UsageError(`template ${t.name} has no section ${id}; its sections are ${ids}`);
578
- }
579
- checkText(`section ${id}`, text);
580
- }
581
- const rendered = renderTemplate(t, {
582
- title,
583
- sections
584
- }, { missing: "guidance" });
585
- if (!rendered.ok) throw new UsageError(rendered.findings.map((f) => f.message).join("; "));
586
- let id = "";
587
- let bodyPath = "";
588
- for (let attempt = 0;; attempt++) {
589
- id = newSubjectId(c.random);
590
- bodyPath = `${read.root}/${read.layout.bodies}/${bodyFileName(id, slugify(title))}`;
591
- if (!read.folded.subjects.has(id) && await read.tree.read(bodyPath) === null) break;
592
- if (attempt + 1 >= SUBJECT_ATTEMPTS) throw new FileExistsError(bodyPath);
593
- }
594
- const event = newEvent(read, c, {
595
- subject: id,
596
- type: "proposed",
597
- data: {
598
- title,
599
- template: t.name,
600
- template_version: t.version,
601
- ...supersedes.length === 0 ? {} : { supersedes }
602
- },
603
- actor: input.actor
604
- });
605
- const body = encoder.encode(rendered.text);
606
- const files = { [bodyPath]: body };
607
- if (chosen.materialize !== null) files[chosen.path] = encoder.encode(chosen.materialize);
608
- Object.assign(files, filesOf([event], read));
609
- const { after, added } = await precheck(read, files);
610
- if (added.length > 0) return {
611
- ok: false,
612
- decision: null,
613
- findings: added
614
- };
615
- const { scope, eventDirs } = commitScope(read, [id], {
616
- body: bodyPath,
617
- template: chosen.path
618
- });
619
- return {
620
- ok: true,
621
- plan: {
622
- command: "new",
623
- subject: id,
624
- files,
625
- events: [event],
626
- stage: Object.keys(files),
627
- involved: [id],
628
- commitScope: scope,
629
- eventDirs,
630
- draft: {
631
- path: bodyPath,
632
- rendered: body,
633
- template: t
634
- },
635
- message: `Propose decision ${id}: ${title}`,
636
- trailers: pairsOf([event]),
637
- decision: viewOf(after, id),
638
- affected: [],
639
- findings: lintDocument(t, rendered.text, {
640
- file: bodyPath,
641
- subject: id
642
- })
643
- }
644
- };
645
- }
646
- /** The draft lint of an `accept`: the body against the template version its proposal recorded. */
647
- async function acceptLint(read, view, c) {
648
- const body = read.folded.subjects.get(view.id)?.loaded.body ?? null;
649
- if (body === null) return {
650
- findings: [refusal(read, "body/missing", "body file is missing", view.id)],
651
- bytes: null
652
- };
653
- const { name, version } = view.template;
654
- const found = await c.templates.get(name, version);
655
- if (!found.ok) {
656
- const message = found.reason === "not-found" ? `template ${name} not found at ${found.path}` : `template ${name} has no version ${version} at ${found.path}`;
657
- return {
658
- findings: [refusal(read, `template/${found.reason}`, message, view.id)],
659
- bytes: body.bytes
660
- };
661
- }
662
- let text;
663
- try {
664
- text = new TextDecoder("utf-8", { fatal: true }).decode(body.bytes);
665
- } catch {
666
- return {
667
- findings: [refusal(read, "body/not-utf8", "body is not valid UTF-8", view.id)],
668
- bytes: body.bytes
669
- };
670
- }
671
- return {
672
- findings: lintDocument(found.template, text, {
673
- file: body.path,
674
- subject: view.id
675
- }).map((f) => ({
676
- ...f,
677
- kind: adrKind.name
678
- })),
679
- bytes: body.bytes
680
- };
681
- }
682
- /** Plans `adr accept`: `accepted` with the body hash, and `superseded` on each declared target. */
683
- async function planAccept(read, input, c) {
684
- const id = resolveDecision(read, input.id);
685
- const view = viewOf(read, id);
686
- const refused = (findings) => ({
687
- ok: false,
688
- decision: view,
689
- findings
690
- });
691
- const findings = [];
692
- if (view.status !== "proposed") findings.push(refusal(read, "transition/illegal", illegal("accepted", view.status), id));
693
- const lint = await acceptLint(read, view, c);
694
- findings.push(...lint.findings);
695
- if (read.adr.acceptRequiresHuman && input.actor.kind === "agent") findings.push(refusal(read, "decision/agent-accepted", `accepted by agent ${input.actor.name}; accept_requires_human needs a human`, id));
696
- const targets = [];
697
- for (const target of view.declared_supersedes) {
698
- if (target.includes(":")) {
699
- findings.push(refusal(read, "decision/reference-missing", `supersedes ${target}: ${QUALIFIED_SUPERSESSION}`, id));
700
- continue;
701
- }
702
- const t = read.projection.views.get(target);
703
- if (t === void 0) findings.push(refusal(read, "decision/reference-missing", `supersedes ${target}, which is not in the log`, id));
704
- else if (target === id) findings.push(refusal(read, "decision/supersession-cycle", `supersession cycle: ${id} → ${id}`, id));
705
- else if (t.status !== "accepted" && t.status !== "deprecated") findings.push(refusal(read, "transition/illegal", illegal("superseded", t.status), target));
706
- else targets.push(target);
707
- }
708
- if (findings.length > 0 || lint.bytes === null) return refused(findings);
709
- const events = [newEvent(read, c, {
710
- subject: id,
711
- type: "accepted",
712
- data: { body_sha256: bodySha256(lint.bytes) },
713
- actor: input.actor
714
- }), ...targets.map((target) => newEvent(read, c, {
715
- subject: target,
716
- type: "superseded",
717
- data: { by: id },
718
- actor: input.actor
719
- }))];
720
- const files = filesOf(events, read);
721
- const { after, added } = await precheck(read, files);
722
- if (added.length > 0) return refused(added);
723
- const involved = sorted([id, ...targets]);
724
- const { scope, eventDirs } = commitScope(read, involved);
725
- const bodyPath = view.body_path ?? "";
726
- return {
727
- ok: true,
728
- plan: {
729
- command: "accept",
730
- subject: id,
731
- files,
732
- events,
733
- stage: [...Object.keys(files), bodyPath],
734
- involved,
735
- commitScope: scope,
736
- eventDirs,
737
- draft: null,
738
- message: [`Accept decision ${id}: ${view.title}`, ...targets.length === 0 ? [] : ["", ...targets.map((t) => `Supersedes ${t}.`)]].join("\n"),
739
- trailers: pairsOf(events),
740
- decision: viewOf(after, id),
741
- affected: targets.map((t) => viewOf(after, t)),
742
- findings: []
743
- }
744
- };
745
- }
746
- /** Plans `adr supersede <id> --by <by>`: one `superseded` event on `id`. */
747
- async function planSupersede(read, input, c) {
748
- if (input.by.includes(":")) throw new UsageError(`--by ${input.by.trim()}: ${QUALIFIED_SUPERSESSION}`);
749
- const reason = checkReason(input.reason);
750
- const id = resolveDecision(read, input.id);
751
- const by = resolveDecision(read, input.by);
752
- const view = viewOf(read, id);
753
- const target = viewOf(read, by);
754
- const findings = [];
755
- if (view.status !== "accepted" && view.status !== "deprecated") findings.push(refusal(read, "transition/illegal", illegal("superseded", view.status), id));
756
- if (by === id || target.chain.includes(id)) {
757
- const cycle = by === id ? [id, id] : [id, ...target.chain.slice(0, target.chain.indexOf(id) + 1)];
758
- findings.push(refusal(read, "decision/supersession-cycle", `supersession cycle: ${cycle.join(" → ")}`, id));
759
- } else if (target.status !== "accepted") findings.push(refusal(read, "decision/superseded-by-unaccepted", `superseded by ${by}, which is not accepted`, id));
760
- const refused = (fs) => ({
761
- ok: false,
762
- decision: view,
763
- findings: fs
764
- });
765
- if (findings.length > 0) return refused(findings);
766
- const event = newEvent(read, c, {
767
- subject: id,
768
- type: "superseded",
769
- data: { by },
770
- reason,
771
- actor: input.actor
772
- });
773
- const files = filesOf([event], read);
774
- const { after, added } = await precheck(read, files);
775
- if (added.length > 0) return refused(added);
776
- const involved = sorted([id, by]);
777
- const { scope, eventDirs } = commitScope(read, involved);
778
- return {
779
- ok: true,
780
- plan: {
781
- command: "supersede",
782
- subject: id,
783
- files,
784
- events: [event],
785
- stage: Object.keys(files),
786
- involved,
787
- commitScope: scope,
788
- eventDirs,
789
- draft: null,
790
- message: `Supersede decision ${id}: ${view.title}\n\nSuperseded by ${by}.`,
791
- trailers: pairsOf([event]),
792
- decision: viewOf(after, id),
793
- affected: [viewOf(after, id)],
794
- findings: []
795
- }
796
- };
797
- }
798
- const isYaml = (path) => /\.ya?ml$/.test(path);
799
- /**
800
- * Applies a plan (§5.4): refuses a commit whose scope has unstaged or needed untracked paths,
801
- * writes the files (removing them again if a write fails), stages them, runs `afterStage`, and
802
- * commits `plan.stage` plus the involved subjects' already-staged files with exact trailers.
803
- */
804
- async function applyPlan(log, plan, o) {
805
- const { git } = log;
806
- const stage = new Set(plan.stage);
807
- if (o.commit) {
808
- const unstaged = new Set(await git.unstagedPaths(plan.commitScope));
809
- const dirs = new Set(plan.eventDirs);
810
- const untracked = /* @__PURE__ */ new Set([...await git.untrackedPaths(plan.commitScope.filter((p) => !dirs.has(p))), ...(await git.untrackedPaths(plan.eventDirs)).filter(isYaml)]);
811
- const [first] = sorted([...unstaged, ...untracked]).filter((p) => !stage.has(p));
812
- 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`);
813
- }
814
- const eventPaths = new Set(plan.events.map((e) => eventPath(log.layout, e)));
815
- const written = [];
816
- try {
817
- for (const [path, bytes] of Object.entries(plan.files)) {
818
- if (eventPaths.has(path)) continue;
819
- await writeNewFile(git, path, bytes, { stage: false });
820
- written.push(path);
821
- }
822
- await writeEvents(git, log.layout, plan.events, {
823
- kind: log.compiled,
824
- stage: false
825
- });
826
- } catch (err) {
827
- const root = git.root ?? "";
828
- for (const path of written) await rm(join(root, path), { force: true });
829
- throw err;
830
- }
831
- await git.add(plan.stage);
832
- let findings = plan.findings;
833
- let commit = o.commit;
834
- if (o.afterStage !== void 0) {
835
- const after = await o.afterStage();
836
- findings = after.findings ?? plan.findings;
837
- commit = commit && after.commit;
838
- }
839
- let sha = null;
840
- if (commit) {
841
- const extra = (await git.stagedPaths(plan.commitScope)).filter((p) => !stage.has(p));
842
- const staged = extra.filter(isYaml).flatMap((p) => {
843
- const c = classifyPath(log.layout, p);
844
- const name = c.area === "event" && !c.file.includes("/") ? parseEventFileName(c.file) : null;
845
- return c.area === "event" && c.subject !== null && name !== null ? [["ADR", c.subject], ["ADR-Event", name.type]] : [];
846
- });
847
- sha = await git.commit({
848
- message: plan.message,
849
- trailers: [...staged, ...plan.trailers],
850
- only: sorted([...plan.stage, ...extra])
851
- });
852
- }
853
- return {
854
- written: Object.keys(plan.files),
855
- commit: sha,
856
- findings
857
- };
858
- }
859
- const DEFAULT_DEPS = {
860
- now: Date.now,
861
- apply: applyPlan
862
- };
863
- /**
864
- * The views `adr-write/v1` reports. Without a commit they are the plan's, folded on the overlay.
865
- * After a commit they come from a new read of the log, so a commit on the main ref shows the
866
- * numbers it assigned, as the CLI text and phase 3's MCP tools do. A commit that failed threw in
867
- * `applyPlan`, so nothing is read for it.
868
- */
869
- async function viewsAfter(log, plan, sha) {
870
- if (sha === null) return {
871
- decision: plan.decision,
872
- affected: plan.affected
873
- };
874
- const read = await readDecisions({
875
- ...log,
876
- commit: sha
877
- });
878
- const view = (v) => read.projection.views.get(v.id) ?? v;
879
- return {
880
- decision: view(plan.decision),
881
- affected: plan.affected.map(view)
882
- };
883
- }
884
- /** Plan, then apply, with the single `EventExistsError` retry (§5.4 steps 1–5). */
885
- async function write(log, command, plan, o, deps, afterStage) {
886
- if (log.ref !== null) throw new InvalidArgumentError("writes read the work tree; open the log without a ref");
887
- if (log.tree.label !== "worktree") throw new InvalidArgumentError("writes read the work tree; open the log with the worktree reader");
888
- const commit = o.commit ?? log.adr.autoCommit;
889
- for (let attempt = 0;; attempt++) {
890
- const read = await readDecisions(log);
891
- const ref = await templateHistoryRef(log.git, { type: "full" });
892
- const r = await plan(read, {
893
- now: deps.now,
894
- ...deps.random === void 0 ? {} : { random: deps.random },
895
- templates: historyTemplates(log.git, log.tree, `${read.root}/templates`, { ref }),
896
- builtins: BUILTIN_TEMPLATES
897
- });
898
- if (!r.ok) return {
899
- format: "adr-write/v1",
900
- command,
901
- ok: false,
902
- decision: r.decision,
903
- affected: [],
904
- written: [],
905
- commit: null,
906
- findings: r.findings,
907
- notes: log.notes
908
- };
909
- try {
910
- const applied = await deps.apply(log, r.plan, {
911
- commit,
912
- ...afterStage === void 0 ? {} : { afterStage: afterStage(r.plan, commit) }
913
- });
914
- const views = await viewsAfter(log, r.plan, applied.commit);
915
- return {
916
- format: "adr-write/v1",
917
- command,
918
- ok: true,
919
- decision: views.decision,
920
- affected: views.affected,
921
- written: applied.written,
922
- commit: applied.commit,
923
- findings: applied.findings,
924
- notes: log.notes
925
- };
926
- } catch (err) {
927
- if (!(err instanceof EventExistsError) || attempt > 0) throw err;
928
- }
929
- }
930
- }
931
- const sameBytes = (a, b) => a.byteLength === b.byteLength && a.every((x, i) => x === b[i]);
932
- async function readIfPresent(path) {
933
- try {
934
- return await readFile(path);
935
- } catch (err) {
936
- if (err.code === "ENOENT") return null;
937
- throw err;
938
- }
939
- }
940
- /**
941
- * The editor step (§5.4): runs `edit` on the staged draft, re-stages the edited body when the
942
- * editor exited 0, and skips the commit when committing would take content nobody confirmed: the
943
- * body changed while the editor failed, the body is gone, or another path in the commit scope (a
944
- * materialized template, say) changed while the editor was open (design review NIT 2). The
945
- * findings are the lint of the draft as it now is.
946
- */
947
- function editorStep(log, edit, onNote) {
948
- return (plan, commitRequested) => async () => {
949
- const d = plan.draft;
950
- if (d === null) return {
951
- findings: null,
952
- commit: true
953
- };
954
- const abs = join(log.git.root ?? "", d.path);
955
- const note = await edit(abs);
956
- const bytes = await readIfPresent(abs);
957
- let commit;
958
- if (bytes === null) {
959
- commit = false;
960
- onNote?.(`${d.path} was removed while editing; the rendered draft is still staged. Restore it with \`git checkout -- ${d.path}\``);
961
- } else if (note === null) {
962
- await log.git.add([d.path]);
963
- commit = true;
964
- } else if (sameBytes(bytes, d.rendered)) commit = true;
965
- else {
966
- commit = false;
967
- if (commitRequested) onNote?.(`${d.path} changed while the editor failed; review it, then git add it and commit`);
968
- }
969
- if (commit && commitRequested) {
970
- const [first] = (await log.git.unstagedPaths(plan.commitScope)).filter((p) => p !== d.path);
971
- if (first !== void 0) {
972
- commit = false;
973
- onNote?.(`${first} changed while the editor was open; review it, then git add it and commit`);
974
- }
975
- }
976
- const text = decoder.decode(bytes ?? d.rendered);
977
- return {
978
- findings: lintDocument(d.template, text, {
979
- file: d.path,
980
- subject: plan.subject
981
- }),
982
- commit
983
- };
984
- };
985
- }
986
- /**
987
- * `adr new`: renders a draft, writes the body, any materialized template and the `proposed`
988
- * event, stages them, and commits with `commit` (default `[policy] auto_commit`).
989
- *
990
- * `edit` (CLI only; MCP never passes it) runs between staging and committing, on the absolute
991
- * body path. It resolves to `null` when the editor exited 0, else to a note for the user (spawn
992
- * error, exit status). `onNote` receives the notes about a skipped commit or a removed body,
993
- * without the "not committed: " prefix the CLI adds when a commit was requested.
994
- */
995
- async function proposeDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
996
- return write(log, "new", (read, c) => planPropose(read, input, c), o, deps, o.edit === void 0 ? void 0 : editorStep(log, o.edit, o.onNote));
997
- }
998
- /**
999
- * `adr accept`: lints the draft, hashes the body, writes `accepted` and a `superseded` event on
1000
- * each declared target, stages them with the body, and commits with `commit`.
1001
- */
1002
- async function acceptDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
1003
- return write(log, "accept", (read, c) => planAccept(read, input, c), o, deps);
1004
- }
1005
- /** `adr supersede <id> --by <by>`: writes `superseded`, stages it, and commits with `commit`. */
1006
- async function supersedeDecision(log, input, o = {}, deps = DEFAULT_DEPS) {
1007
- return write(log, "supersede", (read, c) => planSupersede(read, input, c), o, deps);
1008
- }
1009
- export { showDecision as a, verifyDecisions as c, readDecisionsFrom as d, resolveDecision as f, parseAdrConfig as h, listDecisions as i, openDecisionLog as l, DEFAULT_ADR_CONFIG as m, proposeDecision as n, stateHeader as o, unavailableReason as p, supersedeDecision as r, adrChecks as s, acceptDecision as t, readDecisions as u };
1010
-
1011
- //# sourceMappingURL=write-C3IW5WrA.js.map