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.
@@ -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 m = text.match(/^[ \t]*```[ \t]*ops[ \t]*\r?\n([\s\S]*?)^[ \t]*```[ \t]*$/m);
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.** Turn any request into a change with `doctrina work
26
- "<prompt>"` and follow the printed playbook (spec delta → tasks →
27
- implement → analyze → apply → archive → validate). Unsure what to do
28
- next? Run `doctrina next`. Need to read? Run `doctrina context`. Ask the
29
- human only on genuine ambiguity.
30
-
31
- ## Doctrina command surface (reach for these — don't hand-author)
32
-
33
- The CLI scaffolds from canonical templates and keeps `index.json` in sync,
34
- so prefer it over writing artifacts by hand. Full list: `doctrina --help`.
35
-
36
- - **Read / orient (any task, first & last):** `doctrina status` (health) ·
37
- `doctrina context [<cap>] --concat` (read pack) · `doctrina next` (what's
38
- next) · `doctrina why <cap>` (provenance) · `doctrina constitution`
39
- (standing rules) · `doctrina spec list` · `doctrina decision list` ·
40
- `doctrina search <query>`
41
- - **Start:** `doctrina work "<prompt>"` · `--from-diff` (backfill from
42
- existing code) · `--chore` (no-spec infra/docs/build)
43
- - **Scaffold (manual creation stays available):** `doctrina spec new <cap>`
44
- (`--bug`) · `doctrina decision new "<title>"` · `doctrina change new <id>`
45
- · `doctrina contract new <id>` · `doctrina skill new <slug>` · `doctrina
46
- intent add "<text>"` (new product anchor post-intake)
47
- - **Advance / close:** `doctrina change apply|archive|diff <id>` · `doctrina
48
- decision accept|land|supersede <n>` · `doctrina spec set <cap> [...]` ·
49
- `doctrina close <id>` (whole close in one pass, coverage scoped to the
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: include only the sections that change, with clear
11
- before/after markers. Bookkeeping edits (flip a criterion, bump the
12
- version, set a header) can be made mechanical by adding a fenced `ops`
13
- block — on apply the CLI executes it against the target spec, all ops or
14
- none (ADR 0007). Anything the ops verbs cannot express (rewriting prose)
15
- stays a by-hand merge; a MODIFIED delta with no `ops` block prints a
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
  -->