doctrina-cli 0.11.0 → 0.12.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
4
4
  import { c } from "../lib/colors.js";
5
+ import { isUntouchedScaffold } from "./change.js";
5
6
 
6
7
  export async function run(positional, _flags) {
7
8
  const id = positional[0];
@@ -74,8 +75,13 @@ export async function run(positional, _flags) {
74
75
  const targetSpec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
75
76
  const targetRel = relPath(projectRoot, targetSpec);
76
77
  if (op === "ADDED") {
77
- if (exists(targetSpec)) {
78
- results.push(fail(` ${cap} (ADDED) but target ${targetRel} already exists`, " "));
78
+ // Mirrors `change apply`: an existing target that is still the
79
+ // untouched `spec new` scaffold is the canonical flow (spec new →
80
+ // ADDED delta), so it passes as a replacement; only real content fails.
81
+ if (exists(targetSpec) && !isUntouchedScaffold(read(targetSpec), cap)) {
82
+ results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first`, " "));
83
+ } else if (exists(targetSpec)) {
84
+ results.push(pass(` ${cap} (ADDED) → ${targetRel} (replaces the untouched scaffold)`, " "));
79
85
  } else {
80
86
  results.push(pass(` ${cap} (ADDED) → ${targetRel} (new)`, " "));
81
87
  }
@@ -67,9 +67,15 @@ function changeNew(args, flags) {
67
67
  const date = today();
68
68
  const tokens = { CHANGE_ID: id, CHANGE_TITLE: title, DATE: date, CAPABILITY: "" };
69
69
 
70
+ // design.md is opt-in (--design): in practice it scaffolded on every change
71
+ // and stayed empty — nine changes out of nine in the 0.11.0 field review. A
72
+ // change that needs a design doc asks for one; the rest stop carrying a
73
+ // blank file through apply/archive/ledger.
74
+ const wantDesign = flagBool(flags, "design", false);
70
75
  const tree = loadTemplateTree(templatesDir, "change");
71
76
  for (const entry of tree) {
72
77
  if (entry.relativePath === "spec-delta.md.template") continue;
78
+ if (entry.relativePath === "design.md.template" && !wantDesign) continue;
73
79
  const written = materialiseEntry(entry, changeDir, tokens, { force });
74
80
  console.log(c.green("created") + ` ${relPath(projectRoot, written)}`);
75
81
  }
@@ -139,15 +145,24 @@ function changeApply(args, _flags) {
139
145
  const targetPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
140
146
 
141
147
  if (op === "ADDED") {
142
- if (exists(targetPath)) {
143
- console.error(c.red("error:") + ` ADDED delta targets existing spec ${relPath(projectRoot, targetPath)} — refusing to overwrite`);
148
+ // The canonical new-capability flow is `spec new <cap>` (which creates
149
+ // the file) → write the ADDED delta — so "target already exists" is the
150
+ // NORMAL case, not a conflict. When the existing spec is still the
151
+ // untouched `spec new` scaffold, ADDED means "replace the placeholder
152
+ // with the real spec" and applies as a whole-file write. Only a spec
153
+ // with real content refuses, since overwriting it would destroy truth.
154
+ if (exists(targetPath) && !isUntouchedScaffold(read(targetPath), capability)) {
155
+ console.error(c.red("error:") + ` ADDED delta targets existing spec ${relPath(projectRoot, targetPath)} with real content — refusing to overwrite`);
156
+ console.error(c.gray("hint: ") + "use a MODIFIED delta (with an ```ops block for bookkeeping edits), or REMOVE the spec first if it is truly being replaced");
144
157
  errors += 1;
145
158
  continue;
146
159
  }
160
+ const replacing = exists(targetPath);
147
161
  const body = extractDeltaBody(text);
148
- write(targetPath, body, { force: false });
162
+ write(targetPath, body, { force: replacing });
149
163
  writes += 1;
150
- console.log(c.green("applied[ADDED]") + ` ${relPath(projectRoot, targetPath)}`);
164
+ console.log(c.green("applied[ADDED]") + ` ${relPath(projectRoot, targetPath)}` +
165
+ (replacing ? c.gray(" (replaced the untouched spec-new scaffold)") : ""));
151
166
  } else if (op === "REMOVED") {
152
167
  if (!exists(targetPath)) {
153
168
  console.log(c.yellow("warn:") + ` REMOVED delta targets missing spec ${relPath(projectRoot, targetPath)} — skipping`);
@@ -275,6 +290,25 @@ function changeArchive(args, flags) {
275
290
  console.error(c.red("error:") + ` archive target already exists: ${relPath(projectRoot, archiveDir)}`);
276
291
  return 1;
277
292
  }
293
+
294
+ // Flip the proposal Status before the move. `apply` only flips it when no
295
+ // delta fell back to a manual merge, so a change whose merges were manual
296
+ // (the common case before the ops-block docs) reached the archive still
297
+ // saying "proposed" — the index recorded applied, the file lied. Archiving
298
+ // IS the declaration that the change went in; stamp the file to match.
299
+ const proposalPathLive = path.join(changeDir, "proposal.md");
300
+ if (exists(proposalPathLive)) {
301
+ const txt = read(proposalPathLive);
302
+ const updated = txt.replace(
303
+ /^(-\s+\*\*Status:\*\*)\s+proposed\s*$/m,
304
+ `$1 applied\n- **Applied:** ${date}`,
305
+ );
306
+ if (updated !== txt) {
307
+ write(proposalPathLive, updated, { force: true });
308
+ console.log(c.green("status") + " proposal.md → applied (stamped at archive)");
309
+ }
310
+ }
311
+
278
312
  move(changeDir, archiveDir);
279
313
  console.log(c.green("archived") + ` ${relPath(projectRoot, archiveDir)}`);
280
314
 
@@ -470,6 +504,38 @@ function extractDeltaBody(text) {
470
504
  return text.slice(idxSep + 5).replace(/^\n+/, "");
471
505
  }
472
506
 
507
+ // Is the on-disk spec still the untouched `spec new <cap>` scaffold? Precise
508
+ // check: render the shipped capability template for the same capability and
509
+ // compare, ignoring the date-bearing "Last updated" line and whitespace
510
+ // normalisation. When the template cannot be located (unusual installs),
511
+ // fall back to the scaffold's own placeholder fingerprints — text no real
512
+ // spec keeps. Used by `change apply` so an ADDED delta can replace a
513
+ // scaffold (the canonical spec-new → delta flow) without ever clobbering a
514
+ // spec that carries real content.
515
+ function isUntouchedScaffold(specText, capability) {
516
+ const normalize = (s) =>
517
+ s.replace(/\r\n/g, "\n")
518
+ .split("\n")
519
+ .filter((line) => !/^\*\*Last updated:\*\*/.test(line))
520
+ .join("\n")
521
+ .trim();
522
+ try {
523
+ const tplPath = path.join(locateTemplatesDir(), "spec.md.template");
524
+ const rendered = read(tplPath)
525
+ .replace(/\{\{CAPABILITY\}\}/g, capability)
526
+ .replace(/\{\{DATE\}\}/g, "");
527
+ if (normalize(rendered) === normalize(specText)) return true;
528
+ } catch {
529
+ // fall through to the fingerprint heuristic
530
+ }
531
+ // Fingerprints: the Purpose placeholder comment AND an empty Ubiquitous
532
+ // section survive only in a scaffold nobody edited.
533
+ return (
534
+ specText.includes("<!-- One paragraph: what this capability does and why it exists. -->") &&
535
+ /##\s+Requirements \(EARS\)[\s\S]*?### Ubiquitous\s*\n\s*-\s*\n/.test(specText)
536
+ );
537
+ }
538
+
473
539
  // Reasons a change is not finished enough to archive. Counts unchecked
474
540
  // GitHub-style checkboxes (`- [ ]`) in tasks.md (every task, including the
475
541
  // closing steps) and in the proposal's "## Verification" section. Returns
@@ -521,8 +587,12 @@ Usage: doctrina change <subcommand> [args]
521
587
  Subcommands:
522
588
  new <id> "<title>" Open a new change proposal at .doctrina/changes/<id>/
523
589
  (--chore / --no-spec: a spec-less change for infra /
524
- docs / build that still gets a proposal + ledger)
525
- apply <id> Apply spec deltas: ADDED writes, REMOVED deletes,
590
+ docs / build that still gets a proposal + ledger;
591
+ --design: also scaffold design.md, opt-in)
592
+ apply <id> Apply spec deltas: ADDED writes the full body (it
593
+ also REPLACES a target that is still the untouched
594
+ \`spec new\` scaffold — the canonical new-capability
595
+ flow; real content refuses), REMOVED deletes,
526
596
  MODIFIED with an \`\`\`ops block is applied mechanically
527
597
  (set-header / bump-version / set-criterion / ...),
528
598
  MODIFIED without one prints a manual-merge pointer.
@@ -544,9 +614,10 @@ A MODIFIED delta may carry a fenced operations block that \`apply\` executes:
544
614
  Options:
545
615
  --force Overwrite existing files (where applicable)
546
616
  --chore, --no-spec With new, open a spec-less chore change
617
+ --design With new, also scaffold design.md (opt-in)
547
618
  --reason "<text>" With abandon, the reason recorded in the ledger
548
619
  `;
549
620
 
550
621
  // Re-export parsers so scan.js (index rebuild) can reuse them, and the
551
622
  // scaffold so `work` can open a change without duplicating the logic.
552
- export { parseOperation, parseCapabilityFromDelta, changeNew };
623
+ export { parseOperation, parseCapabilityFromDelta, changeNew, isUntouchedScaffold };
@@ -5,7 +5,14 @@ import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
5
5
  import { flagBool } from "../lib/args.js";
6
6
  import { c } from "../lib/colors.js";
7
7
 
8
- const RULES = [
8
+ // Two lexicons, one linter (0.11.0 field review item 3: an English-only
9
+ // clarify is permanently red on a PT-BR project — "some" is the verb *sumir*,
10
+ // "TODO o pensamento" is the pronoun *todo* — which teaches people to ignore
11
+ // the gate). The language comes from `.doctrina/config.json` ("language":
12
+ // "pt-BR" | "en"), else a per-file stopword heuristic. Portuguese mode swaps
13
+ // the ambiguous EN tokens for a PT lexicon; TODO only counts with a colon
14
+ // ("TODO:") since bare TODO is the pronoun.
15
+ const RULES_EN = [
9
16
  {
10
17
  // "may" is intentionally NOT in this list: EARS Optional grammar
11
18
  // uses "the system may ..." legitimately, and a clarify pass that
@@ -27,6 +34,56 @@ const RULES = [
27
34
  },
28
35
  ];
29
36
 
37
+ const RULES_PT = [
38
+ {
39
+ name: "weasel",
40
+ re: /\b(talvez|possivelmente|provavelmente|aproximadamente|quem sabe|eventualmente)\b/gi,
41
+ hint: "afirme explicitamente, com um verbo definido",
42
+ },
43
+ {
44
+ // ASCII-folded variants included so "varios"/"vários" both match.
45
+ name: "vague",
46
+ re: /\b(muitos|muitas|poucos|poucas|v[aá]rios|v[aá]rias|alguns|algumas|adequado|adequada|robusto|robusta|escal[aá]vel)\b(?!\s+\d)/gi,
47
+ hint: "quantifique (um número ou um escopo preciso)",
48
+ },
49
+ {
50
+ // Bare TODO is the Portuguese pronoun; only TODO: is a marker here.
51
+ name: "placeholder",
52
+ re: /\b(TBD|FIXME|XXX|\?\?\?)\b|\bTODO:/g,
53
+ hint: "resolva antes de aplicar",
54
+ },
55
+ ];
56
+
57
+ // Language for a file: the project-declared `.doctrina/config.json`
58
+ // ("language": "pt-BR" / "pt" / "en") wins; otherwise a deterministic
59
+ // stopword count over the file text decides. Never semantic (ADR 0005).
60
+ const PT_STOPWORDS = /\b(que|n[aã]o|para|uma|como|mais|ser|quando|est[aá]|s[aã]o|pela|pelo|dos|das|ou seja|deve)\b/gi;
61
+ const EN_STOPWORDS = /\b(the|and|that|with|shall|when|this|from|are|not|for|must)\b/gi;
62
+
63
+ function projectLanguage(projectRoot) {
64
+ const cfgPath = path.join(projectRoot, ".doctrina", "config.json");
65
+ if (isFile(cfgPath)) {
66
+ try {
67
+ const lang = String(JSON.parse(read(cfgPath))?.language ?? "").toLowerCase();
68
+ if (lang.startsWith("pt")) return "pt";
69
+ if (lang.startsWith("en")) return "en";
70
+ } catch { /* fall through to detection */ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function detectLang(text) {
76
+ const pt = (text.match(PT_STOPWORDS) ?? []).length;
77
+ const en = (text.match(EN_STOPWORDS) ?? []).length;
78
+ return pt > en ? "pt" : "en";
79
+ }
80
+
81
+ function rulesFor(projectRoot, text) {
82
+ const declared = projectLanguage(projectRoot);
83
+ const lang = declared ?? detectLang(text);
84
+ return lang === "pt" ? RULES_PT : RULES_EN;
85
+ }
86
+
30
87
  export async function run(positional, flags) {
31
88
  const projectRoot = process.cwd();
32
89
 
@@ -110,19 +167,27 @@ function clarifyAll(projectRoot) {
110
167
  return 1;
111
168
  }
112
169
 
113
- function scanFile(fullPath) {
170
+ function scanFile(fullPath, projectRoot = process.cwd()) {
114
171
  const text = read(fullPath);
115
172
  const skippedRanges = collectSkippedRanges(text);
173
+ const rules = rulesFor(projectRoot, text);
116
174
  const smells = [];
117
175
 
118
- // Per-line rules: regex pass with skip-range filter.
176
+ // Per-line rules: regex pass with skip-range filter. A line carrying the
177
+ // inline suppression marker `<!-- clarify:ok -->` is deliberately accepted
178
+ // by the author — skip it entirely (the escape hatch for a false positive
179
+ // the lexicon cannot know about; "block, never imprison").
119
180
  const lines = text.split("\n");
120
181
  let cursor = 0;
121
182
  for (let i = 0; i < lines.length; i++) {
122
183
  const line = lines[i];
123
184
  const lineStart = cursor;
124
185
  const lineEnd = cursor + line.length;
125
- for (const rule of RULES) {
186
+ if (line.includes("clarify:ok")) {
187
+ cursor = lineEnd + 1;
188
+ continue;
189
+ }
190
+ for (const rule of rules) {
126
191
  rule.re.lastIndex = 0;
127
192
  let m;
128
193
  while ((m = rule.re.exec(line))) {
@@ -200,6 +265,13 @@ placeholders, and empty Acceptance criteria sections. Exits 0 when
200
265
  no smells are found, 1 otherwise. Matches inside fenced code
201
266
  blocks, HTML comments, and inline backtick spans are skipped.
202
267
 
268
+ Language-aware: declare it in .doctrina/config.json
269
+ ({ "language": "pt-BR" }) or let a per-file stopword count decide.
270
+ Portuguese mode uses a PT lexicon (bare TODO is the pronoun, only
271
+ "TODO:" is a marker; "some"/"several" are not smells). A line ending
272
+ in <!-- clarify:ok --> is author-accepted and never flagged — the
273
+ escape hatch for a false positive.
274
+
203
275
  With --all, every living document is scanned in one pass:
204
276
  product.md, capability specs, open changes, and skills. ADRs
205
277
  (immutable) and the archive (history) are excluded. CI-friendly.
@@ -1,8 +1,9 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
- import { exists, isFile } from "../lib/fs-ops.js";
3
+ import { exists, isFile, read, walk } from "../lib/fs-ops.js";
4
4
  import { flagBool } from "../lib/args.js";
5
5
  import { c } from "../lib/colors.js";
6
+ import { parseCapabilityFromDelta } from "./change.js";
6
7
  import * as analyze from "./analyze.js";
7
8
  import * as change from "./change.js";
8
9
  import * as verify from "./verify.js";
@@ -30,9 +31,21 @@ export async function run(positional, flags) {
30
31
  }
31
32
 
32
33
  const force = flagBool(flags, "force", false);
33
- const strict = new Map([["strict", true]]);
34
34
  const archiveFlags = force ? new Map([["force", true]]) : new Map();
35
35
 
36
+ // Scope the coverage gate to the capabilities THIS change touches (its
37
+ // deltas), so one deliberately deferred spec elsewhere in the tree cannot
38
+ // block closing a change that never went near it (0.11.0 field review
39
+ // item 4). A change with no deltas (chore / metadata-only) falls back to
40
+ // the whole-tree gate — there is no narrower honest scope for it.
41
+ const touched = touchedCapabilities(projectRoot, id);
42
+ const coverageFlags = new Map([["strict", true]]);
43
+ let coverageRerun = "doctrina coverage --strict";
44
+ if (touched.length > 0) {
45
+ coverageFlags.set("only", touched.join(","));
46
+ coverageRerun = `doctrina coverage --strict --only ${touched.join(",")}`;
47
+ }
48
+
36
49
  // Each step: a label, the command that runs it, and the literal command to
37
50
  // rerun on failure. verify is conditional (skipped, with a loud note, when no
38
51
  // verify.json is declared — a project may not have wired the real gate yet).
@@ -43,7 +56,11 @@ export async function run(positional, flags) {
43
56
  verifyConfigured
44
57
  ? { label: "verify", rerun: "doctrina verify", run: () => verify.run([], new Map()) }
45
58
  : { label: "verify", skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
46
- { label: "coverage", rerun: "doctrina coverage --strict", run: () => coverage.run([], strict) },
59
+ {
60
+ label: touched.length > 0 ? `coverage (scoped: ${touched.join(", ")})` : "coverage",
61
+ rerun: coverageRerun,
62
+ run: () => coverage.run([], coverageFlags),
63
+ },
47
64
  // trace is advisory (provenance is a warning, not a hard gate): report it,
48
65
  // never let it block the close.
49
66
  { label: "trace", rerun: "doctrina trace", run: async () => { await trace.run([], new Map()); return 0; } },
@@ -84,19 +101,35 @@ export async function run(positional, flags) {
84
101
  return 0;
85
102
  }
86
103
 
104
+ // The capabilities this change's deltas target — the honest scope for its
105
+ // coverage gate. Read from the change folder's specs/**/delta.md files.
106
+ function touchedCapabilities(projectRoot, id) {
107
+ const specsDir = path.join(projectRoot, ".doctrina", "changes", id, "specs");
108
+ const caps = new Set();
109
+ for (const deltaPath of walk(specsDir)) {
110
+ if (!deltaPath.endsWith("delta.md")) continue;
111
+ const cap = parseCapabilityFromDelta(read(deltaPath), deltaPath);
112
+ if (cap) caps.add(cap);
113
+ }
114
+ return [...caps].sort();
115
+ }
116
+
87
117
  export const help = `
88
118
  Usage: doctrina close <id> [--force]
89
119
 
90
120
  Run the whole closing sequence for a change in one pass, stopping at the
91
121
  first failure with the exact command to rerun:
92
122
 
93
- analyze → change apply → verify → coverage --strict → trace → change
94
- archive → validate
123
+ analyze → change apply → verify → coverage --strict (scoped to the
124
+ change's touched capabilities) → trace → change archive → validate
95
125
 
96
- verify is skipped (with a note) when no .doctrina/verify.json is declared;
97
- trace is advisory (a report, never a blocker). This is a driver over the
98
- existing commands — it adds no checks of its own — so the agent makes one
99
- call instead of seven and the human approves once.
126
+ The coverage gate is scoped to the capabilities the change's deltas touch,
127
+ so a deliberately deferred spec elsewhere cannot block an unrelated close;
128
+ a change with no deltas gates on the whole tree. verify is skipped (with a
129
+ note) when no .doctrina/verify.json is declared; trace is advisory (a
130
+ report, never a blocker). This is a driver over the existing commands — it
131
+ adds no checks of its own — so the agent makes one call instead of seven
132
+ and the human approves once.
100
133
 
101
134
  Options:
102
135
  --force Pass through to \`change archive\` (archive even if verification
@@ -3,7 +3,7 @@ import process from "node:process";
3
3
  import { readdirSync } from "node:fs";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
6
- import { listHeader } from "../lib/scan.js";
6
+ import { listHeader, parseDependsOn } from "../lib/scan.js";
7
7
  import { parseFrontmatter } from "./skill.js";
8
8
  import { flagBool, flagString } from "../lib/args.js";
9
9
  import { c } from "../lib/colors.js";
@@ -80,6 +80,15 @@ export async function run(positional, flags) {
80
80
  const specRel = `.doctrina/specs/${capability}/spec.md`;
81
81
  if (isFile(path.join(projectRoot, specRel))) {
82
82
  pushFile(specRel, `spec: ${capability}`);
83
+ // Pull the declared dependencies into the pack (the machine-readable
84
+ // **Depends on:** header): a task on `risk-map` that builds on
85
+ // `reporting` needs both truths in one read. One level deep,
86
+ // deterministic, no cycles (each spec is pushed at most once).
87
+ for (const dep of parseDependsOn(read(path.join(projectRoot, specRel)))) {
88
+ if (dep === capability) continue;
89
+ const depRel = `.doctrina/specs/${dep}/spec.md`;
90
+ if (isFile(path.join(projectRoot, depRel))) pushFile(depRel, `dependency of ${capability}`);
91
+ }
83
92
  } else {
84
93
  specMissing = true;
85
94
  }