@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.
- package/README.md +77 -13
- package/dist/cli.js +348 -46
- package/dist/cli.js.map +1 -1
- package/dist/index.js +5 -3
- package/dist/init-YgW7g9Fx.js +588 -0
- package/dist/init-YgW7g9Fx.js.map +1 -0
- package/dist/{kind-BcHjCHvi.js → kind-DjvA3dyA.js} +2 -6
- package/dist/{kind-BcHjCHvi.js.map → kind-DjvA3dyA.js.map} +1 -1
- package/dist/kind.js +1 -1
- package/dist/list-Dt4KGxvj.js +58 -0
- package/dist/list-Dt4KGxvj.js.map +1 -0
- package/dist/{write-9IaqHRrY.js → search-CwIPlNGd.js} +1339 -560
- package/dist/search-CwIPlNGd.js.map +1 -0
- package/dist/server-CXCO5zfp.js +938 -0
- package/dist/server-CXCO5zfp.js.map +1 -0
- package/dist/types/cli/hooks.d.ts +6 -0
- package/dist/types/cli/index-command.d.ts +3 -0
- package/dist/types/cli/init.d.ts +5 -2
- package/dist/types/cli/list.d.ts +1 -1
- package/dist/types/cli/mcp.d.ts +6 -0
- package/dist/types/cli/open.d.ts +2 -0
- package/dist/types/cli/search.d.ts +3 -0
- package/dist/types/cli/text.d.ts +12 -0
- package/dist/types/commit-actions.d.ts +74 -0
- package/dist/types/config.d.ts +7 -1
- package/dist/types/hooks.d.ts +59 -0
- package/dist/types/index.d.ts +7 -2
- package/dist/types/init.d.ts +23 -2
- package/dist/types/log-index.d.ts +117 -0
- package/dist/types/log.d.ts +44 -1
- package/dist/types/mcp/policy.d.ts +33 -0
- package/dist/types/mcp/prompt.d.ts +7 -0
- package/dist/types/mcp/resources.d.ts +16 -0
- package/dist/types/mcp/server.d.ts +16 -0
- package/dist/types/mcp/text.d.ts +42 -0
- package/dist/types/mcp/tools.d.ts +27 -0
- package/dist/types/scope.d.ts +3 -1
- package/dist/types/search.d.ts +61 -0
- package/dist/types/skill.d.ts +5 -0
- package/dist/types/templates/list.d.ts +31 -0
- package/dist/types/verify.d.ts +6 -4
- package/dist/types/views.d.ts +9 -3
- package/dist/types/write.d.ts +65 -9
- package/package.json +5 -2
- package/dist/write-9IaqHRrY.js.map +0 -1
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import { C as headTree, D as renderDecisionIndex, E as indexPath, S as firstLine, b as INDEX_AFTER, c as followScopeRenames, j as numberDecisions, k as writeIndexCopy, v as BUILTIN_TEMPLATES, w as holdsText, x as decisionIndexCheck } from "./search-CwIPlNGd.js";
|
|
2
|
+
import { t as adrKind } from "./kind-DjvA3dyA.js";
|
|
3
|
+
import { ConfigError, InvalidArgumentError, UsageError, anchorCheck, assertNoHookConflicts, classifyPath, commitWithRecovery, exitCode, finding, foldChecks, gitTemplates, governedChanges, hookSnippet, installHooks, narrowedScopes, overlayTree, parseEventFileName, projectLog, readToolConfig, renderCommitMessage, replaceFile, resolveAttribution, scopeGlobCheck, stagedRenames, templateHistoryRef, uninstallHooks, verifyLog, worktreeReader, writeIndexFile, writeNewFile } from "@nikovirtala/ledger";
|
|
4
|
+
import { access, constants, rm } from "node:fs/promises";
|
|
5
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
6
|
+
// src/verify.ts
|
|
7
|
+
/** At most this many paths are named in a `decision/scope-narrowed` message. */
|
|
8
|
+
const NARROWED_PATHS = 3;
|
|
9
|
+
/** The head's decision views with the read's numbering, for the displays in messages. */
|
|
10
|
+
async function headViews(log, ctx) {
|
|
11
|
+
const head = ctx.folded?.head;
|
|
12
|
+
if (head === void 0) return /* @__PURE__ */ new Map();
|
|
13
|
+
return projectLog(adrKind, head, { numbering: await numberDecisions(log) }).views;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* `decision/governed-by-inactive` (`[verify] governed_by_inactive`, default warning): a changed
|
|
17
|
+
* code file that a superseded or deprecated decision governs, by glob or anchor (01:366).
|
|
18
|
+
*/
|
|
19
|
+
function governedByInactive(log) {
|
|
20
|
+
return {
|
|
21
|
+
id: "decision-governed-by-inactive",
|
|
22
|
+
async run(ctx) {
|
|
23
|
+
const changes = await governedChanges(ctx, {
|
|
24
|
+
keyword: log.adr.anchorKeyword,
|
|
25
|
+
exclude: log.adr.anchorExclude,
|
|
26
|
+
statuses: ["superseded", "deprecated"]
|
|
27
|
+
});
|
|
28
|
+
if (changes.length === 0) return [];
|
|
29
|
+
const views = await headViews(log, ctx);
|
|
30
|
+
const display = (id) => views.get(id)?.display ?? id;
|
|
31
|
+
const message = (c) => {
|
|
32
|
+
const what = `${c.path} is governed by ${display(c.subject)}`;
|
|
33
|
+
if (c.status === "deprecated") return `${what}, which is deprecated; check whether the change still needs it`;
|
|
34
|
+
const next = views.get(c.subject)?.superseded_by ?? null;
|
|
35
|
+
return next === null ? `${what}, which is superseded; check the change against its successor` : `${what}, which is superseded by ${display(next)}; check the change against ${display(next)}`;
|
|
36
|
+
};
|
|
37
|
+
return changes.map((c) => finding({
|
|
38
|
+
severity: log.adr.governedByInactive,
|
|
39
|
+
rule: "decision/governed-by-inactive",
|
|
40
|
+
message: message(c),
|
|
41
|
+
file: c.path,
|
|
42
|
+
subject: c.subject
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `decision/scope-narrowed` (warning): an accepted decision whose new scope stops covering files
|
|
49
|
+
* that still exist (01:367), which calls for a check against its Scope section.
|
|
50
|
+
*/
|
|
51
|
+
function scopeNarrowed(log) {
|
|
52
|
+
return {
|
|
53
|
+
id: "decision-scope-narrowed",
|
|
54
|
+
async run(ctx) {
|
|
55
|
+
const narrowed = await narrowedScopes(ctx, { statuses: ["accepted"] });
|
|
56
|
+
if (narrowed.length === 0) return [];
|
|
57
|
+
const views = await headViews(log, ctx);
|
|
58
|
+
return narrowed.map((n) => {
|
|
59
|
+
const count = n.lost.length;
|
|
60
|
+
const files = count === 1 ? "1 file that still exists" : `${count} files that still exist`;
|
|
61
|
+
const named = n.lost.slice(0, NARROWED_PATHS).join(", ");
|
|
62
|
+
const more = count > NARROWED_PATHS ? ", …" : "";
|
|
63
|
+
return finding({
|
|
64
|
+
severity: "warning",
|
|
65
|
+
rule: "decision/scope-narrowed",
|
|
66
|
+
message: `${views.get(n.subject)?.display ?? n.subject}'s scope no longer covers ${files} (${named}${more}); check the change against its Scope section`,
|
|
67
|
+
file: n.file,
|
|
68
|
+
subject: n.subject
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Every check for the log: ledger's mechanical and fold checks with adr's actor policy, template
|
|
76
|
+
* lint against the templates in the head tree and, for older versions, the history of
|
|
77
|
+
* `historyRef`, the scope-glob and anchor checks, and the decision checks over code
|
|
78
|
+
* (`decision/governed-by-inactive`, `decision/scope-narrowed`). With `[log] index = "committed"`,
|
|
79
|
+
* also the freshness of `<root>/index.md`.
|
|
80
|
+
*/
|
|
81
|
+
function adrChecks(log, historyRef) {
|
|
82
|
+
return [
|
|
83
|
+
...foldChecks(adrKind, {
|
|
84
|
+
options: { acceptRequiresHuman: log.adr.acceptRequiresHuman },
|
|
85
|
+
templates: gitTemplates(log.git, { historyRef })
|
|
86
|
+
}),
|
|
87
|
+
scopeGlobCheck(),
|
|
88
|
+
anchorCheck({
|
|
89
|
+
keyword: log.adr.anchorKeyword,
|
|
90
|
+
exclude: log.adr.anchorExclude
|
|
91
|
+
}),
|
|
92
|
+
governedByInactive(log),
|
|
93
|
+
scopeNarrowed(log),
|
|
94
|
+
...log.adr.index === "committed" ? [decisionIndexCheck(log)] : []
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
/** Verifies the decision log in `mode` (staged, range or full) with `adrChecks`. */
|
|
98
|
+
async function verifyDecisions(log, mode) {
|
|
99
|
+
return verifyLog({
|
|
100
|
+
git: log.git,
|
|
101
|
+
kind: adrKind,
|
|
102
|
+
layout: log.layout,
|
|
103
|
+
mode,
|
|
104
|
+
checks: adrChecks(log, await templateHistoryRef(log.git, mode))
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// src/hooks.ts
|
|
108
|
+
/** The hooks adr registers, in the order the text output lists them. */
|
|
109
|
+
const HOOKS = [
|
|
110
|
+
"pre-commit",
|
|
111
|
+
"commit-msg",
|
|
112
|
+
"pre-push",
|
|
113
|
+
"post-merge"
|
|
114
|
+
];
|
|
115
|
+
/**
|
|
116
|
+
* adr's command per hook (design 2b D4), after `adr`. `--root <root>` comes first only when the
|
|
117
|
+
* root can't be found without it: no `<repo>/adr.toml` names it and it isn't `docs/adr`. So the
|
|
118
|
+
* hooks see the same config as a plain `adr` run, and never print the root-override note.
|
|
119
|
+
*/
|
|
120
|
+
function decisionHookCommands(log) {
|
|
121
|
+
const r = log.configPath === "adr.toml" || log.layout.root === adrKind.def.defaultRoot ? [] : ["--root", log.layout.root];
|
|
122
|
+
return {
|
|
123
|
+
"pre-commit": [
|
|
124
|
+
...r,
|
|
125
|
+
"hooks",
|
|
126
|
+
"run",
|
|
127
|
+
"pre-commit"
|
|
128
|
+
],
|
|
129
|
+
"commit-msg": [
|
|
130
|
+
...r,
|
|
131
|
+
"hooks",
|
|
132
|
+
"trailers",
|
|
133
|
+
"{args}"
|
|
134
|
+
],
|
|
135
|
+
"pre-push": [
|
|
136
|
+
...r,
|
|
137
|
+
"verify",
|
|
138
|
+
"--base",
|
|
139
|
+
"@{upstream}",
|
|
140
|
+
"--ref",
|
|
141
|
+
"HEAD"
|
|
142
|
+
],
|
|
143
|
+
"post-merge": [
|
|
144
|
+
...r,
|
|
145
|
+
"list",
|
|
146
|
+
"--since",
|
|
147
|
+
"ORIG_HEAD"
|
|
148
|
+
]
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function registrations(log) {
|
|
152
|
+
const commands = decisionHookCommands(log);
|
|
153
|
+
return HOOKS.flatMap((hook) => {
|
|
154
|
+
const argv = commands[hook];
|
|
155
|
+
return argv === void 0 ? [] : [{
|
|
156
|
+
hook,
|
|
157
|
+
argv
|
|
158
|
+
}];
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/** A shim path as the user sees it: repo-relative, with `/`. */
|
|
162
|
+
function shown(git, path) {
|
|
163
|
+
return relative(git.root ?? git.gitDir, path).split(sep).join("/");
|
|
164
|
+
}
|
|
165
|
+
/** The shims in hook order: `HOOKS`' order, as the text output lists them. */
|
|
166
|
+
function inHookOrder(git, paths) {
|
|
167
|
+
const rank = (p) => HOOKS.findIndex((h) => p.endsWith(`/${h}`));
|
|
168
|
+
return paths.map((p) => shown(git, p)).sort((a, b) => rank(a) - rank(b));
|
|
169
|
+
}
|
|
170
|
+
function result(log, action, r, extra = {}) {
|
|
171
|
+
return {
|
|
172
|
+
format: "adr-hooks/v1",
|
|
173
|
+
action,
|
|
174
|
+
written: r === null ? [] : inHookOrder(log.git, r.written),
|
|
175
|
+
unchanged: r === null ? [] : inHookOrder(log.git, r.unchanged),
|
|
176
|
+
registrations: registrations(log),
|
|
177
|
+
snippet: extra.snippet ?? null,
|
|
178
|
+
trailers: extra.trailers ?? []
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Registers adr's four commands in ledger's shims, one per hook and shared with other ark tools
|
|
183
|
+
* (design 2b D4). With `core.hooksPath` set or a foreign hook file present, it writes the rest and
|
|
184
|
+
* then throws `HookConflictError` (exit 2), naming each conflict and the `print` command.
|
|
185
|
+
*/
|
|
186
|
+
async function installDecisionHooks(log) {
|
|
187
|
+
const r = await installHooks(log.git, {
|
|
188
|
+
tool: "adr",
|
|
189
|
+
commands: decisionHookCommands(log)
|
|
190
|
+
});
|
|
191
|
+
assertNoHookConflicts(r, { tool: "adr" });
|
|
192
|
+
return result(log, "install", r);
|
|
193
|
+
}
|
|
194
|
+
/** Removes adr from every shim; a shim left with no tool is deleted. */
|
|
195
|
+
async function uninstallDecisionHooks(log) {
|
|
196
|
+
return result(log, "uninstall", await uninstallHooks(log.git, { tool: "adr" }));
|
|
197
|
+
}
|
|
198
|
+
/** The config to paste into a hook manager instead of installing shims. */
|
|
199
|
+
function printDecisionHooks(log, manager) {
|
|
200
|
+
const regs = registrations(log).map((r) => ({
|
|
201
|
+
tool: "adr",
|
|
202
|
+
...r
|
|
203
|
+
}));
|
|
204
|
+
return result(log, "print", null, { snippet: hookSnippet(manager, regs) });
|
|
205
|
+
}
|
|
206
|
+
/** The `ADR-Event` trailer key as git compares keys: case-insensitively. */
|
|
207
|
+
const ADR_EVENT = /^adr-event[ \t]*:/im;
|
|
208
|
+
/**
|
|
209
|
+
* The `commit-msg` step (P1 §7.1): appends one `ADR`/`ADR-Event` pair per staged added event file
|
|
210
|
+
* of the log, by path, keeping repeats (`ifExists: "add"`). It does nothing for a message that
|
|
211
|
+
* already has an `ADR-Event:` trailer (an `adr … --commit` message), or during a merge, whose
|
|
212
|
+
* events came with their own commits' trailers. Throws `UsageError` when the file is missing.
|
|
213
|
+
*/
|
|
214
|
+
async function addHookTrailers(log, messageFile) {
|
|
215
|
+
const { git, layout } = log;
|
|
216
|
+
const file = resolve(git.root ?? git.gitDir, messageFile);
|
|
217
|
+
try {
|
|
218
|
+
await access(file, constants.R_OK | constants.W_OK);
|
|
219
|
+
} catch {
|
|
220
|
+
throw new UsageError(`no commit message file at ${messageFile}`);
|
|
221
|
+
}
|
|
222
|
+
const parsed = await git.run([
|
|
223
|
+
"interpret-trailers",
|
|
224
|
+
"--parse",
|
|
225
|
+
"--",
|
|
226
|
+
file
|
|
227
|
+
]);
|
|
228
|
+
if (ADR_EVENT.test(Buffer.from(parsed.stdout).toString("utf8"))) return result(log, "trailers", null);
|
|
229
|
+
if (await git.resolveCommit("MERGE_HEAD") !== null) return result(log, "trailers", null);
|
|
230
|
+
const trailers = (await git.stagedPaths([`${layout.root}/${layout.events}`], { addedOnly: true })).flatMap((p) => {
|
|
231
|
+
const c = classifyPath(layout, p);
|
|
232
|
+
const name = c.area === "event" && !c.file.includes("/") ? parseEventFileName(c.file) : null;
|
|
233
|
+
return c.area === "event" && c.subject !== null && name !== null ? [["ADR", c.subject], ["ADR-Event", name.type]] : [];
|
|
234
|
+
});
|
|
235
|
+
await git.appendTrailers(file, trailers, { ifExists: "add" });
|
|
236
|
+
return result(log, "trailers", null, { trailers });
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Whether the hook may stage: only into the commit's real index (design 2b D8). `git commit
|
|
240
|
+
* <paths>` and `--only`, adr's own `--commit` included, run hooks on a temporary index, and
|
|
241
|
+
* staging there leaves the user's index showing the file as deleted.
|
|
242
|
+
*/
|
|
243
|
+
function stagingSafe(git, env) {
|
|
244
|
+
const file = env.GIT_INDEX_FILE;
|
|
245
|
+
if (file === void 0 || file === "") return true;
|
|
246
|
+
const at = resolve(git.root ?? git.gitDir, file);
|
|
247
|
+
return at === resolve(git.gitDir, "index") || at === resolve(git.gitDir, "index.lock");
|
|
248
|
+
}
|
|
249
|
+
/** `ADR-0003, ADR-0004` for the decisions a rename following changes. */
|
|
250
|
+
const displays = (views) => views.map((v) => v.display).join(", ");
|
|
251
|
+
/**
|
|
252
|
+
* `adr hooks run pre-commit` (design 2b D8): rename following, then the index, then `verify
|
|
253
|
+
* --staged`, stopping at the first step that fails. `worktree` must be opened with the work-tree
|
|
254
|
+
* reader, because rename following writes through `write()`; `index` with the index reader, for
|
|
255
|
+
* the config as staged, the index render and verify. Only the index step stages `index.md`, and
|
|
256
|
+
* only with `[hooks] index = "stage"`; rename following passes `index: "skip"`. Neither stages
|
|
257
|
+
* into a temporary index (`stagingSafe`). A step that can't run throws (exit 2).
|
|
258
|
+
*/
|
|
259
|
+
async function runPreCommitHook(o) {
|
|
260
|
+
const { worktree, index, env } = o;
|
|
261
|
+
const config = index.adr;
|
|
262
|
+
const failOn = config.failOn;
|
|
263
|
+
const notes = [...index.notes];
|
|
264
|
+
const done = (exit, findings) => ({
|
|
265
|
+
exit,
|
|
266
|
+
findings,
|
|
267
|
+
notes: [...new Set(notes)],
|
|
268
|
+
failOn
|
|
269
|
+
});
|
|
270
|
+
const safe = stagingSafe(index.git, env);
|
|
271
|
+
if (config.scopeFollowRenames !== "off" && (await stagedRenames(worktree.git)).length > 0) {
|
|
272
|
+
const { actor, notes: attribution } = await resolveAttribution(worktree.git, { env });
|
|
273
|
+
if (config.scopeFollowRenames === "warn") {
|
|
274
|
+
const dry = await followScopeRenames(worktree, { actor }, { dryRun: true });
|
|
275
|
+
for (const v of dry.affected) notes.push(`${v.display}'s globs don't follow the renames in this commit (scope_follow_renames = "warn"); run adr scope --follow-renames`);
|
|
276
|
+
} else if (safe) {
|
|
277
|
+
const doc = await followScopeRenames(worktree, { actor }, {
|
|
278
|
+
index: "skip",
|
|
279
|
+
notes: attribution
|
|
280
|
+
});
|
|
281
|
+
notes.push(...doc.notes);
|
|
282
|
+
if (!doc.ok) return done(1, doc.findings);
|
|
283
|
+
} else {
|
|
284
|
+
const dry = await followScopeRenames(worktree, { actor }, { dryRun: true });
|
|
285
|
+
if (dry.affected.length > 0) {
|
|
286
|
+
notes.push(`the renames in this commit change the scope of ${displays(dry.affected)}; git commit <paths> can't take the scoped events. Run adr scope --follow-renames, then commit again including the files it lists, or set [hooks] scope_follow_renames = "warn"`);
|
|
287
|
+
return done(1, []);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (config.index === "committed" && config.hookIndex === "stage" && safe) {
|
|
292
|
+
const path = indexPath(index.layout);
|
|
293
|
+
const text = await renderDecisionIndex(index.git, {
|
|
294
|
+
layout: index.layout,
|
|
295
|
+
adr: config,
|
|
296
|
+
tree: index.tree,
|
|
297
|
+
history: await index.git.resolveCommit("HEAD")
|
|
298
|
+
});
|
|
299
|
+
if (text !== null && !holdsText(await index.tree.read(path), text)) {
|
|
300
|
+
await writeIndexFile(index.git, path, text);
|
|
301
|
+
await index.git.add([path]);
|
|
302
|
+
notes.push(`regenerated and staged ${path}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const findings = await verifyDecisions(index, { type: "staged" });
|
|
306
|
+
return done(exitCode(findings, { failOn }), findings);
|
|
307
|
+
}
|
|
308
|
+
// src/skill.ts
|
|
309
|
+
/**
|
|
310
|
+
* The agent skill `adr init --skill` writes (01, "Agent skill (SKILL.md)"): the judgment of when
|
|
311
|
+
* to use adr's MCP tools. `skill.test.ts` keeps it equal to the block in docs/01-adr-v1.md.
|
|
312
|
+
*/
|
|
313
|
+
const SKILL_MD = `---
|
|
314
|
+
name: adr
|
|
315
|
+
description: Use before changing architecture, data stores, integration patterns, dependencies or cross-cutting conventions, and whenever asked to record, propose or look up an architectural decision.
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
# Architectural decisions (adr)
|
|
319
|
+
|
|
320
|
+
Decisions in this repo are managed by adr. Decision files do not
|
|
321
|
+
show their own status, so never judge from the raw files whether a
|
|
322
|
+
decision applies.
|
|
323
|
+
|
|
324
|
+
## Before changing architecture
|
|
325
|
+
|
|
326
|
+
1. Call adr_for_path for the files you will touch, and adr_search
|
|
327
|
+
for the area you are about to change. adr_search returns active
|
|
328
|
+
decisions only; adr_for_path also lists inactive ones, each with
|
|
329
|
+
a warning.
|
|
330
|
+
2. If your change conflicts with an accepted decision, stop and tell
|
|
331
|
+
the user which ADR it conflicts with. Do not work around it silently.
|
|
332
|
+
3. If the user still wants the change, propose a superseding ADR.
|
|
333
|
+
|
|
334
|
+
## Reading decisions
|
|
335
|
+
|
|
336
|
+
- Use adr_get or adr://decision/{id}, never docs/adr/decisions/*.md.
|
|
337
|
+
- If a response says SUPERSEDED or DEPRECATED, follow it to the
|
|
338
|
+
effective decision before acting.
|
|
339
|
+
- If it says REJECTED or WITHDRAWN, the decision was never in force;
|
|
340
|
+
do not apply it.
|
|
341
|
+
|
|
342
|
+
## Proposing decisions
|
|
343
|
+
|
|
344
|
+
1. Call adr_templates and fill every required section.
|
|
345
|
+
2. Use only facts from the conversation and the code. Mark unknowns
|
|
346
|
+
as open questions instead of inventing them.
|
|
347
|
+
3. List at least two options considered when the template has that section.
|
|
348
|
+
4. Set supersedes when the proposal replaces an existing decision.
|
|
349
|
+
5. Do not accept your own proposal. Tell the user the ADR id and
|
|
350
|
+
that they can review it and run: adr accept <id>
|
|
351
|
+
6. If the user decides against your proposal, withdraw it with
|
|
352
|
+
adr_withdraw and give their reason. Don't withdraw other
|
|
353
|
+
people's proposals; tell the user instead.
|
|
354
|
+
|
|
355
|
+
## Changing scope
|
|
356
|
+
|
|
357
|
+
- When you move or rename code covered by a decision, let the
|
|
358
|
+
pre-commit hook update its globs (adr hooks install). For your
|
|
359
|
+
own proposals, you can use adr_scope. Rescoping an accepted
|
|
360
|
+
decision needs mcp_can_decide; otherwise tell the user and
|
|
361
|
+
suggest adr scope <id> ….
|
|
362
|
+
- Before any scope change, read the decision's Scope section. If it
|
|
363
|
+
would no longer read true after the change, do not rescope: tell
|
|
364
|
+
the user the decision itself is changing and propose a
|
|
365
|
+
superseding ADR.
|
|
366
|
+
|
|
367
|
+
## Never
|
|
368
|
+
|
|
369
|
+
- Edit an accepted decision body, any file under docs/adr/events/,
|
|
370
|
+
or index.md by hand.
|
|
371
|
+
- Use adr_note to change the meaning of a decision. Notes clarify;
|
|
372
|
+
changed decisions need a superseding ADR.
|
|
373
|
+
|
|
374
|
+
## Without the MCP server
|
|
375
|
+
|
|
376
|
+
Use the CLI with --json: adr search, adr for, adr show, adr new,
|
|
377
|
+
adr note. Set ARK_AGENT=<your name> and ARK_ON_BEHALF_OF=<the user's
|
|
378
|
+
email> so your writes are attributed.
|
|
379
|
+
`;
|
|
380
|
+
// src/init.ts
|
|
381
|
+
/**
|
|
382
|
+
* What the recovery hint says about rerunning `adr init` after `git commit` failed (design 2b D5):
|
|
383
|
+
* init keeps existing files, so it would commit nothing.
|
|
384
|
+
*/
|
|
385
|
+
const INIT_RERUN = "Running adr init again won't commit them: the files exist now, so it keeps them and commits nothing.";
|
|
386
|
+
const CONFIG = "adr.toml";
|
|
387
|
+
const DEFAULT_ROOT = adrKind.def.defaultRoot;
|
|
388
|
+
const encoder = new TextEncoder();
|
|
389
|
+
const decoder = new TextDecoder();
|
|
390
|
+
const AGENTS = "AGENTS.md";
|
|
391
|
+
/** The line `adr init --skill` adds to AGENTS.md for tools that don't load skills. */
|
|
392
|
+
const agentsPointer = (skill) => `Architecture decisions in this repository are managed by adr: read ${skill} before changing architecture, and follow it.`;
|
|
393
|
+
/** `text` with `line` appended after one blank line, ending with a newline. */
|
|
394
|
+
function appendLine(text, line) {
|
|
395
|
+
if (text === "") return `${line}\n`;
|
|
396
|
+
const ended = text.endsWith("\n") ? text : `${text}\n`;
|
|
397
|
+
return `${ended.endsWith("\n\n") ? ended : `${ended}\n`}${line}\n`;
|
|
398
|
+
}
|
|
399
|
+
/** The starter config; `root` only for a repo-level file naming a log elsewhere. */
|
|
400
|
+
function configText(o) {
|
|
401
|
+
return [
|
|
402
|
+
"# adr configuration. Every key is optional: https://github.com/nikovirtala/ark/tree/main/packages/adr#config-adrtoml",
|
|
403
|
+
"[log]",
|
|
404
|
+
...o.root === null ? [] : [`root = ${JSON.stringify(o.root)}`],
|
|
405
|
+
`default_template = ${JSON.stringify(o.template)}`,
|
|
406
|
+
"",
|
|
407
|
+
"[commit]",
|
|
408
|
+
"# subject = \"docs(adr): {action} {title}\"",
|
|
409
|
+
""
|
|
410
|
+
].join("\n");
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* `adr init` (design §6.2): writes a config for the log, unless one is found, and each built-in
|
|
414
|
+
* template in `o.templates` (default `nygard`) to `<root>/templates/`, never overwriting a file.
|
|
415
|
+
* A `--root` other than `docs/adr` gets a repo-level `adr.toml` naming it, so later commands find
|
|
416
|
+
* the log without `--root`; when that file exists for another log, the config goes in the root.
|
|
417
|
+
* With `[log] index = "committed"` it also writes `<root>/index.md` unless one exists. With
|
|
418
|
+
* `commit` (default `[policy] auto_commit`) the written files are committed on their own;
|
|
419
|
+
* nothing written means nothing committed. No events are written. With `o.skill` it also writes
|
|
420
|
+
* the agent skill, unless a SKILL.md is there, and adds the pointer line to `AGENTS.md`, unless a
|
|
421
|
+
* line already names the skill; an AGENTS.md with unstaged changes or not tracked is refused
|
|
422
|
+
* with a commit and left unstaged without one.
|
|
423
|
+
*/
|
|
424
|
+
async function initDecisionLog(log, o = {}) {
|
|
425
|
+
if (log.ref !== null || log.tree.label !== "worktree") throw new InvalidArgumentError("init writes the work tree; open the log without a ref");
|
|
426
|
+
const known = Object.keys(BUILTIN_TEMPLATES);
|
|
427
|
+
const templates = [...new Set(o.templates === void 0 ? ["nygard"] : o.templates)];
|
|
428
|
+
for (const name of templates) if (!known.includes(name)) throw new UsageError(`unknown built-in template ${name}; use nygard, madr or y-statement`);
|
|
429
|
+
const root = log.layout.root;
|
|
430
|
+
const notes = [...log.notes];
|
|
431
|
+
const files = [];
|
|
432
|
+
const kept = [];
|
|
433
|
+
let config = log.configPath;
|
|
434
|
+
if (config !== null) kept.push(config);
|
|
435
|
+
else {
|
|
436
|
+
const template = templates[0] ?? "nygard";
|
|
437
|
+
const repo = await readToolConfig(log.tree, CONFIG);
|
|
438
|
+
if (root === DEFAULT_ROOT) {
|
|
439
|
+
config = `${root}/${CONFIG}`;
|
|
440
|
+
files.push([config, configText({
|
|
441
|
+
root: null,
|
|
442
|
+
template
|
|
443
|
+
})]);
|
|
444
|
+
} else if (repo === null) {
|
|
445
|
+
config = CONFIG;
|
|
446
|
+
files.push([config, configText({
|
|
447
|
+
root,
|
|
448
|
+
template
|
|
449
|
+
})]);
|
|
450
|
+
} else {
|
|
451
|
+
const section = repo.raw.log;
|
|
452
|
+
const other = typeof section?.root === "string" ? section.root : DEFAULT_ROOT;
|
|
453
|
+
config = `${root}/${CONFIG}`;
|
|
454
|
+
files.push([config, configText({
|
|
455
|
+
root: null,
|
|
456
|
+
template
|
|
457
|
+
})]);
|
|
458
|
+
notes.push(`${CONFIG} names ${other}; pass --root ${root} to use this log`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
for (const name of templates) {
|
|
462
|
+
const path = `${root}/templates/${name}.md`;
|
|
463
|
+
if (await log.tree.read(path) !== null) kept.push(path);
|
|
464
|
+
else files.push([path, BUILTIN_TEMPLATES[name] ?? ""]);
|
|
465
|
+
}
|
|
466
|
+
const commitRequested = o.commit ?? log.adr.autoCommit;
|
|
467
|
+
const logFiles = files.length;
|
|
468
|
+
const skill = o.skill === void 0 ? null : o.skill.dir === "" ? "SKILL.md" : `${o.skill.dir}/SKILL.md`;
|
|
469
|
+
let agents = null;
|
|
470
|
+
if (skill !== null) {
|
|
471
|
+
const current = await log.tree.read(skill);
|
|
472
|
+
if (current === null) files.push([skill, SKILL_MD]);
|
|
473
|
+
else {
|
|
474
|
+
kept.push(skill);
|
|
475
|
+
if (decoder.decode(current) !== "---\nname: adr\ndescription: Use before changing architecture, data stores, integration patterns, dependencies or cross-cutting conventions, and whenever asked to record, propose or look up an architectural decision.\n---\n\n# Architectural decisions (adr)\n\nDecisions in this repo are managed by adr. Decision files do not\nshow their own status, so never judge from the raw files whether a\ndecision applies.\n\n## Before changing architecture\n\n1. Call adr_for_path for the files you will touch, and adr_search\n for the area you are about to change. adr_search returns active\n decisions only; adr_for_path also lists inactive ones, each with\n a warning.\n2. If your change conflicts with an accepted decision, stop and tell\n the user which ADR it conflicts with. Do not work around it silently.\n3. If the user still wants the change, propose a superseding ADR.\n\n## Reading decisions\n\n- Use adr_get or adr://decision/{id}, never docs/adr/decisions/*.md.\n- If a response says SUPERSEDED or DEPRECATED, follow it to the\n effective decision before acting.\n- If it says REJECTED or WITHDRAWN, the decision was never in force;\n do not apply it.\n\n## Proposing decisions\n\n1. Call adr_templates and fill every required section.\n2. Use only facts from the conversation and the code. Mark unknowns\n as open questions instead of inventing them.\n3. List at least two options considered when the template has that section.\n4. Set supersedes when the proposal replaces an existing decision.\n5. Do not accept your own proposal. Tell the user the ADR id and\n that they can review it and run: adr accept <id>\n6. If the user decides against your proposal, withdraw it with\n adr_withdraw and give their reason. Don't withdraw other\n people's proposals; tell the user instead.\n\n## Changing scope\n\n- When you move or rename code covered by a decision, let the\n pre-commit hook update its globs (adr hooks install). For your\n own proposals, you can use adr_scope. Rescoping an accepted\n decision needs mcp_can_decide; otherwise tell the user and\n suggest adr scope <id> ….\n- Before any scope change, read the decision's Scope section. If it\n would no longer read true after the change, do not rescope: tell\n the user the decision itself is changing and propose a\n superseding ADR.\n\n## Never\n\n- Edit an accepted decision body, any file under docs/adr/events/,\n or index.md by hand.\n- Use adr_note to change the meaning of a decision. Notes clarify;\n changed decisions need a superseding ADR.\n\n## Without the MCP server\n\nUse the CLI with --json: adr search, adr for, adr show, adr new,\nadr note. Set ARK_AGENT=<your name> and ARK_ON_BEHALF_OF=<the user's\nemail> so your writes are attributed.\n") notes.push(`${skill} differs from adr ${o.skill?.version ?? ""}'s skill; delete it and run adr init --skill again to replace it`);
|
|
476
|
+
}
|
|
477
|
+
const old = await log.tree.read(AGENTS);
|
|
478
|
+
const text = old === null ? null : decoder.decode(old);
|
|
479
|
+
if ((text ?? "").split("\n").some((l) => l.includes(skill))) kept.push(AGENTS);
|
|
480
|
+
else {
|
|
481
|
+
let stage = true;
|
|
482
|
+
if (old !== null) {
|
|
483
|
+
const unstaged = (await log.git.unstagedPaths([AGENTS])).length > 0;
|
|
484
|
+
const untracked = (await log.git.untrackedPaths([AGENTS])).length > 0;
|
|
485
|
+
if (commitRequested && unstaged) throw new UsageError(`${AGENTS} has unstaged changes; stage or discard them, or run without --commit`);
|
|
486
|
+
if (commitRequested && untracked) throw new UsageError(`${AGENTS} is not tracked; git add it, or run without --commit`);
|
|
487
|
+
if (unstaged || untracked) {
|
|
488
|
+
stage = false;
|
|
489
|
+
notes.push(untracked ? `${AGENTS} is not tracked, so the pointer is added but not staged; review it and git add it` : `${AGENTS} had unstaged changes, so the pointer is added but not staged; review it and git add it`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
agents = {
|
|
493
|
+
bytes: encoder.encode(appendLine(text ?? "", agentsPointer(skill))),
|
|
494
|
+
expected: old,
|
|
495
|
+
stage
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const at = indexPath(log.layout);
|
|
500
|
+
let indexed = false;
|
|
501
|
+
if (log.adr.index === "committed") {
|
|
502
|
+
if (await log.tree.read(at) !== null) kept.push(at);
|
|
503
|
+
else {
|
|
504
|
+
const head = await log.git.resolveCommit("HEAD");
|
|
505
|
+
const added = Object.fromEntries(files.map(([p, t]) => [p, encoder.encode(t)]));
|
|
506
|
+
const base = commitRequested ? await headTree(log.git, head) : log.tree;
|
|
507
|
+
const text = await renderDecisionIndex(log.git, {
|
|
508
|
+
layout: log.layout,
|
|
509
|
+
adr: log.adr,
|
|
510
|
+
tree: overlayTree(base, added),
|
|
511
|
+
history: head
|
|
512
|
+
});
|
|
513
|
+
if (text === null) notes.push(`${at} not updated: numbers need history in this shallow clone; run git fetch --unshallow, then adr index`);
|
|
514
|
+
else {
|
|
515
|
+
files.push([at, text]);
|
|
516
|
+
indexed = true;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const commit = (files.length > 0 || agents !== null) && commitRequested;
|
|
521
|
+
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");
|
|
522
|
+
const written = [];
|
|
523
|
+
let replaced = false;
|
|
524
|
+
try {
|
|
525
|
+
for (const [path, text] of files) {
|
|
526
|
+
await writeNewFile(log.git, path, encoder.encode(text));
|
|
527
|
+
written.push(path);
|
|
528
|
+
}
|
|
529
|
+
if (agents !== null) {
|
|
530
|
+
if (agents.expected === null) await writeNewFile(log.git, AGENTS, agents.bytes);
|
|
531
|
+
else await replaceFile(log.git, AGENTS, agents.bytes, { expected: agents.expected });
|
|
532
|
+
replaced = agents.expected !== null;
|
|
533
|
+
written.push(AGENTS);
|
|
534
|
+
}
|
|
535
|
+
} catch (err) {
|
|
536
|
+
const top = log.git.root ?? "";
|
|
537
|
+
const restore = replaced ? agents?.expected ?? null : null;
|
|
538
|
+
for (const path of written) if (path === AGENTS && restore !== null && agents !== null) await replaceFile(log.git, AGENTS, restore, { expected: agents.bytes }).catch(() => {});
|
|
539
|
+
else await rm(join(top, path), { force: true });
|
|
540
|
+
throw err;
|
|
541
|
+
}
|
|
542
|
+
const staged = written.filter((p) => p !== AGENTS || agents?.stage === true);
|
|
543
|
+
if (staged.length > 0) await log.git.add(staged);
|
|
544
|
+
let sha = null;
|
|
545
|
+
if (commit) {
|
|
546
|
+
const message = o.message ?? renderCommitMessage(log.adr.commitSubject, {
|
|
547
|
+
title: logFiles > 0 ? "the decision log" : "the adr agent skill",
|
|
548
|
+
vars: {
|
|
549
|
+
action: "set up",
|
|
550
|
+
id: "",
|
|
551
|
+
display: "",
|
|
552
|
+
command: "init"
|
|
553
|
+
}
|
|
554
|
+
}).message;
|
|
555
|
+
sha = await commitWithRecovery(log.git, {
|
|
556
|
+
message,
|
|
557
|
+
only: staged,
|
|
558
|
+
rerun: INIT_RERUN,
|
|
559
|
+
after: indexed ? INDEX_AFTER(root) : null
|
|
560
|
+
});
|
|
561
|
+
if (indexed) try {
|
|
562
|
+
const again = await renderDecisionIndex(log.git, {
|
|
563
|
+
layout: log.layout,
|
|
564
|
+
adr: log.adr,
|
|
565
|
+
tree: worktreeReader(log.git),
|
|
566
|
+
history: sha
|
|
567
|
+
});
|
|
568
|
+
if (again !== null) {
|
|
569
|
+
if ((await writeIndexCopy(log.git, at, again)).stage) await log.git.add([at]);
|
|
570
|
+
}
|
|
571
|
+
} catch (err) {
|
|
572
|
+
notes.push(`${at} may be stale: ${firstLine(err)}; run adr index`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
format: "adr-init/v1",
|
|
577
|
+
root,
|
|
578
|
+
config,
|
|
579
|
+
written,
|
|
580
|
+
kept,
|
|
581
|
+
skill,
|
|
582
|
+
commit: sha,
|
|
583
|
+
notes
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
export { installDecisionHooks as a, uninstallDecisionHooks as c, decisionHookCommands as i, adrChecks as l, SKILL_MD as n, printDecisionHooks as o, addHookTrailers as r, runPreCommitHook as s, initDecisionLog as t, verifyDecisions as u };
|
|
587
|
+
|
|
588
|
+
//# sourceMappingURL=init-YgW7g9Fx.js.map
|