doctrina-cli 0.20.0 → 0.21.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.
Files changed (57) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +10 -0
  4. package/src/commands/change.js +92 -14
  5. package/src/commands/clarify.js +1 -204
  6. package/src/commands/context.js +33 -2
  7. package/src/commands/contract.js +1 -0
  8. package/src/commands/delivery.js +135 -17
  9. package/src/commands/doctor.js +21 -1
  10. package/src/commands/handoff.js +11 -2
  11. package/src/commands/init.js +13 -1
  12. package/src/commands/intake.js +16 -5
  13. package/src/commands/metrics.js +15 -0
  14. package/src/commands/next.js +41 -6
  15. package/src/commands/prime.js +53 -4
  16. package/src/commands/review.js +39 -3
  17. package/src/commands/skill.js +2 -1
  18. package/src/commands/status.js +28 -0
  19. package/src/commands/upgrade.js +18 -0
  20. package/src/commands/validate.js +18 -0
  21. package/src/commands/verify.js +32 -4
  22. package/src/commands/work.js +43 -8
  23. package/src/commands/workspace.js +73 -63
  24. package/src/index.js +98 -62
  25. package/src/lib/actions.js +43 -3
  26. package/src/lib/adapters.js +63 -1
  27. package/src/lib/args.js +33 -1
  28. package/src/lib/clarify-scan.js +221 -0
  29. package/src/lib/code-inventory.js +576 -103
  30. package/src/lib/commands.js +8 -0
  31. package/src/lib/context-slice.js +34 -3
  32. package/src/lib/delivery.js +88 -10
  33. package/src/lib/docs-impact.js +36 -1
  34. package/src/lib/fs-ops.js +23 -0
  35. package/src/lib/intake-model.js +1 -1
  36. package/src/lib/json-out.js +9 -1
  37. package/src/lib/shell.js +45 -0
  38. package/src/lib/snapshot.js +5 -1
  39. package/src/lib/task-card.js +100 -7
  40. package/src/lib/views.js +3 -3
  41. package/src/lib/work-model.js +8 -2
  42. package/src/lib/workspace-agents.js +65 -18
  43. package/src/lib/workspace-guard.js +65 -0
  44. package/src/lib/workspace-locate.js +141 -0
  45. package/src/lib/workspace-tools.js +63 -16
  46. package/src/lib/workspace-view.js +391 -0
  47. package/src/lib/workspace.js +82 -130
  48. package/templates/AGENTS.md.template +1 -1
  49. package/templates/README.md +2 -1
  50. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  51. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  52. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  53. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  54. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  55. package/templates/playbooks/absorb.md.template +39 -16
  56. package/templates/playbooks/bootstrap.md.template +18 -6
  57. package/templates/playbooks/delivery.md.template +12 -6
package/src/lib/args.js CHANGED
@@ -8,12 +8,25 @@
8
8
  // -x=value or -x value -> string value
9
9
  // -- -> stop flag parsing; rest is positional
10
10
  //
11
- // Anything else becomes positional. Repeated flags overwrite the previous value.
11
+ // Anything else becomes positional. Repeated flags overwrite the previous value
12
+ // in `flags`; every value is still kept, in order, for `flagAll` — a command
13
+ // that takes a flag more than once (`change delta --op a --op b`) reads them
14
+ // all there instead of losing every one but the last (change 0228).
15
+
16
+ /** @type {WeakMap<Map<string, string|boolean>, Map<string, string[]>>} */
17
+ const EVERY_VALUE = new WeakMap();
12
18
 
