doctrina-cli 0.13.0 → 0.14.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 +1 -1
- package/package.json +3 -2
- package/src/commands/adapter.js +205 -0
- package/src/commands/analyze.js +66 -19
- package/src/commands/change.js +100 -42
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +57 -2
- package/src/commands/completion.js +6 -0
- package/src/commands/constitution.js +8 -1
- package/src/commands/context.js +359 -53
- package/src/commands/contract.js +17 -24
- package/src/commands/coverage.js +16 -4
- package/src/commands/decision.js +172 -40
- package/src/commands/doctor.js +38 -7
- package/src/commands/handoff.js +8 -1
- package/src/commands/hooks.js +6 -0
- package/src/commands/index-rebuild.js +8 -1
- package/src/commands/init.js +139 -6
- package/src/commands/intake.js +11 -2
- package/src/commands/intent.js +8 -1
- package/src/commands/metrics.js +80 -7
- package/src/commands/next.js +14 -2
- package/src/commands/prime.js +8 -1
- package/src/commands/report.js +8 -1
- package/src/commands/review.js +8 -1
- package/src/commands/search.js +8 -1
- package/src/commands/show.js +8 -1
- package/src/commands/skill.js +16 -6
- package/src/commands/spec.js +13 -6
- package/src/commands/status.js +14 -2
- package/src/commands/templates.js +193 -59
- package/src/commands/trace.js +15 -3
- package/src/commands/upgrade.js +8 -1
- package/src/commands/validate.js +58 -2
- package/src/commands/verify.js +12 -2
- package/src/commands/watch.js +8 -1
- package/src/commands/why.js +10 -24
- package/src/commands/work.js +8 -1
- package/src/index.js +60 -6
- package/src/lib/adapters.js +140 -0
- package/src/lib/adr-guard.js +1 -0
- package/src/lib/args.js +10 -0
- package/src/lib/clarity.js +1 -0
- package/src/lib/colors.js +1 -0
- package/src/lib/commands.js +239 -26
- package/src/lib/criteria.js +1 -0
- package/src/lib/dates.js +1 -0
- package/src/lib/diff.js +1 -0
- package/src/lib/doc-model.js +287 -0
- package/src/lib/docs-impact.js +167 -0
- package/src/lib/ears.js +1 -0
- package/src/lib/exit-codes.js +103 -0
- package/src/lib/flag-catalog.js +106 -0
- package/src/lib/fs-ops.js +1 -0
- package/src/lib/gates.js +123 -0
- package/src/lib/git.js +97 -0
- package/src/lib/index-json.js +14 -6
- package/src/lib/json-out.js +83 -0
- package/src/lib/prompt.js +18 -1
- package/src/lib/scan.js +81 -33
- package/src/lib/spec-ops.js +94 -9
- package/src/lib/suggest.js +1 -0
- package/src/lib/templates.js +89 -0
- package/src/lib/usage.js +89 -0
- package/src/lib/version.js +1 -0
- package/templates/AGENTS.md.template +59 -53
package/src/commands/coverage.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
@@ -6,6 +7,8 @@ import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
|
|
|
6
7
|
import { specHeader } from "../lib/scan.js";
|
|
7
8
|
import { flagBool, flagString } from "../lib/args.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
10
|
+
import { emitJson } from "../lib/json-out.js";
|
|
11
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
9
12
|
|
|
10
13
|
// Traceability report: how many acceptance criteria cite an artifact or
|
|
11
14
|
// test that actually exists on disk. Doctrina otherwise has no link
|
|
@@ -22,10 +25,19 @@ import { c } from "../lib/colors.js";
|
|
|
22
25
|
// proves nothing — review G3: existence ≠ a passing test), "dangling" when
|
|
23
26
|
// a cited path does not resolve, and "bare" when nothing is cited.
|
|
24
27
|
|
|
28
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
29
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
30
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
31
|
+
// This command builds its own JSON payload; the entrypoint must not
|
|
32
|
+
// wrap it in the generic envelope.
|
|
33
|
+
export const jsonNative = true;
|
|
34
|
+
|
|
35
|
+
export const flags = { boolean: ["json", "run", "strict"], string: ["only"] };
|
|
36
|
+
|
|
25
37
|
export async function run(_positional, flags) {
|
|
26
38
|
const projectRoot = process.cwd();
|
|
27
39
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
28
|
-
throw
|
|
40
|
+
throw notADoctrinaProject();
|
|
29
41
|
}
|
|
30
42
|
const strict = flagBool(flags, "strict", false);
|
|
31
43
|
const json = flagBool(flags, "json", false);
|
|
@@ -48,7 +60,7 @@ export async function run(_positional, flags) {
|
|
|
48
60
|
|
|
49
61
|
if (reports.length === 0) {
|
|
50
62
|
if (json) {
|
|
51
|
-
|
|
63
|
+
emitJson("coverage", { specs: [], summary: { criteria: 0, covered: 0, dangling: 0, conditional: 0, pct: 100 } });
|
|
52
64
|
return 0;
|
|
53
65
|
}
|
|
54
66
|
console.log(c.gray("no acceptance criteria found under .doctrina/specs/"));
|
|
@@ -72,10 +84,10 @@ export async function run(_positional, flags) {
|
|
|
72
84
|
const jsonClean = totalCovered + totalDeferred === totalCriteria && totalDangling === 0 && totalConditional === 0;
|
|
73
85
|
|
|
74
86
|
if (json) {
|
|
75
|
-
|
|
87
|
+
emitJson("coverage", {
|
|
76
88
|
specs: reports.map((rep) => ({ capability: rep.cap, deferred: rep.deferred, criteria: rep.rows })),
|
|
77
89
|
summary: { criteria: totalCriteria, covered: totalCovered, dangling: totalDangling, conditional: totalConditional, deferred: totalDeferred, pct: jsonPct },
|
|
78
|
-
}
|
|
90
|
+
});
|
|
79
91
|
return jsonClean ? 0 : strict ? 1 : 0;
|
|
80
92
|
}
|
|
81
93
|
|
package/src/commands/decision.js
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getHeader, setHeader } from "../lib/doc-model.js";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import process from "node:process";
|
|
3
5
|
import { exists, read, relPath, write } from "../lib/fs-ops.js";
|
|
4
6
|
import { walk } from "../lib/fs-ops.js";
|
|
5
|
-
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
7
|
+
import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
6
8
|
import * as idx from "../lib/index-json.js";
|
|
7
9
|
import { today, slugify, padNumber } from "../lib/dates.js";
|
|
8
10
|
import { c } from "../lib/colors.js";
|
|
9
11
|
import { suggest } from "../lib/suggest.js";
|
|
12
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
|
+
import { flagBool } from "../lib/args.js";
|
|
14
|
+
import { parseAdrScope, decisionEntry } from "../lib/scan.js";
|
|
10
15
|
|
|
11
|
-
const SUBCOMMANDS = ["new", "supersede", "accept", "land", "list"];
|
|
16
|
+
const SUBCOMMANDS = ["new", "supersede", "accept", "land", "list", "scope"];
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
19
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
20
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
21
|
+
export const flags = { boolean: ["json", "write"], string: [] };
|
|
22
|
+
|
|
23
|
+
export async function run(positional, cmdFlags) {
|
|
14
24
|
const sub = positional[0];
|
|
15
25
|
switch (sub) {
|
|
16
26
|
case "new":
|
|
@@ -21,6 +31,8 @@ export async function run(positional, _flags) {
|
|
|
21
31
|
return decisionAccept(positional.slice(1));
|
|
22
32
|
case "land":
|
|
23
33
|
return decisionLand(positional.slice(1));
|
|
34
|
+
case "scope":
|
|
35
|
+
return decisionScope(positional.slice(1), cmdFlags);
|
|
24
36
|
case "list":
|
|
25
37
|
return decisionList();
|
|
26
38
|
default:
|
|
@@ -52,8 +64,7 @@ function decisionNew(args) {
|
|
|
52
64
|
return 1;
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
const
|
|
56
|
-
const tpl = read(path.join(templatesDir, "decision.md.template"));
|
|
67
|
+
const tpl = readTemplate(projectRoot, "decision.md.template").body;
|
|
57
68
|
const body = substitute(tpl, {
|
|
58
69
|
DECISION_NUMBER: next,
|
|
59
70
|
DECISION_TITLE: title,
|
|
@@ -63,14 +74,11 @@ function decisionNew(args) {
|
|
|
63
74
|
write(targetPath, body, { force: false });
|
|
64
75
|
console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}`);
|
|
65
76
|
|
|
77
|
+
// Register through the SAME deriver the index rebuild uses. Assembling a
|
|
78
|
+
// look-alike entry here is how a field added to the deriver turns every
|
|
79
|
+
// freshly created ADR into index drift the moment it is written.
|
|
66
80
|
const index = idx.load(projectRoot);
|
|
67
|
-
idx.addDecision(index,
|
|
68
|
-
id: next,
|
|
69
|
-
path: `.doctrina/decisions/${filename}`,
|
|
70
|
-
title,
|
|
71
|
-
status: "proposed",
|
|
72
|
-
date,
|
|
73
|
-
});
|
|
81
|
+
idx.addDecision(index, decisionEntry(body, filename, null, date));
|
|
74
82
|
idx.touch(index, date);
|
|
75
83
|
idx.save(projectRoot, index);
|
|
76
84
|
console.log(c.green("indexed") + ` decision ${next}`);
|
|
@@ -95,12 +103,12 @@ function decisionSupersede(args) {
|
|
|
95
103
|
return 1;
|
|
96
104
|
}
|
|
97
105
|
const oldText = read(oldFile);
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
106
|
+
const oldStatusValue = getHeader(oldText, "Status");
|
|
107
|
+
if (oldStatusValue === null) {
|
|
100
108
|
console.error(c.red("error:") + ` ADR at ${relPath(projectRoot, oldFile)} has no Status: header`);
|
|
101
109
|
return 1;
|
|
102
110
|
}
|
|
103
|
-
const oldStatus =
|
|
111
|
+
const oldStatus = oldStatusValue;
|
|
104
112
|
if (oldStatus.toLowerCase().startsWith("superseded")) {
|
|
105
113
|
console.error(c.red("error:") + ` ADR ${padded} is already superseded`);
|
|
106
114
|
return 1;
|
|
@@ -132,23 +140,17 @@ function decisionSupersede(args) {
|
|
|
132
140
|
write(newPath, body, { force: false });
|
|
133
141
|
console.log(c.green("created") + ` ${relPath(projectRoot, newPath)}`);
|
|
134
142
|
|
|
135
|
-
// Mutate ONLY the Status: and Superseded by: headers of the old ADR
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
143
|
+
// Mutate ONLY the Status: and Superseded by: headers of the old ADR.
|
|
144
|
+
// Both go through the document model, so an ADR written with a slightly
|
|
145
|
+
// off header form is still updated rather than silently skipped (M3).
|
|
146
|
+
const withStatus = setHeader(oldText, "Status", `superseded by ${next}`) ?? oldText;
|
|
147
|
+
const updated = setHeader(withStatus, "Superseded by", String(next)) ?? withStatus;
|
|
139
148
|
write(oldFile, updated, { force: true });
|
|
140
149
|
console.log(c.green("updated") + ` ${relPath(projectRoot, oldFile)} status -> superseded by ${next}`);
|
|
141
150
|
|
|
142
151
|
// Update index
|
|
143
152
|
const index = idx.load(projectRoot);
|
|
144
|
-
idx.addDecision(index,
|
|
145
|
-
id: next,
|
|
146
|
-
path: `.doctrina/decisions/${filename}`,
|
|
147
|
-
title,
|
|
148
|
-
status: "proposed",
|
|
149
|
-
date,
|
|
150
|
-
supersedes: padded,
|
|
151
|
-
});
|
|
153
|
+
idx.addDecision(index, decisionEntry(body, filename, null, date));
|
|
152
154
|
idx.updateDecision(index, padded, () => ({
|
|
153
155
|
status: `superseded by ${next}`,
|
|
154
156
|
superseded_by: next,
|
|
@@ -176,19 +178,19 @@ function decisionAccept(args) {
|
|
|
176
178
|
return 1;
|
|
177
179
|
}
|
|
178
180
|
const text = read(file);
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
181
|
+
const statusValue = getHeader(text, "Status");
|
|
182
|
+
if (statusValue === null) {
|
|
181
183
|
console.error(c.red("error:") + ` ADR at ${relPath(projectRoot, file)} has no Status: header`);
|
|
182
184
|
return 1;
|
|
183
185
|
}
|
|
184
|
-
const status =
|
|
186
|
+
const status = statusValue.toLowerCase();
|
|
185
187
|
if (status !== "proposed") {
|
|
186
|
-
console.error(c.red("error:") + ` ADR ${padded} is "${
|
|
188
|
+
console.error(c.red("error:") + ` ADR ${padded} is "${statusValue}", not "proposed" — nothing to accept`);
|
|
187
189
|
return 1;
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
// Mutate ONLY the Status: header; the body stays immutable.
|
|
191
|
-
write(file, text
|
|
193
|
+
write(file, setHeader(text, "Status", "accepted") ?? text, { force: true });
|
|
192
194
|
console.log(c.green("accepted") + ` ${relPath(projectRoot, file)}`);
|
|
193
195
|
|
|
194
196
|
const date = today();
|
|
@@ -224,16 +226,16 @@ function decisionLand(args) {
|
|
|
224
226
|
return 1;
|
|
225
227
|
}
|
|
226
228
|
const text = read(file);
|
|
227
|
-
const
|
|
228
|
-
if (
|
|
229
|
+
const statusValue = getHeader(text, "Status");
|
|
230
|
+
if (statusValue === null) {
|
|
229
231
|
console.error(c.red("error:") + ` ADR at ${relPath(projectRoot, file)} has no Status: header`);
|
|
230
232
|
return 1;
|
|
231
233
|
}
|
|
232
|
-
const status =
|
|
234
|
+
const status = statusValue.toLowerCase();
|
|
233
235
|
if (status !== "accepted") {
|
|
234
236
|
console.error(
|
|
235
237
|
c.red("error:") +
|
|
236
|
-
` ADR ${padded} is "${
|
|
238
|
+
` ADR ${padded} is "${statusValue}", not "accepted" — accept it before recording that it landed`,
|
|
237
239
|
);
|
|
238
240
|
return 1;
|
|
239
241
|
}
|
|
@@ -251,7 +253,7 @@ function decisionLand(args) {
|
|
|
251
253
|
} else if (/^-\s+\*\*Evidence:\*\*.*$/m.test(text)) {
|
|
252
254
|
updated = text.replace(/^(-\s+\*\*Evidence:\*\*.*)$/m, `$1\n- **Landed:** ${landedValue}`);
|
|
253
255
|
} else {
|
|
254
|
-
updated = text.replace(/^(
|
|
256
|
+
updated = text.replace(/^(\s*-\s+\*\*Status:\*\*.*)$/m, `$1\n- **Landed:** ${landedValue}`);
|
|
255
257
|
}
|
|
256
258
|
write(file, updated, { force: true });
|
|
257
259
|
console.log(c.green("landed") + ` ${relPath(projectRoot, file)} on ${date}`);
|
|
@@ -286,11 +288,11 @@ function decisionList() {
|
|
|
286
288
|
if (!m) continue;
|
|
287
289
|
const text = read(f);
|
|
288
290
|
const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
|
|
289
|
-
const
|
|
291
|
+
const listedStatus = getHeader(text, "Status");
|
|
290
292
|
const dateMatch = text.match(/^-\s+\*\*Date:\*\*\s+(\S+)/m);
|
|
291
293
|
rows.push({
|
|
292
294
|
id: m[1],
|
|
293
|
-
status:
|
|
295
|
+
status: listedStatus ?? "?",
|
|
294
296
|
date: dateMatch ? dateMatch[1] : "—",
|
|
295
297
|
title: titleMatch ? titleMatch[1].trim() : path.basename(f),
|
|
296
298
|
});
|
|
@@ -309,6 +311,127 @@ function decisionList() {
|
|
|
309
311
|
return 0;
|
|
310
312
|
}
|
|
311
313
|
|
|
314
|
+
// Propose — or, with --write, apply — the capability scope of an ADR.
|
|
315
|
+
//
|
|
316
|
+
// An unscoped ADR is GLOBAL: it loads into every context pack, forever,
|
|
317
|
+
// because ADRs are immutable and never retire. That is what makes the
|
|
318
|
+
// default read pack grow with the project's age instead of with the task
|
|
319
|
+
// at hand. Scoping is the fix, but only if it gets adopted, and nobody
|
|
320
|
+
// hand-annotates forty immutable documents.
|
|
321
|
+
//
|
|
322
|
+
// So the scope is SUGGESTED from evidence the tree already holds: the
|
|
323
|
+
// archived change that introduced an ADR records which specs it touched
|
|
324
|
+
// (`changes_archive[].specs_affected`), and the ADR's own text names the
|
|
325
|
+
// capabilities it governs. Both are hints. The agent or human confirms —
|
|
326
|
+
// the tool proposes, it never decides (ADR 0005).
|
|
327
|
+
function decisionScope(args, cmdFlags) {
|
|
328
|
+
const projectRoot = process.cwd();
|
|
329
|
+
ensureDoctrinaProject(projectRoot);
|
|
330
|
+
const doWrite = flagBool(cmdFlags, "write", false);
|
|
331
|
+
|
|
332
|
+
const index = idx.load(projectRoot);
|
|
333
|
+
const capabilities = new Set((index.artifacts.specs ?? []).map((s) => s.id));
|
|
334
|
+
const archive = index.artifacts.changes_archive ?? [];
|
|
335
|
+
const wanted = args[0] ? padNumber(parseInt(args[0], 10)) : null;
|
|
336
|
+
|
|
337
|
+
const rows = [];
|
|
338
|
+
for (const dec of index.artifacts.decisions ?? []) {
|
|
339
|
+
if (wanted && dec.id !== wanted) continue;
|
|
340
|
+
const file = path.join(projectRoot, dec.path);
|
|
341
|
+
if (!exists(file)) continue;
|
|
342
|
+
const text = read(file);
|
|
343
|
+
const declared = parseAdrScope(text);
|
|
344
|
+
|
|
345
|
+
// Signal 1 — the archived change that cites this ADR, and the specs
|
|
346
|
+
// that change touched. Strongest evidence: it is what actually moved.
|
|
347
|
+
const fromLedger = new Set();
|
|
348
|
+
for (const ch of archive) {
|
|
349
|
+
if (!archivedChangeCites(projectRoot, ch, dec.id)) continue;
|
|
350
|
+
for (const s of ch.specs_affected ?? []) {
|
|
351
|
+
if (capabilities.has(s.capability)) fromLedger.add(s.capability);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Signal 2 — capability ids the ADR's own text names, and only as a
|
|
355
|
+
// FALLBACK. Capability ids are ordinary words (`cli`, `core`, `docs`),
|
|
356
|
+
// so on a mature tree this signal matches nearly every ADR and a scope
|
|
357
|
+
// of "everything" is the same as no scope at all. The ledger records
|
|
358
|
+
// what actually moved; text only guesses. When the ledger has an
|
|
359
|
+
// answer, it is the answer.
|
|
360
|
+
const fromText = new Set();
|
|
361
|
+
if (fromLedger.size === 0) {
|
|
362
|
+
for (const cap of capabilities) {
|
|
363
|
+
const escaped = cap.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
364
|
+
if (new RegExp(`(^|[^a-z0-9-])${escaped}([^a-z0-9-]|$)`, "im").test(text)) {
|
|
365
|
+
fromText.add(cap);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const viaLedger = fromLedger.size > 0;
|
|
370
|
+
const suggested = [...(viaLedger ? fromLedger : fromText)].sort();
|
|
371
|
+
rows.push({ dec, file, text, declared, suggested, viaLedger });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (rows.length === 0) {
|
|
375
|
+
console.log(c.gray(wanted ? `no ADR ${wanted}` : "no ADRs found in .doctrina/decisions/"));
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
console.log(c.bold("ADR scope") + c.gray(" — an unscoped ADR is global: it loads into every pack"));
|
|
380
|
+
console.log("");
|
|
381
|
+
let wrote = 0;
|
|
382
|
+
for (const r of rows) {
|
|
383
|
+
const have = r.declared.length > 0
|
|
384
|
+
? c.green(r.declared.join(", "))
|
|
385
|
+
: c.gray("(global)");
|
|
386
|
+
console.log(` ${c.cyan(r.dec.id)} ${String(r.dec.title ?? "").slice(0, 48).padEnd(48)} ${have}`);
|
|
387
|
+
if (r.declared.length > 0 || r.suggested.length === 0) continue;
|
|
388
|
+
const via = r.viaLedger ? "ledger" : "text — confirm before writing";
|
|
389
|
+
console.log(` ${c.yellow("suggest:")} ${r.suggested.join(", ")} ${c.gray(`(${via})`)}`);
|
|
390
|
+
if (!doWrite) continue;
|
|
391
|
+
const updated = insertScopeHeader(r.text, r.suggested);
|
|
392
|
+
if (updated) {
|
|
393
|
+
write(r.file, updated, { force: true });
|
|
394
|
+
wrote += 1;
|
|
395
|
+
console.log(` ${c.green("written")}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
console.log("");
|
|
400
|
+
if (wrote > 0) {
|
|
401
|
+
console.log(c.green("ok") + ` scoped ${wrote} ADR${wrote === 1 ? "" : "s"}`);
|
|
402
|
+
console.log(c.gray("next: ") + c.cyan("doctrina index rebuild") + c.gray(" then review — a scope too narrow"));
|
|
403
|
+
console.log(c.gray(" hides a decision from the pack that needs it."));
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
const open = rows.filter((r) => r.declared.length === 0);
|
|
407
|
+
const actionable = open.filter((r) => r.suggested.length > 0).length;
|
|
408
|
+
console.log(c.gray(`${open.length} of ${rows.length} unscoped (global)`)
|
|
409
|
+
+ (actionable > 0 ? c.gray(`, ${actionable} with a suggestion`) : ""));
|
|
410
|
+
if (!doWrite && actionable > 0) {
|
|
411
|
+
console.log(c.gray("apply: ") + c.cyan("doctrina decision scope --write"));
|
|
412
|
+
}
|
|
413
|
+
return 0;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Does an archived change cite this ADR anywhere in its folder?
|
|
417
|
+
function archivedChangeCites(projectRoot, archived, adrId) {
|
|
418
|
+
if (!archived.path) return false;
|
|
419
|
+
const dir = path.join(projectRoot, archived.path);
|
|
420
|
+
for (const f of walk(dir)) {
|
|
421
|
+
if (f.endsWith(".md") && read(f).includes(`ADR ${adrId}`)) return true;
|
|
422
|
+
}
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Place `- **Scope:** a, b` in the metadata block, right after Status —
|
|
427
|
+
// where a reader looks for what a decision applies to.
|
|
428
|
+
function insertScopeHeader(text, scope) {
|
|
429
|
+
if (getHeader(text, "Scope") !== null) return null;
|
|
430
|
+
const line = `- **Scope:** ${scope.join(", ")}`;
|
|
431
|
+
const updated = text.replace(/^(-[ \t]+\*\*Status:\*\*.*)$/m, `$1\n${line}`);
|
|
432
|
+
return updated === text ? null : updated;
|
|
433
|
+
}
|
|
434
|
+
|
|
312
435
|
function nextDecisionNumber(projectRoot) {
|
|
313
436
|
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
314
437
|
const files = walk(adrDir);
|
|
@@ -325,7 +448,7 @@ function nextDecisionNumber(projectRoot) {
|
|
|
325
448
|
|
|
326
449
|
function ensureDoctrinaProject(projectRoot) {
|
|
327
450
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
328
|
-
throw
|
|
451
|
+
throw notADoctrinaProject();
|
|
329
452
|
}
|
|
330
453
|
}
|
|
331
454
|
|
|
@@ -345,4 +468,13 @@ Subcommands:
|
|
|
345
468
|
and rewrite the target's Status: header
|
|
346
469
|
list One line per ADR: number, status, date,
|
|
347
470
|
title. Read-only.
|
|
471
|
+
scope [<number>] [--write] Show which capabilities each ADR governs
|
|
472
|
+
and propose one for the unscoped, from the
|
|
473
|
+
archived change that cites it. An unscoped
|
|
474
|
+
ADR is global: it loads into every context
|
|
475
|
+
pack. --write applies the suggestions.
|
|
476
|
+
|
|
477
|
+
Options:
|
|
478
|
+
--write scope: apply the suggested scopes
|
|
479
|
+
--json Machine-readable output
|
|
348
480
|
`;
|
package/src/commands/doctor.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { spawnSync } from "node:child_process";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { exists } from "../lib/fs-ops.js";
|
|
6
7
|
import { collectStatus } from "./status.js";
|
|
8
|
+
import { collectFindings } from "./templates.js";
|
|
7
9
|
import { c } from "../lib/colors.js";
|
|
10
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
8
11
|
|
|
9
12
|
// Aggregate diagnostic: the one command to run when "something looks wrong"
|
|
10
13
|
// and you do not know which gate to ask. It sequences the existing checks —
|
|
@@ -15,10 +18,15 @@ import { c } from "../lib/colors.js";
|
|
|
15
18
|
|
|
16
19
|
const cliEntry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "index.js");
|
|
17
20
|
|
|
21
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
22
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
23
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
24
|
+
export const flags = { boolean: ["json"], string: [] };
|
|
25
|
+
|
|
18
26
|
export async function run(_positional, _flags) {
|
|
19
27
|
const projectRoot = process.cwd();
|
|
20
28
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
21
|
-
throw
|
|
29
|
+
throw notADoctrinaProject();
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
console.log(c.bold("Doctrina doctor") + c.gray(" — aggregate diagnostic (read-only):"));
|
|
@@ -84,12 +92,35 @@ export async function run(_positional, _flags) {
|
|
|
84
92
|
if (clean.status === 0) row("ok", "clean-checkout", "no reproducibility footguns detected");
|
|
85
93
|
else row("fail", "clean-checkout", "a fresh clone would not build/run as-is", "doctrina verify --clean (fix the listed package.json footguns)");
|
|
86
94
|
|
|
87
|
-
// 5. Template shape vs the running CLI's recommendation.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
// 5. Template shape vs the running CLI's recommendation. The row reports
|
|
96
|
+
// the ACTUAL findings and the remedy each one names, rather than
|
|
97
|
+
// assuming every failure is a missing section — an adapter pointer
|
|
98
|
+
// break was reported as "recommended sections/fields are missing" and
|
|
99
|
+
// sent the user to `templates update`, which does not touch adapters
|
|
100
|
+
// and so could never clear it (C2).
|
|
101
|
+
{
|
|
102
|
+
let tmplFindings = [];
|
|
103
|
+
try {
|
|
104
|
+
tmplFindings = collectFindings(projectRoot).findings;
|
|
105
|
+
} catch {
|
|
106
|
+
tmplFindings = [];
|
|
107
|
+
}
|
|
108
|
+
if (tmplFindings.length === 0) {
|
|
109
|
+
row("ok", "templates", "project follows the current template shape");
|
|
110
|
+
} else {
|
|
111
|
+
// Group by remedy so the row names what actually fixes what.
|
|
112
|
+
const byRemedy = new Map();
|
|
113
|
+
for (const f of tmplFindings) {
|
|
114
|
+
const key = f.remedy ?? "(manual repair)";
|
|
115
|
+
byRemedy.set(key, (byRemedy.get(key) ?? 0) + 1);
|
|
116
|
+
}
|
|
117
|
+
const summary = tmplFindings.length === 1
|
|
118
|
+
? tmplFindings[0].message
|
|
119
|
+
: `${tmplFindings.length} template findings`;
|
|
120
|
+
const remedy = [...byRemedy.keys()].filter((k) => k !== "(manual repair)");
|
|
121
|
+
row("warn", "templates", summary, remedy.length === 1 ? remedy[0] : "doctrina templates check (each finding names its own fix)");
|
|
122
|
+
warningsTotal += 1;
|
|
123
|
+
}
|
|
93
124
|
}
|
|
94
125
|
|
|
95
126
|
// 6. Verify configuration (the real build gate must exist to be run).
|
package/src/commands/handoff.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { exists } from "../lib/fs-ops.js";
|
|
@@ -6,6 +7,7 @@ import { computeActions } from "./next.js";
|
|
|
6
7
|
import { openChanges } from "./prime.js";
|
|
7
8
|
import { today } from "../lib/dates.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
10
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
9
11
|
|
|
10
12
|
// Session handoff note: everything the NEXT session (a fresh agent, a
|
|
11
13
|
// teammate, tomorrow's you) needs to resume without re-reading the tree —
|
|
@@ -16,10 +18,15 @@ import { c } from "../lib/colors.js";
|
|
|
16
18
|
// a stored file: a saved note goes stale the moment work continues, the
|
|
17
19
|
// tree never does (no new home for facts).
|
|
18
20
|
|
|
21
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
22
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
23
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
24
|
+
export const flags = { boolean: ["json"], string: [] };
|
|
25
|
+
|
|
19
26
|
export async function run(_positional, _flags) {
|
|
20
27
|
const projectRoot = process.cwd();
|
|
21
28
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
22
|
-
throw
|
|
29
|
+
throw notADoctrinaProject();
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
const s = collectStatus(projectRoot);
|
package/src/commands/hooks.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { chmodSync } from "node:fs";
|
|
@@ -9,6 +10,11 @@ import { suggest } from "../lib/suggest.js";
|
|
|
9
10
|
|
|
10
11
|
const SUBCOMMANDS = ["install"];
|
|
11
12
|
|
|
13
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
14
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
15
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
16
|
+
export const flags = { boolean: ["json", "force"], string: [] };
|
|
17
|
+
|
|
12
18
|
export async function run(positional, flags) {
|
|
13
19
|
const sub = positional[0];
|
|
14
20
|
if (!SUBCOMMANDS.includes(sub)) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { exists } from "../lib/fs-ops.js";
|
|
@@ -8,9 +9,15 @@ import { today } from "../lib/dates.js";
|
|
|
8
9
|
import { flagBool } from "../lib/args.js";
|
|
9
10
|
import { c } from "../lib/colors.js";
|
|
10
11
|
import { suggest } from "../lib/suggest.js";
|
|
12
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
11
13
|
|
|
12
14
|
const SUBCOMMANDS = ["rebuild"];
|
|
13
15
|
|
|
16
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
17
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
18
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
19
|
+
export const flags = { boolean: ["json", "check"], string: [] };
|
|
20
|
+
|
|
14
21
|
export async function run(positional, flags) {
|
|
15
22
|
const sub = positional[0];
|
|
16
23
|
if (sub !== "rebuild") {
|
|
@@ -28,7 +35,7 @@ function rebuild(flags) {
|
|
|
28
35
|
const check = flagBool(flags, "check", false);
|
|
29
36
|
const projectRoot = process.cwd();
|
|
30
37
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
31
|
-
throw
|
|
38
|
+
throw notADoctrinaProject();
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
let current = null;
|