doctrina-cli 0.13.0 → 0.15.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 +120 -17
- 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 +391 -58
- package/src/commands/contract.js +76 -43
- package/src/commands/coverage.js +126 -12
- package/src/commands/decision.js +172 -40
- package/src/commands/doctor.js +92 -11
- 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 +38 -3
- 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 +201 -8
- 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/triage.js +248 -0
- package/src/commands/upgrade.js +8 -1
- package/src/commands/validate.js +148 -5
- package/src/commands/verify.js +126 -5
- package/src/commands/watch.js +8 -1
- package/src/commands/why.js +10 -24
- package/src/commands/work.js +44 -3
- package/src/index.js +61 -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 +252 -27
- 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/pipeline.js +161 -0
- package/src/lib/prompt.js +18 -1
- package/src/lib/runtime.js +683 -0
- 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/templates/contract.md.template +74 -3
- package/templates/spec.md.template +22 -0
package/src/lib/scan.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getHeader, getSection } from "./doc-model.js";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import { readdirSync } from "node:fs";
|
|
3
5
|
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
@@ -5,29 +7,18 @@ import { today } from "./dates.js";
|
|
|
5
7
|
import { parseFrontmatter } from "../commands/skill.js";
|
|
6
8
|
import { parseOperation, parseCapabilityFromDelta } from "../commands/change.js";
|
|
7
9
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// agent hand-authoring an ADR with the bare spec form, or vice versa)
|
|
13
|
-
// still parses instead of silently falling back to a default value.
|
|
14
|
-
const HEADER_PREFIX = "(?:-\\s+)?";
|
|
15
|
-
|
|
16
|
-
// Parse a spec-style header line: **Name:** value (dash optional).
|
|
10
|
+
// Header reading lives in ONE place now (lib/doc-model.js, audit item M3).
|
|
11
|
+
// These two names survive because dozens of call sites use them and the
|
|
12
|
+
// distinction they once encoded — spec style vs list style — is now the
|
|
13
|
+
// document model's job, not the caller's.
|
|
17
14
|
export function specHeader(text, name) {
|
|
18
|
-
|
|
19
|
-
return m ? m[1].trim() : null;
|
|
15
|
+
return getHeader(text, name);
|
|
20
16
|
}
|
|
21
17
|
|
|
22
|
-
// Parse an ADR/proposal-style header line: - **Name:** value (dash optional).
|
|
23
18
|
export function listHeader(text, name) {
|
|
24
|
-
|
|
25
|
-
return m ? m[1].trim() : null;
|
|
19
|
+
return getHeader(text, name);
|
|
26
20
|
}
|
|
27
21
|
|
|
28
|
-
// The machine-readable "**Depends on:** defects, decisions" spec header: the
|
|
29
|
-
// sibling capabilities this spec builds on. Returns [] when absent, "n/a", or
|
|
30
|
-
// empty. One parser so index, why, context, and review agree.
|
|
31
22
|
export function parseDependsOn(text) {
|
|
32
23
|
const raw = specHeader(text, "Depends on");
|
|
33
24
|
if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
|
|
@@ -39,6 +30,72 @@ function dirEntries(dir) {
|
|
|
39
30
|
return readdirSync(dir).filter((e) => !e.startsWith(".")).sort();
|
|
40
31
|
}
|
|
41
32
|
|
|
33
|
+
|
|
34
|
+
// The capabilities an ADR governs, from its optional "Scope:" header
|
|
35
|
+
// (M4). Returns [] when absent, "n/a", or "—" — an unscoped ADR is global
|
|
36
|
+
// and belongs in every pack, which is what keeps this backward compatible.
|
|
37
|
+
export function parseAdrScope(text) {
|
|
38
|
+
const raw = listHeader(text, "Scope");
|
|
39
|
+
if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
|
|
40
|
+
return raw.match(/[a-z][a-z0-9][a-z0-9-]*/g) ?? [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// One sentence describing what an ADR decided, taken from its "## Decision"
|
|
44
|
+
// section. This is what a budget-constrained pack falls back to before it
|
|
45
|
+
// drops an ADR entirely: a title and a sentence still carry the decision,
|
|
46
|
+
// where an omission carries nothing.
|
|
47
|
+
export function adrSummary(text) {
|
|
48
|
+
const body = getSection(text, "Decision");
|
|
49
|
+
if (!body) return null;
|
|
50
|
+
for (const line of body.split("\n")) {
|
|
51
|
+
const s = line.trim();
|
|
52
|
+
if (!s || s.startsWith("<!--") || s.startsWith("#") || s.startsWith("|")) continue;
|
|
53
|
+
// Take the first sentence, capped so the index stays scannable.
|
|
54
|
+
const plain = s.replace(/^[-*\d.]+\s*/, "").replace(/\*\*/g, "");
|
|
55
|
+
const sentence = (plain.match(/^.*?[.!?](?=\s|$)/) ?? [plain])[0].trim();
|
|
56
|
+
if (sentence.length < 12) continue;
|
|
57
|
+
return sentence.length > 220 ? sentence.slice(0, 217).trimEnd() + "..." : sentence;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The index entry for ONE decision, derived from its file. This is the
|
|
63
|
+
// single definition of a decision record: `deriveIndex` builds the whole
|
|
64
|
+
// list from it, and `doctrina decision new` registers a new ADR through it
|
|
65
|
+
// rather than assembling a look-alike by hand. Two constructors for one
|
|
66
|
+
// record shape is how a field added to the deriver (M4's `summary`) turns
|
|
67
|
+
// every freshly created ADR into index drift the moment it is written.
|
|
68
|
+
export function decisionEntry(text, basename, prev, date) {
|
|
69
|
+
const id = basename.match(/^(\d{4})-/)?.[1] ?? prev?.id ?? "0000";
|
|
70
|
+
const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
|
|
71
|
+
const entry = {
|
|
72
|
+
id,
|
|
73
|
+
path: `.doctrina/decisions/${basename}`,
|
|
74
|
+
title: titleMatch ? titleMatch[1].trim() : prev?.title ?? basename,
|
|
75
|
+
status: listHeader(text, "Status") ?? prev?.status ?? "proposed",
|
|
76
|
+
date: listHeader(text, "Date") ?? prev?.date ?? date,
|
|
77
|
+
};
|
|
78
|
+
const supersedes = listHeader(text, "Supersedes");
|
|
79
|
+
if (supersedes && supersedes !== "—") entry.supersedes = supersedes;
|
|
80
|
+
const supersededBy = listHeader(text, "Superseded by");
|
|
81
|
+
if (supersededBy && supersededBy !== "—") entry.superseded_by = supersededBy;
|
|
82
|
+
const landed = listHeader(text, "Landed");
|
|
83
|
+
if (landed && landed !== "—") entry.landed = landed;
|
|
84
|
+
// Scope (M4): the capabilities this decision governs. ADRs are immutable
|
|
85
|
+
// and never retire, so without a scope every accepted ADR loads into every
|
|
86
|
+
// context pack forever — the pack grows O(project age) with no decay. An
|
|
87
|
+
// ADR with no Scope: is GLOBAL by definition, the backward-compatible
|
|
88
|
+
// default.
|
|
89
|
+
const scope = parseAdrScope(text);
|
|
90
|
+
if (scope.length > 0) entry.scope = scope;
|
|
91
|
+
// Summary: one sentence from the Decision section, so a pack over budget
|
|
92
|
+
// can degrade an ADR to title + summary instead of dropping it outright.
|
|
93
|
+
const summary = adrSummary(text);
|
|
94
|
+
if (summary) entry.summary = summary;
|
|
95
|
+
return entry;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
42
99
|
// Regenerate the index object from the artifacts on disk. The files are
|
|
43
100
|
// the source of truth; fields with no on-disk source (project name,
|
|
44
101
|
// framework_version, product metadata) are carried over from `current`.
|
|
@@ -52,6 +109,12 @@ export function deriveIndex(projectRoot, current) {
|
|
|
52
109
|
project: current?.project ?? path.basename(projectRoot),
|
|
53
110
|
framework_version: current?.framework_version ?? "0.0.0",
|
|
54
111
|
last_updated: current?.last_updated ?? date,
|
|
112
|
+
// Project settings, carried over verbatim. `deriveIndex` rebuilds the
|
|
113
|
+
// artifact graph FROM DISK, so anything it does not explicitly carry is
|
|
114
|
+
// silently erased on the next `index rebuild` — config has no on-disk
|
|
115
|
+
// source to be rederived from, which is exactly why it must be listed
|
|
116
|
+
// here alongside project and framework_version. Absent means defaults.
|
|
117
|
+
...(current?.config !== undefined ? { config: current.config } : {}),
|
|
55
118
|
artifacts: {
|
|
56
119
|
product: cur.product ?? {
|
|
57
120
|
path: ".doctrina/product.md",
|
|
@@ -112,23 +175,8 @@ export function deriveIndex(projectRoot, current) {
|
|
|
112
175
|
const base = path.basename(f);
|
|
113
176
|
const m = base.match(/^(\d{4})-.*\.md$/);
|
|
114
177
|
if (!m) continue;
|
|
115
|
-
const text = read(f);
|
|
116
178
|
const prev = (cur.decisions ?? []).find((d) => d.id === m[1]);
|
|
117
|
-
|
|
118
|
-
const entry = {
|
|
119
|
-
id: m[1],
|
|
120
|
-
path: `.doctrina/decisions/${base}`,
|
|
121
|
-
title: titleMatch ? titleMatch[1].trim() : prev?.title ?? base,
|
|
122
|
-
status: listHeader(text, "Status") ?? prev?.status ?? "proposed",
|
|
123
|
-
date: listHeader(text, "Date") ?? prev?.date ?? date,
|
|
124
|
-
};
|
|
125
|
-
const supersedes = listHeader(text, "Supersedes");
|
|
126
|
-
if (supersedes && supersedes !== "—") entry.supersedes = supersedes;
|
|
127
|
-
const supersededBy = listHeader(text, "Superseded by");
|
|
128
|
-
if (supersededBy && supersededBy !== "—") entry.superseded_by = supersededBy;
|
|
129
|
-
const landed = listHeader(text, "Landed");
|
|
130
|
-
if (landed && landed !== "—") entry.landed = landed;
|
|
131
|
-
out.artifacts.decisions.push(entry);
|
|
179
|
+
out.artifacts.decisions.push(decisionEntry(read(f), base, prev, date));
|
|
132
180
|
}
|
|
133
181
|
|
|
134
182
|
// Open changes — every directory except archive/.
|
package/src/lib/spec-ops.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
// Structured spec operations (ADR 0007 / review F3). A MODIFIED spec delta
|
|
2
3
|
// historically carried prose ("flip criterion 2 to verified", "bump to
|
|
3
4
|
// 0.3.0") that a human merged by hand — the single most error-prone step in
|
|
@@ -32,6 +33,9 @@
|
|
|
32
33
|
// no ops block, apply falls back to the manual-merge pointer (backward
|
|
33
34
|
// compatible).
|
|
34
35
|
|
|
36
|
+
// The read grammar is the document model's (M3); this local pattern exists
|
|
37
|
+
// only because the ops verbs REWRITE headers in place and need the prefix
|
|
38
|
+
// captured to preserve it.
|
|
35
39
|
const HEADER_RE = (name) =>
|
|
36
40
|
new RegExp(`^(\\s*(?:-\\s+)?\\*\\*${escapeRe(name)}:\\*\\*)\\s*(.*)$`, "m");
|
|
37
41
|
|
|
@@ -57,14 +61,28 @@ export function extractOps(deltaText) {
|
|
|
57
61
|
|
|
58
62
|
// Find the body of the first ```ops fenced block, or null if absent. The
|
|
59
63
|
// info string must be exactly "ops" (case-insensitive) so a normal code
|
|
60
|
-
// fence in the prose is never mistaken for operations.
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
+
// fence in the prose is never mistaken for operations.
|
|
65
|
+
//
|
|
66
|
+
// A fence sitting INSIDE an HTML comment is skipped: the scaffolded delta
|
|
67
|
+
// template carries an EXAMPLE ops block in its instructional comment, and
|
|
68
|
+
// executing an example against a real spec is exactly the surprise this
|
|
69
|
+
// guard removes. The comment is skipped by POSITION, never by deleting the
|
|
70
|
+
// comment text first — stripping mutated ops values that legitimately
|
|
71
|
+
// contain a comment (an `<!-- illustrative -->` marker inside an
|
|
72
|
+
// append-criterion landed in the spec with the marker silently gutted).
|
|
64
73
|
function matchOpsBlock(text) {
|
|
65
|
-
const
|
|
66
|
-
const m
|
|
67
|
-
|
|
74
|
+
const commentRanges = [];
|
|
75
|
+
for (const m of text.matchAll(/<!--[\s\S]*?-->/g)) {
|
|
76
|
+
commentRanges.push([m.index, m.index + m[0].length]);
|
|
77
|
+
}
|
|
78
|
+
const insideComment = (offset) => commentRanges.some(([a, b]) => offset >= a && offset < b);
|
|
79
|
+
|
|
80
|
+
const fenceRe = /^[ \t]*```[ \t]*ops[ \t]*\r?\n([\s\S]*?)^[ \t]*```[ \t]*$/gm;
|
|
81
|
+
let m;
|
|
82
|
+
while ((m = fenceRe.exec(text))) {
|
|
83
|
+
if (!insideComment(m.index)) return m[1];
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
68
86
|
}
|
|
69
87
|
|
|
70
88
|
function parseOpLine(line) {
|
|
@@ -322,15 +340,82 @@ function endOfItem(lines, startIndex) {
|
|
|
322
340
|
return i;
|
|
323
341
|
}
|
|
324
342
|
|
|
343
|
+
// The EARS subsections in the order the spec template lays them out. A
|
|
344
|
+
// created subsection goes where a reader expects it, not at the end.
|
|
345
|
+
const EARS_ORDER = ["ubiquitous", "event", "state", "unwanted", "optional"];
|
|
346
|
+
const EARS_HEADINGS = {
|
|
347
|
+
ubiquitous: "### Ubiquitous",
|
|
348
|
+
event: "### Event-driven",
|
|
349
|
+
state: "### State-driven",
|
|
350
|
+
unwanted: "### Unwanted-behavior (must-not)",
|
|
351
|
+
optional: "### Optional",
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// Create an absent `### <EARS>` subsection under `## Requirements (EARS)`,
|
|
355
|
+
// carrying its first requirement, in canonical order: after the last
|
|
356
|
+
// existing subsection that precedes it, or ahead of them all when none
|
|
357
|
+
// does. The requirement goes in with the heading rather than in a second
|
|
358
|
+
// pass, so the blank lines around the new block are exact.
|
|
359
|
+
function createEarsSubsection(loc, section, value) {
|
|
360
|
+
const heading = EARS_HEADINGS[section];
|
|
361
|
+
if (!heading) return { error: `append-requirement: unknown EARS subsection "${section}"` };
|
|
362
|
+
const lines = [...loc.lines];
|
|
363
|
+
|
|
364
|
+
let insertAt = null;
|
|
365
|
+
const rank = EARS_ORDER.indexOf(section);
|
|
366
|
+
for (const token of EARS_ORDER.slice(0, rank).reverse()) {
|
|
367
|
+
const prior = loc.sections[token];
|
|
368
|
+
if (prior) {
|
|
369
|
+
// End of that subsection, trimmed back past the blank lines it trails.
|
|
370
|
+
let end = Math.min(prior.endIndex, lines.length);
|
|
371
|
+
while (end > prior.headingIndex + 1 && lines[end - 1].trim() === "") end -= 1;
|
|
372
|
+
insertAt = end;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (insertAt === null) {
|
|
377
|
+
// Nothing precedes it: place it ahead of whichever subsection currently
|
|
378
|
+
// comes first, or directly under the Requirements heading.
|
|
379
|
+
const first = EARS_ORDER.map((t) => loc.sections[t]).find(Boolean);
|
|
380
|
+
if (first) {
|
|
381
|
+
insertAt = first.headingIndex;
|
|
382
|
+
} else {
|
|
383
|
+
const reqIndex = lines.findIndex((l) => /^##\s+Requirements\b/i.test(l));
|
|
384
|
+
if (reqIndex < 0) return { error: "append-requirement: spec has no '## Requirements (EARS)' section" };
|
|
385
|
+
insertAt = reqIndex + 1;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Exactly one blank line on each side, borrowing whichever the
|
|
390
|
+
// insertion point already provides rather than adding a second.
|
|
391
|
+
const block = [heading, "", `- ${value}`];
|
|
392
|
+
if (insertAt > 0 && lines[insertAt - 1].trim() !== "") block.unshift("");
|
|
393
|
+
if (lines[insertAt] !== undefined && lines[insertAt].trim() !== "") block.push("");
|
|
394
|
+
lines.splice(insertAt, 0, ...block);
|
|
395
|
+
return { text: lines.join("\n") };
|
|
396
|
+
}
|
|
397
|
+
|
|
325
398
|
// append-requirement <section>: <text> — add "- <text>" at the end of the
|
|
326
399
|
// EARS subsection. A lone "-" placeholder bullet (the untouched scaffold) is
|
|
327
400
|
// replaced instead of appended after, so the first real requirement does not
|
|
328
401
|
// land under an empty stub.
|
|
329
402
|
export function appendRequirement(text, section, value) {
|
|
330
|
-
|
|
403
|
+
let loc = locateRequirements(text);
|
|
331
404
|
if (!loc) return { error: "append-requirement: spec has no '## Requirements (EARS)' section" };
|
|
405
|
+
if (!loc.sections[section]) {
|
|
406
|
+
// The subsection does not exist yet. Refusing here is a dead end: the
|
|
407
|
+
// author cannot hand-edit the spec (AGENTS.md forbids editing specs
|
|
408
|
+
// outside a delta) and no op creates a subsection, so a spec that was
|
|
409
|
+
// split — losing the EARS headings it had no requirements for — could
|
|
410
|
+
// never receive one again. Create it, in canonical EARS order.
|
|
411
|
+
const created = createEarsSubsection(loc, section, value);
|
|
412
|
+
if (created.error) return created;
|
|
413
|
+
return {
|
|
414
|
+
text: created.text,
|
|
415
|
+
summary: `append-requirement ${section} (created subsection): ${value.slice(0, 44)}`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
332
418
|
const sec = loc.sections[section];
|
|
333
|
-
if (!sec) return { error: `append-requirement: no '### ${section}...' subsection found under Requirements` };
|
|
334
419
|
const placeholder = sec.bullets.length === 1 && /^\s*-\s*$/.test(loc.lines[sec.bullets[0]]);
|
|
335
420
|
if (placeholder) {
|
|
336
421
|
loc.lines[sec.bullets[0]] = `- ${value}`;
|
package/src/lib/suggest.js
CHANGED
package/src/lib/templates.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { read, toPosix, walk, write } from "./fs-ops.js";
|
|
@@ -35,6 +36,94 @@ export function locateTemplatesDir() {
|
|
|
35
36
|
);
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// The project-local override chain (audit item M1).
|
|
41
|
+
//
|
|
42
|
+
// `doctrina init` creates `.doctrina/templates/` containing a single
|
|
43
|
+
// `.gitkeep`, and nothing ever read it or wrote to it. The resolver above
|
|
44
|
+
// answers only "where did the CLI install its templates?", so a directory
|
|
45
|
+
// the framework creates in every project was inert — a ghost.
|
|
46
|
+
//
|
|
47
|
+
// Resolution is now a CHAIN, per file: a template present under the
|
|
48
|
+
// project's `.doctrina/templates/` wins; anything absent falls back to the
|
|
49
|
+
// bundled copy. That turns the ghost into the customisation point, and it
|
|
50
|
+
// is the same wiring the custom-adapter support needs (ADR 0016).
|
|
51
|
+
//
|
|
52
|
+
// Deliberately per FILE, not per directory: a team that wants its own
|
|
53
|
+
// `spec.md.template` should not have to vendor the whole tree and then
|
|
54
|
+
// maintain every other template forever.
|
|
55
|
+
|
|
56
|
+
export function projectTemplatesDir(projectRoot) {
|
|
57
|
+
return path.join(projectRoot, ".doctrina", "templates");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Where a named template resolves for this project.
|
|
61
|
+
// Returns { path, source: "project" | "bundled" } or null when neither has
|
|
62
|
+
// it. `relativePath` is POSIX-ish and relative to a templates root, e.g.
|
|
63
|
+
// "spec.md.template" or "change/proposal.md.template".
|
|
64
|
+
export function resolveTemplate(projectRoot, relativePath) {
|
|
65
|
+
const rel = relativePath.split("/").join(path.sep);
|
|
66
|
+
if (projectRoot) {
|
|
67
|
+
const local = path.join(projectTemplatesDir(projectRoot), rel);
|
|
68
|
+
if (fileExists(local)) return { path: local, source: "project" };
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const bundled = path.join(locateTemplatesDir(), rel);
|
|
72
|
+
if (fileExists(bundled)) return { path: bundled, source: "bundled" };
|
|
73
|
+
} catch { /* no bundled tree (unusual install) */ }
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Read a template through the chain. Throws with both candidates named
|
|
78
|
+
// when neither side has it, because "template not found" with no path is
|
|
79
|
+
// the least actionable error a scaffolder can give.
|
|
80
|
+
export function readTemplate(projectRoot, relativePath) {
|
|
81
|
+
const resolved = resolveTemplate(projectRoot, relativePath);
|
|
82
|
+
if (!resolved) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`template "${relativePath}" not found in the project override ` +
|
|
85
|
+
`(${projectTemplatesDir(projectRoot)}) or in the installed CLI`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return { ...resolved, body: read(resolved.path) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Every template the chain can serve, with where each one resolved from —
|
|
92
|
+
// what `templates list` prints so the source is never a guess.
|
|
93
|
+
export function listResolvedTemplates(projectRoot) {
|
|
94
|
+
const seen = new Map();
|
|
95
|
+
let bundledRoot = null;
|
|
96
|
+
try {
|
|
97
|
+
bundledRoot = locateTemplatesDir();
|
|
98
|
+
} catch { /* none */ }
|
|
99
|
+
|
|
100
|
+
if (bundledRoot) {
|
|
101
|
+
for (const f of walk(bundledRoot)) {
|
|
102
|
+
const rel = toPosix(path.relative(bundledRoot, f));
|
|
103
|
+
seen.set(rel, { relativePath: rel, path: f, source: "bundled" });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const localRoot = projectRoot ? projectTemplatesDir(projectRoot) : null;
|
|
107
|
+
if (localRoot) {
|
|
108
|
+
for (const f of walk(localRoot)) {
|
|
109
|
+
const rel = toPosix(path.relative(localRoot, f));
|
|
110
|
+
if (rel === ".gitkeep") continue;
|
|
111
|
+
const overrides = seen.has(rel);
|
|
112
|
+
seen.set(rel, { relativePath: rel, path: f, source: "project", overrides });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return [...seen.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function fileExists(p) {
|
|
119
|
+
try {
|
|
120
|
+
read(p);
|
|
121
|
+
return true;
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
38
127
|
// Single-pass {{TOKEN}} substitution. Tokens are uppercase, digits, or
|
|
39
128
|
// underscore inside double curly braces.
|
|
40
129
|
export function substitute(text, tokens) {
|
package/src/lib/usage.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Audit item M8: instrument the command surface before shrinking it.
|
|
3
|
+
//
|
|
4
|
+
// The surface is 36 commands and 59 operations. The audit's charge is that
|
|
5
|
+
// some of them exist because they were easy to add, not because anybody
|
|
6
|
+
// reaches for them — and the honest way to find out which is to MEASURE,
|
|
7
|
+
// not to guess from the outside. Guessing is how `prime`, `handoff` and
|
|
8
|
+
// `doctor` were nearly cut in 0.13.0, right before the operator review
|
|
9
|
+
// found they had gone unused only because AGENTS.md never named them.
|
|
10
|
+
//
|
|
11
|
+
// The design constraints this is built under:
|
|
12
|
+
//
|
|
13
|
+
// OFF unless asked. No file appears, and nothing is recorded, until the
|
|
14
|
+
// operator sets DOCTRINA_USAGE_LOG. A tool that starts logging because
|
|
15
|
+
// it was updated has broken faith regardless of what it logs.
|
|
16
|
+
// LOCAL only. One append-only JSONL file at a path the operator names.
|
|
17
|
+
// The CLI makes no network calls at all (a spec requirement), and this
|
|
18
|
+
// changes nothing about that.
|
|
19
|
+
// NO ARGUMENTS. The command and the sub-operation, never the values.
|
|
20
|
+
// `change apply 0021-secret-project` records `change apply`. Paths,
|
|
21
|
+
// titles, prompts and ids are the user's, not the instrument's.
|
|
22
|
+
// NEVER FATAL. A failure to record is silent. Losing a usage sample
|
|
23
|
+
// matters less than nothing, and losing the command's real work to an
|
|
24
|
+
// instrumentation error would be absurd.
|
|
25
|
+
|
|
26
|
+
import { appendFileSync } from "node:fs";
|
|
27
|
+
import process from "node:process";
|
|
28
|
+
|
|
29
|
+
export const USAGE_ENV = "DOCTRINA_USAGE_LOG";
|
|
30
|
+
|
|
31
|
+
// Which operation was invoked, with no user data attached.
|
|
32
|
+
//
|
|
33
|
+
// The second positional is kept ONLY when the catalog says this command has
|
|
34
|
+
// that sub-operation. Shape alone is not enough to tell a sub-command from
|
|
35
|
+
// an argument: `context cli` and `spec new` are identical in shape, but the
|
|
36
|
+
// first is a capability name. Guessing recorded `context cli` as an
|
|
37
|
+
// operation and left the real `context` looking unused — which is exactly
|
|
38
|
+
// the wrong answer from an instrument whose whole job is deciding what
|
|
39
|
+
// nobody uses.
|
|
40
|
+
export function operationOf(positional, knownOperations) {
|
|
41
|
+
const command = positional[0];
|
|
42
|
+
if (!command) return null;
|
|
43
|
+
const next = positional[1];
|
|
44
|
+
if (typeof next !== "string" || !/^[a-z][a-z-]{1,20}$/.test(next)) return command;
|
|
45
|
+
const pair = `${command} ${next}`;
|
|
46
|
+
return knownOperations?.has(pair) ? pair : command;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Append one sample. Returns true when it recorded, false otherwise — the
|
|
50
|
+
// tests assert on that rather than on the file, so the "off by default"
|
|
51
|
+
// guarantee is checked directly.
|
|
52
|
+
export function recordUsage(positional, exitCode, knownOperations, env = process.env) {
|
|
53
|
+
const target = env[USAGE_ENV];
|
|
54
|
+
if (!target) return false;
|
|
55
|
+
const operation = operationOf(positional, knownOperations);
|
|
56
|
+
if (!operation) return false;
|
|
57
|
+
try {
|
|
58
|
+
appendFileSync(target, JSON.stringify({
|
|
59
|
+
at: new Date().toISOString(),
|
|
60
|
+
operation,
|
|
61
|
+
exit: exitCode ?? 0,
|
|
62
|
+
}) + "\n");
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
// Instrumentation never breaks the command it is measuring.
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Summarise a usage log: how often each operation ran, and — the number the
|
|
71
|
+
// shrink decision actually turns on — which catalog operations never ran at
|
|
72
|
+
// all. An operation with zero samples is a CANDIDATE for cutting or
|
|
73
|
+
// folding, never a verdict: a command used once a quarter (`init`) and a
|
|
74
|
+
// command nobody wants look identical over one week.
|
|
75
|
+
export function summarise(logText, catalogOperations) {
|
|
76
|
+
const counts = new Map();
|
|
77
|
+
let samples = 0;
|
|
78
|
+
for (const line of String(logText).split("\n")) {
|
|
79
|
+
if (!line.trim()) continue;
|
|
80
|
+
let row;
|
|
81
|
+
try { row = JSON.parse(line); } catch { continue; }
|
|
82
|
+
if (!row?.operation) continue;
|
|
83
|
+
counts.set(row.operation, (counts.get(row.operation) ?? 0) + 1);
|
|
84
|
+
samples += 1;
|
|
85
|
+
}
|
|
86
|
+
const used = [...counts.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
|
|
87
|
+
const unused = catalogOperations.filter((op) => !counts.has(op)).sort();
|
|
88
|
+
return { samples, used, unused };
|
|
89
|
+
}
|
package/src/lib/version.js
CHANGED
|
@@ -33,19 +33,42 @@ prompt and approves; do not make them run the CLI or hand-author artifacts.
|
|
|
33
33
|
<!-- doctrina:surface:begin — CLI-owned block, generated from the installed command catalog. Refreshed by `doctrina upgrade --write`; edits inside are overwritten. -->
|
|
34
34
|
## Doctrina command surface (generated — reach for these, don't hand-author)
|
|
35
35
|
|
|
36
|
-
Every
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
|
|
43
|
-
-
|
|
44
|
-
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
`doctrina
|
|
48
|
-
`doctrina
|
|
36
|
+
Every command, with the moment you reach for it. The CLI scaffolds from
|
|
37
|
+
canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.
|
|
38
|
+
**Bootstrap**
|
|
39
|
+
- `doctrina init` — scaffold AGENTS.md and .doctrina/. *When:* starting a project that has no AGENTS.md yet.
|
|
40
|
+
- `doctrina intake` — store the intent and print the bootstrap playbook. *When:* you have a full project description and no specs yet.
|
|
41
|
+
- `doctrina adapter list|add|remove` — install/remove agent adapters (additive). *When:* adding or removing an agent's pointer files.
|
|
42
|
+
**Orient**
|
|
43
|
+
- `doctrina prime (session start)` — gates, standing rules, and open work in one read. *When:* at the START of every session.
|
|
44
|
+
- `doctrina context [<cap>] --for "<task>" --concat` — the read pack, assembled to fit a token budget. *When:* before working on any task, to load the right files.
|
|
45
|
+
- `doctrina show` — point-read a single artifact by reference. *When:* you need one requirement, criterion, or ADR, not a file.
|
|
46
|
+
- `doctrina search` — search the artifact tree, grouped by category. *When:* you do not know which artifact mentions a term.
|
|
47
|
+
- `doctrina status` — index, coverage, trace, and artifact counts. *When:* you need the health of the tree at a glance.
|
|
48
|
+
- `doctrina next` — the recommended next workflow action. *When:* you finished something and do not know what follows.
|
|
49
|
+
- `doctrina why` — provenance: intent, proof, ADRs, and history. *When:* you need to justify or trace a capability's existence.
|
|
50
|
+
- `doctrina handoff (before compaction/handover)` — a resume note: open work, task state, next command. *When:* BEFORE compaction or handing over to another session.
|
|
51
|
+
- `doctrina constitution` — accepted ADRs and product non-goals. *When:* you need the standing rules before deciding something.
|
|
52
|
+
**Change**
|
|
53
|
+
- `doctrina triage` — classify the lane (product/runtime/chore) and check the declared runtime surface. *When:* a request arrives — BEFORE scaffolding, especially if it smells like an incident.
|
|
54
|
+
- `doctrina work "<prompt>" (--capability · --chore · --from-diff · --quiet)` — scaffold a change and print the playbook to execute. *When:* a request arrives that changes behaviour (triage says PRODUCT).
|
|
55
|
+
- `doctrina spec new|list|set` — create, list, and edit capability specs. *When:* a capability needs creating or its headers advancing.
|
|
56
|
+
- `doctrina change new|apply|archive|check|tick|diff|abandon` — new / apply / archive / check / tick / diff / abandon. *When:* driving a change through its lifecycle by hand.
|
|
57
|
+
- `doctrina contract new|list|check` — own and verify the integration surface. *When:* the change touches ports, env vars, or public endpoints.
|
|
58
|
+
- `doctrina decision new|accept|land|supersede|list|scope` — record, accept, land, scope, and supersede ADRs. *When:* the change decides something a later session must not relitigate.
|
|
59
|
+
- `doctrina skill new|list|sync|suggest` — capture on-demand procedural memory. *When:* a lesson is worth not relearning.
|
|
60
|
+
- `doctrina intent add|list` — append and list product intent anchors. *When:* new product intent appears after the intake.
|
|
61
|
+
**Gate**
|
|
62
|
+
- `doctrina analyze` — structural pre-flight on a change folder. *When:* before applying a change.
|
|
63
|
+
- `doctrina clarify --all (--lang pt|en)` — smell-test Markdown for ambiguity. *When:* before applying, or before opening a PR.
|
|
64
|
+
- `doctrina validate (--fix)` — schema, structure, EARS, and index drift. *When:* after any artifact edit, and before considering work done.
|
|
65
|
+
- `doctrina coverage --strict` — acceptance criteria against cited evidence. *When:* before claiming a capability is proven.
|
|
66
|
+
- `doctrina trace --strict` — intent provenance across the tree. *When:* checking that product intent still maps to capabilities.
|
|
67
|
+
- `doctrina review` — conformance of your changes vs specs/ADRs/contracts. *When:* before handing work back, to self-review it.
|
|
68
|
+
- `doctrina verify` — the project's declared typecheck/test/build checks. *When:* the real build gate must run.
|
|
69
|
+
- `doctrina close <id...>` — the whole closing sequence in one attested pass. *When:* a change is implemented and ready to finish.
|
|
70
|
+
- `doctrina doctor` — aggregate diagnostic with per-finding remedies. *When:* something looks wrong and you do not know which gate to ask.
|
|
71
|
+
**Maintain** (triggers: `doctrina <command> --help`) — `doctrina templates list|check|update` · `doctrina hooks install` · `doctrina index rebuild` · `doctrina watch` · `doctrina metrics` · `doctrina report` · `doctrina completion` · `doctrina upgrade --write`
|
|
49
72
|
<!-- doctrina:surface:end -->
|
|
50
73
|
|
|
51
74
|
## Stack and tooling
|
|
@@ -78,55 +101,38 @@ Session bookends: `doctrina prime` to orient at session start;
|
|
|
78
101
|
- Archived changes in `.doctrina/changes/archive/` are out of the default
|
|
79
102
|
read path; consult only when explicitly debugging history.
|
|
80
103
|
|
|
81
|
-
## Artifact invariants (
|
|
82
|
-
|
|
83
|
-
Scaffold
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
- **
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
- **Change folder** at `.doctrina/changes/<id>/` must contain a file named
|
|
97
|
-
exactly `proposal.md` (not `change.md`, not `README.md`). `tasks.md` and
|
|
98
|
-
`design.md` are optional siblings.
|
|
99
|
-
- **Spec** lives at `.doctrina/specs/<capability>/spec.md` and its
|
|
100
|
-
`**Version:**` header must equal the `version` recorded for it in
|
|
101
|
-
`.doctrina/index.json`.
|
|
102
|
-
- **Every artifact on disk** must be registered in `.doctrina/index.json`,
|
|
103
|
-
and every path in `index.json` must exist on disk. Update the index in
|
|
104
|
-
the same change that adds or moves a file.
|
|
105
|
-
|
|
106
|
-
Always finish by running `doctrina validate` and resolving every `error:`
|
|
107
|
-
before considering work done.
|
|
104
|
+
## Artifact invariants (`doctrina validate` enforces these)
|
|
105
|
+
|
|
106
|
+
Scaffold with the CLI; do not hand-author from memory. If you must:
|
|
107
|
+
|
|
108
|
+
- **Headers:** ADRs / proposals / intake use list items
|
|
109
|
+
(`- **Status:** accepted`); specs use bare bold (`**Status:** active`).
|
|
110
|
+
- **ADR filename:** `NNNN-slug.md`, four digits. Other shapes are invisible
|
|
111
|
+
to `decision accept`, the index, and the orphan check.
|
|
112
|
+
- **Change folder:** `.doctrina/changes/<id>/proposal.md`, that exact name.
|
|
113
|
+
- **Spec:** `.doctrina/specs/<cap>/spec.md`; its `**Version:**` must equal
|
|
114
|
+
the version recorded in `index.json`.
|
|
115
|
+
- **Every artifact** is registered in `index.json`, and every indexed path
|
|
116
|
+
exists. Update the index in the same change.
|
|
117
|
+
|
|
118
|
+
Finish by running `doctrina validate` and resolving every `error:`.
|
|
108
119
|
|
|
109
120
|
## How to read context efficiently
|
|
110
121
|
|
|
111
122
|
`doctrina context [<capability>] --concat` assembles the read pack in one
|
|
112
|
-
call
|
|
113
|
-
|
|
114
|
-
`Status: accepted`. Run it for ANY task, not only `doctrina work`; it skips
|
|
115
|
-
`changes/archive/` (history — read only when debugging it).
|
|
123
|
+
call: this `AGENTS.md` → `product.md` → the capability spec → open changes →
|
|
124
|
+
accepted ADRs. Run it for ANY task. It skips `changes/archive/` (history).
|
|
116
125
|
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
Skills are on demand: read the `description:` / `when:` frontmatter in
|
|
127
|
+
`.doctrina/skills/`; load a body only when its trigger fires.
|
|
119
128
|
|
|
120
|
-
Keep this file under 150 lines. Density beats prose.
|
|
129
|
+
Keep this file under 150 lines. Density beats prose.
|
|
121
130
|
|
|
122
131
|
## Definition of done
|
|
123
132
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
- The change folder has been moved to `.doctrina/changes/archive/`.
|
|
128
|
-
- Any architectural decisions are recorded as ADRs with `Status: accepted`.
|
|
129
|
-
- `.doctrina/index.json` has been updated.
|
|
133
|
+
`doctrina close <id>` is the definition: tasks checked, deltas merged into
|
|
134
|
+
specs, gates green, ADRs recorded and accepted, change archived, index
|
|
135
|
+
updated. If close refuses, the change is not done.
|
|
130
136
|
|
|
131
137
|
## What never goes in this file
|
|
132
138
|
|