13
19
  export function parseArgs(argv, opts = {}) {
14
20
  const booleanFlags = new Set(opts.boolean ?? []);
15
21
  const positional = [];
22
+ /** @type {Map<string, string|boolean>} */
16
23
  const flags = new Map();
24
+ /** @type {Map<string, string[]>} */
25
+ const every = new Map();
26
+ EVERY_VALUE.set(flags, every);
27
+ const keep = (/** @type {string} */ name, /** @type {string|boolean} */ value) => {
28
+ if (typeof value === "string") every.set(name, [...(every.get(name) ?? []), value]);
29
+ };
17
30
  let i = 0;
18
31
  let stopped = false;
19
32
 
@@ -37,12 +50,14 @@ export function parseArgs(argv, opts = {}) {
37
50
  const eq = body.indexOf("=");
38
51
  if (eq >= 0) {
39
52
  flags.set(body.slice(0, eq), body.slice(eq + 1));
53
+ keep(body.slice(0, eq), body.slice(eq + 1));
40
54
  i += 1;
41
55
  } else if (booleanFlags.has(body) || isNextTokenFlag(argv[i + 1])) {
42
56
  flags.set(body, true);
43
57
  i += 1;
44
58
  } else {
45
59
  flags.set(body, argv[i + 1] ?? true);
60
+ keep(body, argv[i + 1] ?? true);
46
61
  i += 2;
47
62
  }
48
63
  continue;
@@ -53,12 +68,14 @@ export function parseArgs(argv, opts = {}) {
53
68
  const eq = body.indexOf("=");
54
69
  if (eq >= 0) {
55
70
  flags.set(body.slice(0, eq), body.slice(eq + 1));
71
+ keep(body.slice(0, eq), body.slice(eq + 1));
56
72
  i += 1;
57
73
  } else if (booleanFlags.has(body) || isNextTokenFlag(argv[i + 1])) {
58
74
  flags.set(body, true);
59
75
  i += 1;
60
76
  } else {
61
77
  flags.set(body, argv[i + 1] ?? true);
78
+ keep(body, argv[i + 1] ?? true);
62
79
  i += 2;
63
80
  }
64
81
  continue;
@@ -91,6 +108,21 @@ export function parsePositiveInt(raw) {
91
108
  return Number.isSafeInteger(n) && n > 0 ? n : null;
92
109
  }
93
110
 
111
+ /**
112
+ * Every value a string flag was given, in the order given — `[]` when absent.
113
+ * A Map built by hand (tests, callers that never parsed argv) answers with
114
+ * its one value.
115
+ * @param {Map<string, string|boolean>} flags
116
+ * @param {string} name
117
+ * @returns {string[]}
118
+ */
119
+ export function flagAll(flags, name) {
120
+ const every = EVERY_VALUE.get(flags)?.get(name);
121
+ if (every) return every;
122
+ const one = flags.get(name);
123
+ return typeof one === "string" ? [one] : [];
124
+ }
125
+
94
126
  export function flagString(flags, name, fallback = undefined) {
95
127
  const v = flags.get(name);
96
128
  if (v === undefined || v === true) return fallback;
@@ -0,0 +1,221 @@
1
+ // @ts-check
2
+ import process from "node:process";
3
+ import { read } from "./fs-ops.js";
4
+ import { detectLanguage } from "./lexicon.js";
5
+ import { loadConfig } from "./config.js";
6
+
7
+ // The clarify SCANNER (change 0232): the lexicons and the line scanner that
8
+ // `doctrina clarify` renders, in lib so a second caller can ask the same
9
+ // question. `change check` scans the spec a delta WILL write, before apply:
10
+ // a weasel word in a delta used to surface only after the close had merged
11
+ // it, when the repository's own living-documents check went red mid-close.
12
+
13
+ // Two lexicons, one linter (0.11.0 field review item 3: an English-only
14
+ // clarify is permanently red on a PT-BR project — "some" is the verb *sumir*,
15
+ // "TODO o pensamento" is the pronoun *todo* — which teaches people to ignore
16
+ // the gate). The language comes from `.doctrina/config.json` ("language":
17
+ // "pt-BR" | "en"), else a per-file stopword heuristic. Portuguese mode swaps
18
+ // the ambiguous EN tokens for a PT lexicon; TODO only counts with a colon
19
+ // ("TODO:") since bare TODO is the pronoun.
20
+ const RULES_EN = [
21
+ {
22
+ // "may" is intentionally NOT in this list: EARS Optional grammar
23
+ // uses "the system may ..." legitimately, and a clarify pass that
24
+ // flags every EARS Optional is unusable on Doctrina-style specs.
25
+ // The remaining weasel terms are unambiguous.
26
+ name: "weasel",
27
+ re: /\b(might|could|should\s+probably|perhaps|maybe|approximately|roughly)\b/gi,
28
+ hint: "state explicitly with a definite verb",
29
+ },
30
+ {
31
+ name: "vague",
32
+ // Two exclusions, both structural rather than a vocabulary loosening.
33
+ // The lookahead was already here: `many 5` quantifies. The lookbehind is
34
+ // change 0079: `how many` is INTERROGATIVE, and a requirement that says
35
+ // "shall report how many contracts declared no rows" names exactly the
36
+ // number the command must print — the opposite of vague. Five of this
37
+ // repository's seventeen smells were that phrase, and the noise is what
38
+ // hid the one real finding among them ("name some of them", which said
39
+ // nothing about how many). A gate that cries wolf teaches people to
40
+ // ignore it.
41
+ //
42
+ // The quality adjectives sit here for the same reason the quantifiers do:
43
+ // "robust" names no threshold, so it cannot be met or missed. They were
44
+ // in the Portuguese lexicon and nowhere in this one, so the SAME sentence
45
+ // translated came back with three smells in one language and none in the
46
+ // other — and this project keeps its own pages in both. A smell test that
47
+ // answers differently per language is not testing the writing.
48
+ re: /(?<!\bhow\s+)\b(many|few|some|several|adequate|robust|scalable)\b(?!\s+\d)/gi,
49
+ hint: "quantify (use a number or a precise scope)",
50
+ },
51
+ {
52
+ name: "placeholder",
53
+ re: /\b(TBD|TODO|FIXME|XXX|\?\?\?)\b/g,
54
+ hint: "resolve before applying",
55
+ },
56
+ ];
57
+
58
+ // How much of the previous line a rule may look back into. Long enough for
59
+ // a wrapped two-word phrase, short enough that the probe stays cheap.
60
+ const LOOKBEHIND_TAIL = 40;
61
+
62
+ const RULES_PT = [
63
+ {
64
+ name: "weasel",
65
+ re: /\b(talvez|possivelmente|provavelmente|aproximadamente|quem sabe|eventualmente)\b/gi,
66
+ hint: "afirme explicitamente, com um verbo definido",
67
+ },
68
+ {
69
+ // ASCII-folded variants included so "varios"/"vários" both match.
70
+ name: "vague",
71
+ 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,
72
+ hint: "quantifique (um número ou um escopo preciso)",
73
+ },
74
+ {
75
+ // Bare TODO is the Portuguese pronoun; only TODO: is a marker here.
76
+ name: "placeholder",
77
+ re: /\b(TBD|FIXME|XXX|\?\?\?)\b|\bTODO:/g,
78
+ hint: "resolva antes de aplicar",
79
+ },
80
+ ];
81
+
82
+ // Language for a file: the project-declared `.doctrina/config.json`
83
+ // ("language": "pt-BR" / "pt" / "en") wins; otherwise a deterministic
84
+ // stopword count over the file text decides. Never semantic (ADR 0005).
85
+
86
+ function projectLanguage(projectRoot) {
87
+ // One configuration reader (lib/config.js, change 0047): the key still
88
+ // lives in .doctrina/config.json, but `doctor` can now show what it
89
+ // resolved to, which is what a pt-BR project sitting red needed.
90
+ return loadConfig(projectRoot).language;
91
+ }
92
+
93
+ // Which lexicon a document is written in — the shared detector (change
94
+ // 0040), so `clarify` and any future consumer count the same grammar words.
95
+ function detectLang(text) {
96
+ return detectLanguage(text);
97
+ }
98
+
99
+ export function rulesFor(projectRoot, text, forcedLang = null) {
100
+ const lang = forcedLang ?? projectLanguage(projectRoot);
101
+ if (lang) return lang === "pt" ? RULES_PT : RULES_EN;
102
+ // Detected per file, the document is scanned with BOTH lexicons (change
103
+ // 0107). A bilingual spec — Portuguese purpose, English EARS, or the
104
+ // reverse — detected as one language hid every smell written in the
105
+ // other: `clarify --all` found nothing where `--lang pt` found two. The
106
+ // two vocabularies do not overlap, so a smell in either is a smell; the
107
+ // detected language only decides which hint text the reader gets first.
108
+ const detected = detectLang(text);
109
+ const [first, second] = detected === "pt" ? [RULES_PT, RULES_EN] : [RULES_EN, RULES_PT];
110
+ // The `placeholder` rule is language-neutral and lives in both lists:
111
+ // one copy, or TBD is reported twice.
112
+ return [...first, ...second.filter((r) => r.name !== "placeholder")];
113
+ }
114
+
115
+ /** The smells of a file on disk. */
116
+ export function scanFile(fullPath, projectRoot = process.cwd(), lang = null) {
117
+ return scanText(read(fullPath), projectRoot, lang);
118
+ }
119
+
120
+ /**
121
+ * The smells `clarify` reports for a text, line by line.
122
+ * @returns {Array<{ line: number, rule: string, token: string, hint: string }>}
123
+ */
124
+ export function scanText(text, projectRoot = process.cwd(), lang = null) {
125
+ const skippedRanges = collectSkippedRanges(text);
126
+ const rules = rulesFor(projectRoot, text, lang);
127
+ const smells = [];
128
+
129
+ // Per-line rules: regex pass with skip-range filter. A line carrying the
130
+ // inline suppression marker `<!-- clarify:ok -->` is deliberately accepted
131
+ // by the author — skip it entirely (the escape hatch for a false positive
132
+ // the lexicon cannot know about; "block, never imprison").
133
+ const lines = text.split("\n");
134
+ let cursor = 0;
135
+ for (let i = 0; i < lines.length; i++) {
136
+ const line = lines[i];
137
+ const lineStart = cursor;
138
+ const lineEnd = cursor + line.length;
139
+ if (line.includes("clarify:ok")) {
140
+ cursor = lineEnd + 1;
141
+ continue;
142
+ }
143
+ // A rule reads the previous line as CONTEXT, never as content: a match
144
+ // is only reported when it starts inside this line. Prose wraps, and
145
+ // "report per spec how / many criteria..." split the interrogative
146
+ // across a line break — which a line-at-a-time scanner cannot see, so
147
+ // the phrase came back as a smell on one line of the same document and
148
+ // not on the next (change 0079). The prefix is trimmed to the tail of
149
+ // the previous line: enough for a lookbehind, cheap on every line.
150
+ const prev = i > 0 ? lines[i - 1].slice(-LOOKBEHIND_TAIL) : "";
151
+ const probe = prev === "" ? line : `${prev}\n${line}`;
152
+ const offset = probe.length - line.length;
153
+ for (const rule of rules) {
154
+ rule.re.lastIndex = 0;
155
+ let m;
156
+ while ((m = rule.re.exec(probe))) {
157
+ if (m.index < offset) continue; // the match belongs to the previous line
158
+ const absoluteStart = lineStart + m.index - offset;
159
+ if (isInSkippedRange(absoluteStart, skippedRanges)) continue;
160
+ smells.push({
161
+ line: i + 1,
162
+ rule: rule.name,
163
+ token: m[0],
164
+ hint: rule.hint,
165
+ });
166
+ }
167
+ }
168
+ cursor = lineEnd + 1; // +1 for the newline
169
+ }
170
+
171
+ // Section rule: empty Acceptance criteria.
172
+ if (hasEmptyAcceptanceCriteria(text)) {
173
+ smells.push({
174
+ line: findAcceptanceLine(text),
175
+ rule: "empty-section",
176
+ token: "Acceptance criteria",
177
+ hint: "fill in observable, verifiable signals",
178
+ });
179
+ }
180
+
181
+ smells.sort((a, b) => a.line - b.line);
182
+ return smells;
183
+ }
184
+
185
+ // Build ranges to skip: fenced code blocks, HTML comments, inline backticks.
186
+ function collectSkippedRanges(text) {
187
+ const ranges = [];
188
+ // Fenced code blocks (``` ... ```)
189
+ for (const m of text.matchAll(/```[\s\S]*?```/g)) ranges.push([m.index, m.index + m[0].length]);
190
+ // HTML comments (<!-- ... -->)
191
+ for (const m of text.matchAll(/<!--[\s\S]*?-->/g)) ranges.push([m.index, m.index + m[0].length]);
192
+ // Inline backtick spans (`...`)
193
+ for (const m of text.matchAll(/`[^`\n]+`/g)) ranges.push([m.index, m.index + m[0].length]);
194
+ return ranges;
195
+ }
196
+
197
+ function isInSkippedRange(offset, ranges) {
198
+ for (const [a, b] of ranges) if (offset >= a && offset < b) return true;
199
+ return false;
200
+ }
201
+
202
+ function hasEmptyAcceptanceCriteria(text) {
203
+ const headingRe = /^##\s+Acceptance criteria\s*$/m;
204
+ const m = headingRe.exec(text);
205
+ if (!m) return false;
206
+ const after = text.slice(m.index + m[0].length);
207
+ // Find the body between this heading and the next heading of equal level.
208
+ const nextHeadingMatch = after.match(/^##\s+/m);
209
+ const body = nextHeadingMatch ? after.slice(0, nextHeadingMatch.index) : after;
210
+ // Strip HTML comments before checking emptiness.
211
+ const stripped = body.replace(/<!--[\s\S]*?-->/g, "").trim();
212
+ return stripped.length === 0;
213
+ }
214
+
215
+ function findAcceptanceLine(text) {
216
+ const lines = text.split("\n");
217
+ for (let i = 0; i < lines.length; i++) {
218
+ if (/^##\s+Acceptance criteria\s*$/.test(lines[i])) return i + 1;
219
+ }
220
+ return 1;
221
+ }