doctrina-cli 0.12.0 → 0.13.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/package.json +1 -1
- package/src/commands/analyze.js +10 -0
- package/src/commands/change.js +245 -6
- package/src/commands/clarify.js +32 -18
- package/src/commands/close.js +63 -10
- package/src/commands/init.js +15 -2
- package/src/commands/templates.js +113 -10
- package/src/commands/upgrade.js +15 -11
- package/src/commands/validate.js +35 -1
- package/src/commands/work.js +61 -6
- package/src/lib/adr-guard.js +46 -0
- package/src/lib/commands.js +100 -2
- package/src/lib/spec-ops.js +143 -3
- package/templates/.gitkeep +0 -0
- package/templates/AGENTS.md.template +25 -35
- package/templates/change/spec-delta.md.template +13 -7
package/src/lib/spec-ops.js
CHANGED
|
@@ -17,8 +17,17 @@
|
|
|
17
17
|
// set-criterion 1: verified
|
|
18
18
|
// replace-criterion 2: [verified] new text — verified by `test/x.test.ts`
|
|
19
19
|
// append-criterion [unverified] new signal — verified by `test/y.test.ts`
|
|
20
|
+
// append-requirement event: When X occurs, the system shall do Y.
|
|
21
|
+
// replace-requirement ubiquitous 2: The system shall do Z.
|
|
20
22
|
// ```
|
|
21
23
|
//
|
|
24
|
+
// The requirement verbs (operator review 2026-07-19 §4.1) cover the dominant
|
|
25
|
+
// MODIFIED case — inserting or rewording EARS bullets in a `## Requirements
|
|
26
|
+
// (EARS)` subsection — so a typical delta no longer falls back to a manual
|
|
27
|
+
// merge. Because append-* verbs resolve their position/number at APPLY time,
|
|
28
|
+
// concurrent open changes appending to the same spec cannot collide on
|
|
29
|
+
// numbering: order of application decides, mechanically.
|
|
30
|
+
//
|
|
22
31
|
// A `# comment` or blank line inside the block is ignored. When a delta has
|
|
23
32
|
// no ops block, apply falls back to the manual-merge pointer (backward
|
|
24
33
|
// compatible).
|
|
@@ -48,9 +57,13 @@ export function extractOps(deltaText) {
|
|
|
48
57
|
|
|
49
58
|
// Find the body of the first ```ops fenced block, or null if absent. The
|
|
50
59
|
// info string must be exactly "ops" (case-insensitive) so a normal code
|
|
51
|
-
// fence in the prose is never mistaken for operations.
|
|
60
|
+
// fence in the prose is never mistaken for operations. HTML comments are
|
|
61
|
+
// stripped first: the scaffolded delta template carries an EXAMPLE ops block
|
|
62
|
+
// inside its instructional comment, and executing an example against a real
|
|
63
|
+
// spec is exactly the surprise this guard removes.
|
|
52
64
|
function matchOpsBlock(text) {
|
|
53
|
-
const
|
|
65
|
+
const visible = text.replace(/<!--[\s\S]*?-->/g, "");
|
|
66
|
+
const m = visible.match(/^[ \t]*```[ \t]*ops[ \t]*\r?\n([\s\S]*?)^[ \t]*```[ \t]*$/m);
|
|
54
67
|
return m ? m[1] : null;
|
|
55
68
|
}
|
|
56
69
|
|
|
@@ -92,11 +105,50 @@ function parseOpLine(line) {
|
|
|
92
105
|
if (!rest) return { verb, error: "append-criterion is missing its text" };
|
|
93
106
|
return { verb, value: rest };
|
|
94
107
|
}
|
|
108
|
+
case "append-requirement": {
|
|
109
|
+
// append-requirement <section>: <text>
|
|
110
|
+
const colon = rest.indexOf(":");
|
|
111
|
+
if (colon < 0) return { verb, error: `append-requirement needs "<section>: <text>" (got "${rest}")` };
|
|
112
|
+
const section = normalizeSection(rest.slice(0, colon).trim());
|
|
113
|
+
const value = rest.slice(colon + 1).trim();
|
|
114
|
+
if (!section) return { verb, error: `append-requirement: unknown section "${rest.slice(0, colon).trim()}" (use ${SECTION_TOKENS})` };
|
|
115
|
+
if (!value) return { verb, error: "append-requirement is missing the requirement text" };
|
|
116
|
+
return { verb, section, value };
|
|
117
|
+
}
|
|
118
|
+
case "replace-requirement": {
|
|
119
|
+
// replace-requirement <section> <n>: <text>
|
|
120
|
+
const colon = rest.indexOf(":");
|
|
121
|
+
if (colon < 0) return { verb, error: `replace-requirement needs "<section> <n>: <text>" (got "${rest}")` };
|
|
122
|
+
const head = rest.slice(0, colon).trim().split(/\s+/);
|
|
123
|
+
const value = rest.slice(colon + 1).trim();
|
|
124
|
+
const n = Number(head[head.length - 1]);
|
|
125
|
+
const section = normalizeSection(head.slice(0, -1).join(" "));
|
|
126
|
+
if (!section) return { verb, error: `replace-requirement: unknown section "${head.slice(0, -1).join(" ")}" (use ${SECTION_TOKENS})` };
|
|
127
|
+
if (!Number.isInteger(n) || n < 1) return { verb, error: `replace-requirement needs a positive bullet number (got "${rest}")` };
|
|
128
|
+
if (!value) return { verb, error: `replace-requirement ${n} is missing the requirement text` };
|
|
129
|
+
return { verb, section, n, value };
|
|
130
|
+
}
|
|
95
131
|
default:
|
|
96
132
|
return { verb: null, error: `unknown operation "${verb}"` };
|
|
97
133
|
}
|
|
98
134
|
}
|
|
99
135
|
|
|
136
|
+
// EARS subsection aliases -> the canonical token used to match the spec's
|
|
137
|
+
// `### ` heading under `## Requirements (EARS)`. Deterministic: first word of
|
|
138
|
+
// the heading, folded ("Unwanted-behavior (must-not)" -> "unwanted").
|
|
139
|
+
const SECTIONS = {
|
|
140
|
+
ubiquitous: "ubiquitous",
|
|
141
|
+
event: "event", "event-driven": "event",
|
|
142
|
+
state: "state", "state-driven": "state",
|
|
143
|
+
unwanted: "unwanted", "unwanted-behavior": "unwanted", "must-not": "unwanted",
|
|
144
|
+
optional: "optional",
|
|
145
|
+
};
|
|
146
|
+
const SECTION_TOKENS = "ubiquitous|event|state|unwanted|optional";
|
|
147
|
+
|
|
148
|
+
function normalizeSection(raw) {
|
|
149
|
+
return SECTIONS[raw.toLowerCase()] ?? null;
|
|
150
|
+
}
|
|
151
|
+
|
|
100
152
|
// Apply a parsed op list to a spec's Markdown text. Pure: returns
|
|
101
153
|
// { text, applied: string[], errors: string[] } and never throws. A failed
|
|
102
154
|
// op is recorded in `errors` and leaves the text unchanged for that op, so
|
|
@@ -132,6 +184,10 @@ function applyOne(text, op) {
|
|
|
132
184
|
return replaceCriterion(text, op.n, op.value);
|
|
133
185
|
case "append-criterion":
|
|
134
186
|
return appendCriterion(text, op.value);
|
|
187
|
+
case "append-requirement":
|
|
188
|
+
return appendRequirement(text, op.section, op.value);
|
|
189
|
+
case "replace-requirement":
|
|
190
|
+
return replaceRequirement(text, op.section, op.n, op.value);
|
|
135
191
|
default:
|
|
136
192
|
return { error: `unknown operation "${op.verb}"` };
|
|
137
193
|
}
|
|
@@ -221,6 +277,90 @@ export function replaceCriterion(text, n, value) {
|
|
|
221
277
|
return { text: loc.lines.join("\n"), summary: `replace-criterion ${n}` };
|
|
222
278
|
}
|
|
223
279
|
|
|
280
|
+
// The `### <Section>` blocks under `## Requirements (EARS)`. Returns
|
|
281
|
+
// { lines, sections: { <token>: { headingIndex, endIndex, bullets: [lineIndex] } } }
|
|
282
|
+
// or null when the Requirements section is absent. A section's token is the
|
|
283
|
+
// first word of its heading, folded through SECTIONS ("Unwanted-behavior
|
|
284
|
+
// (must-not)" -> "unwanted"). endIndex is exclusive: the line index of the
|
|
285
|
+
// next heading (### or ##) after the section, or lines.length.
|
|
286
|
+
function locateRequirements(text) {
|
|
287
|
+
const lines = text.split(/\r?\n/);
|
|
288
|
+
let inRequirements = false;
|
|
289
|
+
let current = null;
|
|
290
|
+
const sections = {};
|
|
291
|
+
for (let i = 0; i < lines.length; i++) {
|
|
292
|
+
const line = lines[i];
|
|
293
|
+
if (/^##\s+/.test(line) && !/^###/.test(line)) {
|
|
294
|
+
if (current) { current.endIndex = i; current = null; }
|
|
295
|
+
inRequirements = /^##\s+Requirements\b/i.test(line);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (!inRequirements) continue;
|
|
299
|
+
const h = line.match(/^###\s+(\S+)/);
|
|
300
|
+
if (h) {
|
|
301
|
+
if (current) current.endIndex = i;
|
|
302
|
+
const token = SECTIONS[h[1].toLowerCase().replace(/[^a-z-]/g, "")] ?? null;
|
|
303
|
+
current = { headingIndex: i, endIndex: lines.length, bullets: [] };
|
|
304
|
+
if (token) sections[token] = current;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (current && /^\s*-(\s|$)/.test(line)) current.bullets.push(i);
|
|
308
|
+
}
|
|
309
|
+
return Object.keys(sections).length > 0 || /^##\s+Requirements\b/im.test(text)
|
|
310
|
+
? { lines, sections }
|
|
311
|
+
: null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// A list item may wrap over indented continuation lines ("- When X,\n
|
|
315
|
+
// the system shall ..."). Inserting right after the item's FIRST line
|
|
316
|
+
// splits it from its continuation — the bug the 0.13.0 dogfood close
|
|
317
|
+
// caught on this very repo's cli spec. From the item's first line, the
|
|
318
|
+
// item ends after every following line that is indented content.
|
|
319
|
+
function endOfItem(lines, startIndex) {
|
|
320
|
+
let i = startIndex;
|
|
321
|
+
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) i += 1;
|
|
322
|
+
return i;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// append-requirement <section>: <text> — add "- <text>" at the end of the
|
|
326
|
+
// EARS subsection. A lone "-" placeholder bullet (the untouched scaffold) is
|
|
327
|
+
// replaced instead of appended after, so the first real requirement does not
|
|
328
|
+
// land under an empty stub.
|
|
329
|
+
export function appendRequirement(text, section, value) {
|
|
330
|
+
const loc = locateRequirements(text);
|
|
331
|
+
if (!loc) return { error: "append-requirement: spec has no '## Requirements (EARS)' section" };
|
|
332
|
+
const sec = loc.sections[section];
|
|
333
|
+
if (!sec) return { error: `append-requirement: no '### ${section}...' subsection found under Requirements` };
|
|
334
|
+
const placeholder = sec.bullets.length === 1 && /^\s*-\s*$/.test(loc.lines[sec.bullets[0]]);
|
|
335
|
+
if (placeholder) {
|
|
336
|
+
loc.lines[sec.bullets[0]] = `- ${value}`;
|
|
337
|
+
} else if (sec.bullets.length > 0) {
|
|
338
|
+
loc.lines.splice(endOfItem(loc.lines, sec.bullets[sec.bullets.length - 1]) + 1, 0, `- ${value}`);
|
|
339
|
+
} else {
|
|
340
|
+
// Empty section: insert after the heading (skipping one blank line if present).
|
|
341
|
+
let at = sec.headingIndex + 1;
|
|
342
|
+
while (at < sec.endIndex && loc.lines[at].trim() === "") at += 1;
|
|
343
|
+
loc.lines.splice(at, 0, `- ${value}`);
|
|
344
|
+
}
|
|
345
|
+
return { text: loc.lines.join("\n"), summary: `append-requirement ${section}: ${value.slice(0, 60)}` };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// replace-requirement <section> <n>: <text> — replace the nth bullet (1-based)
|
|
349
|
+
// of the EARS subsection.
|
|
350
|
+
export function replaceRequirement(text, section, n, value) {
|
|
351
|
+
const loc = locateRequirements(text);
|
|
352
|
+
if (!loc) return { error: "replace-requirement: spec has no '## Requirements (EARS)' section" };
|
|
353
|
+
const sec = loc.sections[section];
|
|
354
|
+
if (!sec) return { error: `replace-requirement: no '### ${section}...' subsection found under Requirements` };
|
|
355
|
+
const real = sec.bullets.filter((i) => !/^\s*-\s*$/.test(loc.lines[i]));
|
|
356
|
+
if (n > real.length) {
|
|
357
|
+
return { error: `replace-requirement: section "${section}" has ${real.length} requirement${real.length === 1 ? "" : "s"}, no #${n}` };
|
|
358
|
+
}
|
|
359
|
+
const indent = loc.lines[real[n - 1]].match(/^(\s*)/)[1];
|
|
360
|
+
loc.lines[real[n - 1]] = `${indent}- ${value}`;
|
|
361
|
+
return { text: loc.lines.join("\n"), summary: `replace-requirement ${section} ${n}` };
|
|
362
|
+
}
|
|
363
|
+
|
|
224
364
|
// append-criterion <text> — add a new numbered criterion after the last one
|
|
225
365
|
// in the section.
|
|
226
366
|
export function appendCriterion(text, value) {
|
|
@@ -231,6 +371,6 @@ export function appendCriterion(text, value) {
|
|
|
231
371
|
}
|
|
232
372
|
const last = loc.items[loc.items.length - 1];
|
|
233
373
|
const nextN = Math.max(...loc.items.map((it) => it.n)) + 1;
|
|
234
|
-
loc.lines.splice(last.lineIndex + 1, 0, `${last.indent}${nextN}. ${value}`);
|
|
374
|
+
loc.lines.splice(endOfItem(loc.lines, last.lineIndex) + 1, 0, `${last.indent}${nextN}. ${value}`);
|
|
235
375
|
return { text: loc.lines.join("\n"), summary: `append-criterion ${nextN}` };
|
|
236
376
|
}
|
|
File without changes
|
|
@@ -22,41 +22,31 @@ prompt and approves; do not make them run the CLI or hand-author artifacts.
|
|
|
22
22
|
spec each, `doctrina clarify --all` + `doctrina validate`, then flip the
|
|
23
23
|
intake to `Status: converted`). After conversion the specs are the only
|
|
24
24
|
source of truth — never edit `intake.md` to change requirements.
|
|
25
|
-
- **Day-to-day.**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
- **
|
|
42
|
-
|
|
43
|
-
- **
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
change)
|
|
51
|
-
- **Gates (ground truth, not guesses):** `doctrina validate` (`--fix`) ·
|
|
52
|
-
`doctrina verify` · `doctrina review` (conformance vs specs/ADRs) ·
|
|
53
|
-
`doctrina coverage --strict` (`--run` executes cited proof) ·
|
|
54
|
-
`doctrina trace --strict` · `doctrina contract check` ·
|
|
55
|
-
`doctrina clarify --all` · `doctrina analyze <id>`
|
|
56
|
-
|
|
57
|
-
Continuous: `doctrina watch` re-syncs and re-orients on every save.
|
|
58
|
-
Capture lessons: `doctrina skill suggest` surfaces skills worth writing.
|
|
59
|
-
After a CLI update: `doctrina upgrade --write` brings this project up.
|
|
25
|
+
- **Day-to-day.** Start every session with `doctrina prime` (gates, rules,
|
|
26
|
+
open work in one read). Turn any request into a change with `doctrina
|
|
27
|
+
work "<prompt>"` and follow the printed playbook (spec delta → tasks →
|
|
28
|
+
implement → close). Preview a close with `doctrina change check <id>`;
|
|
29
|
+
close with `doctrina close <id>`. Unsure what's next? `doctrina next`.
|
|
30
|
+
Before compaction or handover: `doctrina handoff`. Ask the human only on
|
|
31
|
+
genuine ambiguity.
|
|
32
|
+
|
|
33
|
+
<!-- doctrina:surface:begin — CLI-owned block, generated from the installed command catalog. Refreshed by `doctrina upgrade --write`; edits inside are overwritten. -->
|
|
34
|
+
## Doctrina command surface (generated — reach for these, don't hand-author)
|
|
35
|
+
|
|
36
|
+
Every operation of the installed CLI, grouped by workflow. The CLI
|
|
37
|
+
scaffolds from canonical templates and keeps `index.json` in sync, so
|
|
38
|
+
prefer it over writing artifacts by hand. Details: `doctrina --help`.
|
|
39
|
+
|
|
40
|
+
- **Start:** `doctrina init` · `doctrina intake` · `doctrina work "<prompt>" (--capability · --chore · --from-diff · --quiet)`
|
|
41
|
+
- **Author:** `doctrina spec new|list|set` · `doctrina change new|apply|archive|check|tick|diff|abandon` · `doctrina contract new|list|check` · `doctrina decision new|accept|land|supersede|list` · `doctrina skill new|list|sync|suggest` · `doctrina intent add|list`
|
|
42
|
+
- **Read / orient:** `doctrina prime (session start)` · `doctrina context [<cap>] --concat` · `doctrina show` · `doctrina search` · `doctrina status` · `doctrina next` · `doctrina why` · `doctrina handoff (before compaction/handover)` · `doctrina constitution`
|
|
43
|
+
- **Gates:** `doctrina analyze` · `doctrina clarify --all (--lang pt|en)` · `doctrina validate (--fix)` · `doctrina coverage --strict` · `doctrina trace --strict` · `doctrina review` · `doctrina verify` · `doctrina close <id...>` · `doctrina doctor`
|
|
44
|
+
- **Maintain:** `doctrina templates list|check|update` · `doctrina hooks install` · `doctrina index rebuild` · `doctrina watch` · `doctrina metrics` · `doctrina report` · `doctrina completion` · `doctrina upgrade --write`
|
|
45
|
+
|
|
46
|
+
Session bookends: `doctrina prime` to orient at session start;
|
|
47
|
+
`doctrina handoff` before compaction or handover. Continuous:
|
|
48
|
+
`doctrina watch`. Capture lessons: `doctrina skill suggest`.
|
|
49
|
+
<!-- doctrina:surface:end -->
|
|
60
50
|
|
|
61
51
|
## Stack and tooling
|
|
62
52
|
|
|
@@ -7,21 +7,27 @@
|
|
|
7
7
|
For ADDED: include the full new spec body below. On apply, the body is
|
|
8
8
|
written verbatim to the target path.
|
|
9
9
|
|
|
10
|
-
For MODIFIED:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
manual-merge pointer.
|
|
10
|
+
For MODIFIED: prefer a fenced `ops` block — on apply the CLI executes it
|
|
11
|
+
against the target spec, all ops or none (ADR 0007). The verbs cover
|
|
12
|
+
headers, acceptance criteria, AND the EARS requirement bullets, so a
|
|
13
|
+
typical delta applies mechanically end to end; only free-prose rewrites
|
|
14
|
+
(Purpose, Maturity, ...) stay a by-hand merge. A MODIFIED delta with no
|
|
15
|
+
`ops` block prints a manual-merge pointer.
|
|
17
16
|
|
|
18
17
|
```ops
|
|
19
18
|
set-header Implementation: verified — durable adapter (`src/db.ts`)
|
|
20
19
|
bump-version minor
|
|
21
20
|
set-criterion 1: verified
|
|
22
21
|
append-criterion [unverified] new signal — verified by `test/x.test.ts`
|
|
22
|
+
append-requirement event: When <trigger>, the system shall <action>.
|
|
23
|
+
replace-requirement ubiquitous 2: The system shall <action>.
|
|
23
24
|
```
|
|
24
25
|
|
|
26
|
+
Requirement sections: ubiquitous | event | state | unwanted | optional.
|
|
27
|
+
append-* ops resolve numbering/position at APPLY time, so several open
|
|
28
|
+
changes appending to the same spec never collide on numbers — order of
|
|
29
|
+
application decides.
|
|
30
|
+
|
|
25
31
|
For REMOVED: the body may be empty; on apply, the target spec file is
|
|
26
32
|
deleted and the capability is recorded in the change archive only.
|
|
27
33
|
-->
|