doctrina-cli 0.12.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 +76 -19
- package/src/commands/change.js +341 -44
- package/src/commands/clarify.js +40 -19
- package/src/commands/close.js +119 -11
- 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 +152 -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 +277 -40
- package/src/commands/trace.js +15 -3
- package/src/commands/upgrade.js +23 -12
- package/src/commands/validate.js +93 -3
- 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 +69 -7
- package/src/index.js +60 -6
- package/src/lib/adapters.js +140 -0
- package/src/lib/adr-guard.js +47 -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 +314 -3
- 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 +228 -3
- 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/.gitkeep +0 -0
- package/templates/AGENTS.md.template +71 -75
- package/templates/change/spec-delta.md.template +13 -7
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
|
|
@@ -17,12 +18,24 @@
|
|
|
17
18
|
// set-criterion 1: verified
|
|
18
19
|
// replace-criterion 2: [verified] new text — verified by `test/x.test.ts`
|
|
19
20
|
// append-criterion [unverified] new signal — verified by `test/y.test.ts`
|
|
21
|
+
// append-requirement event: When X occurs, the system shall do Y.
|
|
22
|
+
// replace-requirement ubiquitous 2: The system shall do Z.
|
|
20
23
|
// ```
|
|
21
24
|
//
|
|
25
|
+
// The requirement verbs (operator review 2026-07-19 §4.1) cover the dominant
|
|
26
|
+
// MODIFIED case — inserting or rewording EARS bullets in a `## Requirements
|
|
27
|
+
// (EARS)` subsection — so a typical delta no longer falls back to a manual
|
|
28
|
+
// merge. Because append-* verbs resolve their position/number at APPLY time,
|
|
29
|
+
// concurrent open changes appending to the same spec cannot collide on
|
|
30
|
+
// numbering: order of application decides, mechanically.
|
|
31
|
+
//
|
|
22
32
|
// A `# comment` or blank line inside the block is ignored. When a delta has
|
|
23
33
|
// no ops block, apply falls back to the manual-merge pointer (backward
|
|
24
34
|
// compatible).
|
|
25
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.
|
|
26
39
|
const HEADER_RE = (name) =>
|
|
27
40
|
new RegExp(`^(\\s*(?:-\\s+)?\\*\\*${escapeRe(name)}:\\*\\*)\\s*(.*)$`, "m");
|
|
28
41
|
|
|
@@ -49,9 +62,27 @@ export function extractOps(deltaText) {
|
|
|
49
62
|
// Find the body of the first ```ops fenced block, or null if absent. The
|
|
50
63
|
// info string must be exactly "ops" (case-insensitive) so a normal code
|
|
51
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).
|
|
52
73
|
function matchOpsBlock(text) {
|
|
53
|
-
const
|
|
54
|
-
|
|
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;
|
|
55
86
|
}
|
|
56
87
|
|
|
57
88
|
function parseOpLine(line) {
|
|
@@ -92,11 +123,50 @@ function parseOpLine(line) {
|
|
|
92
123
|
if (!rest) return { verb, error: "append-criterion is missing its text" };
|
|
93
124
|
return { verb, value: rest };
|
|
94
125
|
}
|
|
126
|
+
case "append-requirement": {
|
|
127
|
+
// append-requirement <section>: <text>
|
|
128
|
+
const colon = rest.indexOf(":");
|
|
129
|
+
if (colon < 0) return { verb, error: `append-requirement needs "<section>: <text>" (got "${rest}")` };
|
|
130
|
+
const section = normalizeSection(rest.slice(0, colon).trim());
|
|
131
|
+
const value = rest.slice(colon + 1).trim();
|
|
132
|
+
if (!section) return { verb, error: `append-requirement: unknown section "${rest.slice(0, colon).trim()}" (use ${SECTION_TOKENS})` };
|
|
133
|
+
if (!value) return { verb, error: "append-requirement is missing the requirement text" };
|
|
134
|
+
return { verb, section, value };
|
|
135
|
+
}
|
|
136
|
+
case "replace-requirement": {
|
|
137
|
+
// replace-requirement <section> <n>: <text>
|
|
138
|
+
const colon = rest.indexOf(":");
|
|
139
|
+
if (colon < 0) return { verb, error: `replace-requirement needs "<section> <n>: <text>" (got "${rest}")` };
|
|
140
|
+
const head = rest.slice(0, colon).trim().split(/\s+/);
|
|
141
|
+
const value = rest.slice(colon + 1).trim();
|
|
142
|
+
const n = Number(head[head.length - 1]);
|
|
143
|
+
const section = normalizeSection(head.slice(0, -1).join(" "));
|
|
144
|
+
if (!section) return { verb, error: `replace-requirement: unknown section "${head.slice(0, -1).join(" ")}" (use ${SECTION_TOKENS})` };
|
|
145
|
+
if (!Number.isInteger(n) || n < 1) return { verb, error: `replace-requirement needs a positive bullet number (got "${rest}")` };
|
|
146
|
+
if (!value) return { verb, error: `replace-requirement ${n} is missing the requirement text` };
|
|
147
|
+
return { verb, section, n, value };
|
|
148
|
+
}
|
|
95
149
|
default:
|
|
96
150
|
return { verb: null, error: `unknown operation "${verb}"` };
|
|
97
151
|
}
|
|
98
152
|
}
|
|
99
153
|
|
|
154
|
+
// EARS subsection aliases -> the canonical token used to match the spec's
|
|
155
|
+
// `### ` heading under `## Requirements (EARS)`. Deterministic: first word of
|
|
156
|
+
// the heading, folded ("Unwanted-behavior (must-not)" -> "unwanted").
|
|
157
|
+
const SECTIONS = {
|
|
158
|
+
ubiquitous: "ubiquitous",
|
|
159
|
+
event: "event", "event-driven": "event",
|
|
160
|
+
state: "state", "state-driven": "state",
|
|
161
|
+
unwanted: "unwanted", "unwanted-behavior": "unwanted", "must-not": "unwanted",
|
|
162
|
+
optional: "optional",
|
|
163
|
+
};
|
|
164
|
+
const SECTION_TOKENS = "ubiquitous|event|state|unwanted|optional";
|
|
165
|
+
|
|
166
|
+
function normalizeSection(raw) {
|
|
167
|
+
return SECTIONS[raw.toLowerCase()] ?? null;
|
|
168
|
+
}
|
|
169
|
+
|
|
100
170
|
// Apply a parsed op list to a spec's Markdown text. Pure: returns
|
|
101
171
|
// { text, applied: string[], errors: string[] } and never throws. A failed
|
|
102
172
|
// op is recorded in `errors` and leaves the text unchanged for that op, so
|
|
@@ -132,6 +202,10 @@ function applyOne(text, op) {
|
|
|
132
202
|
return replaceCriterion(text, op.n, op.value);
|
|
133
203
|
case "append-criterion":
|
|
134
204
|
return appendCriterion(text, op.value);
|
|
205
|
+
case "append-requirement":
|
|
206
|
+
return appendRequirement(text, op.section, op.value);
|
|
207
|
+
case "replace-requirement":
|
|
208
|
+
return replaceRequirement(text, op.section, op.n, op.value);
|
|
135
209
|
default:
|
|
136
210
|
return { error: `unknown operation "${op.verb}"` };
|
|
137
211
|
}
|
|
@@ -221,6 +295,157 @@ export function replaceCriterion(text, n, value) {
|
|
|
221
295
|
return { text: loc.lines.join("\n"), summary: `replace-criterion ${n}` };
|
|
222
296
|
}
|
|
223
297
|
|
|
298
|
+
// The `### <Section>` blocks under `## Requirements (EARS)`. Returns
|
|
299
|
+
// { lines, sections: { <token>: { headingIndex, endIndex, bullets: [lineIndex] } } }
|
|
300
|
+
// or null when the Requirements section is absent. A section's token is the
|
|
301
|
+
// first word of its heading, folded through SECTIONS ("Unwanted-behavior
|
|
302
|
+
// (must-not)" -> "unwanted"). endIndex is exclusive: the line index of the
|
|
303
|
+
// next heading (### or ##) after the section, or lines.length.
|
|
304
|
+
function locateRequirements(text) {
|
|
305
|
+
const lines = text.split(/\r?\n/);
|
|
306
|
+
let inRequirements = false;
|
|
307
|
+
let current = null;
|
|
308
|
+
const sections = {};
|
|
309
|
+
for (let i = 0; i < lines.length; i++) {
|
|
310
|
+
const line = lines[i];
|
|
311
|
+
if (/^##\s+/.test(line) && !/^###/.test(line)) {
|
|
312
|
+
if (current) { current.endIndex = i; current = null; }
|
|
313
|
+
inRequirements = /^##\s+Requirements\b/i.test(line);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (!inRequirements) continue;
|
|
317
|
+
const h = line.match(/^###\s+(\S+)/);
|
|
318
|
+
if (h) {
|
|
319
|
+
if (current) current.endIndex = i;
|
|
320
|
+
const token = SECTIONS[h[1].toLowerCase().replace(/[^a-z-]/g, "")] ?? null;
|
|
321
|
+
current = { headingIndex: i, endIndex: lines.length, bullets: [] };
|
|
322
|
+
if (token) sections[token] = current;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (current && /^\s*-(\s|$)/.test(line)) current.bullets.push(i);
|
|
326
|
+
}
|
|
327
|
+
return Object.keys(sections).length > 0 || /^##\s+Requirements\b/im.test(text)
|
|
328
|
+
? { lines, sections }
|
|
329
|
+
: null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// A list item may wrap over indented continuation lines ("- When X,\n
|
|
333
|
+
// the system shall ..."). Inserting right after the item's FIRST line
|
|
334
|
+
// splits it from its continuation — the bug the 0.13.0 dogfood close
|
|
335
|
+
// caught on this very repo's cli spec. From the item's first line, the
|
|
336
|
+
// item ends after every following line that is indented content.
|
|
337
|
+
function endOfItem(lines, startIndex) {
|
|
338
|
+
let i = startIndex;
|
|
339
|
+
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) i += 1;
|
|
340
|
+
return i;
|
|
341
|
+
}
|
|
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
|
+
|
|
398
|
+
// append-requirement <section>: <text> — add "- <text>" at the end of the
|
|
399
|
+
// EARS subsection. A lone "-" placeholder bullet (the untouched scaffold) is
|
|
400
|
+
// replaced instead of appended after, so the first real requirement does not
|
|
401
|
+
// land under an empty stub.
|
|
402
|
+
export function appendRequirement(text, section, value) {
|
|
403
|
+
let loc = locateRequirements(text);
|
|
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
|
+
}
|
|
418
|
+
const sec = loc.sections[section];
|
|
419
|
+
const placeholder = sec.bullets.length === 1 && /^\s*-\s*$/.test(loc.lines[sec.bullets[0]]);
|
|
420
|
+
if (placeholder) {
|
|
421
|
+
loc.lines[sec.bullets[0]] = `- ${value}`;
|
|
422
|
+
} else if (sec.bullets.length > 0) {
|
|
423
|
+
loc.lines.splice(endOfItem(loc.lines, sec.bullets[sec.bullets.length - 1]) + 1, 0, `- ${value}`);
|
|
424
|
+
} else {
|
|
425
|
+
// Empty section: insert after the heading (skipping one blank line if present).
|
|
426
|
+
let at = sec.headingIndex + 1;
|
|
427
|
+
while (at < sec.endIndex && loc.lines[at].trim() === "") at += 1;
|
|
428
|
+
loc.lines.splice(at, 0, `- ${value}`);
|
|
429
|
+
}
|
|
430
|
+
return { text: loc.lines.join("\n"), summary: `append-requirement ${section}: ${value.slice(0, 60)}` };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// replace-requirement <section> <n>: <text> — replace the nth bullet (1-based)
|
|
434
|
+
// of the EARS subsection.
|
|
435
|
+
export function replaceRequirement(text, section, n, value) {
|
|
436
|
+
const loc = locateRequirements(text);
|
|
437
|
+
if (!loc) return { error: "replace-requirement: spec has no '## Requirements (EARS)' section" };
|
|
438
|
+
const sec = loc.sections[section];
|
|
439
|
+
if (!sec) return { error: `replace-requirement: no '### ${section}...' subsection found under Requirements` };
|
|
440
|
+
const real = sec.bullets.filter((i) => !/^\s*-\s*$/.test(loc.lines[i]));
|
|
441
|
+
if (n > real.length) {
|
|
442
|
+
return { error: `replace-requirement: section "${section}" has ${real.length} requirement${real.length === 1 ? "" : "s"}, no #${n}` };
|
|
443
|
+
}
|
|
444
|
+
const indent = loc.lines[real[n - 1]].match(/^(\s*)/)[1];
|
|
445
|
+
loc.lines[real[n - 1]] = `${indent}- ${value}`;
|
|
446
|
+
return { text: loc.lines.join("\n"), summary: `replace-requirement ${section} ${n}` };
|
|
447
|
+
}
|
|
448
|
+
|
|
224
449
|
// append-criterion <text> — add a new numbered criterion after the last one
|
|
225
450
|
// in the section.
|
|
226
451
|
export function appendCriterion(text, value) {
|
|
@@ -231,6 +456,6 @@ export function appendCriterion(text, value) {
|
|
|
231
456
|
}
|
|
232
457
|
const last = loc.items[loc.items.length - 1];
|
|
233
458
|
const nextN = Math.max(...loc.items.map((it) => it.n)) + 1;
|
|
234
|
-
loc.lines.splice(last.lineIndex + 1, 0, `${last.indent}${nextN}. ${value}`);
|
|
459
|
+
loc.lines.splice(endOfItem(loc.lines, last.lineIndex) + 1, 0, `${last.indent}${nextN}. ${value}`);
|
|
235
460
|
return { text: loc.lines.join("\n"), summary: `append-criterion ${nextN}` };
|
|
236
461
|
}
|
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
|
File without changes
|