doctrina-cli 0.20.0 → 0.22.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 (102) hide show
  1. package/README.md +7 -7
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +10 -0
  4. package/src/commands/change.js +202 -27
  5. package/src/commands/clarify.js +3 -206
  6. package/src/commands/close.js +1 -1
  7. package/src/commands/context.js +33 -2
  8. package/src/commands/contract.js +1 -0
  9. package/src/commands/coverage.js +69 -6
  10. package/src/commands/delivery.js +135 -17
  11. package/src/commands/doctor.js +83 -5
  12. package/src/commands/handoff.js +11 -2
  13. package/src/commands/hooks.js +56 -11
  14. package/src/commands/index-rebuild.js +47 -1
  15. package/src/commands/init.js +64 -8
  16. package/src/commands/intake.js +16 -5
  17. package/src/commands/metrics.js +16 -1
  18. package/src/commands/next.js +41 -6
  19. package/src/commands/onboard.js +113 -0
  20. package/src/commands/prime.js +53 -4
  21. package/src/commands/review.js +40 -4
  22. package/src/commands/search.js +2 -2
  23. package/src/commands/skill.js +2 -1
  24. package/src/commands/status.js +28 -0
  25. package/src/commands/templates.js +2 -1
  26. package/src/commands/triage.js +16 -1
  27. package/src/commands/ui.js +512 -0
  28. package/src/commands/upgrade.js +62 -4
  29. package/src/commands/validate.js +18 -0
  30. package/src/commands/verify.js +35 -7
  31. package/src/commands/why.js +1 -1
  32. package/src/commands/work.js +90 -42
  33. package/src/commands/workspace.js +89 -66
  34. package/src/index.js +107 -62
  35. package/src/lib/actions.js +62 -3
  36. package/src/lib/adapters.js +83 -1
  37. package/src/lib/agent-bridge.js +400 -0
  38. package/src/lib/applied-deltas.js +82 -0
  39. package/src/lib/args.js +34 -2
  40. package/src/lib/change-numbers.js +115 -0
  41. package/src/lib/change-ops.js +2 -2
  42. package/src/lib/clarify-scan.js +221 -0
  43. package/src/lib/code-inventory.js +594 -104
  44. package/src/lib/commands.js +58 -4
  45. package/src/lib/config.js +27 -8
  46. package/src/lib/conflict-markers.js +61 -0
  47. package/src/lib/console-model.js +668 -0
  48. package/src/lib/console-prefs.js +67 -0
  49. package/src/lib/console-room.js +201 -0
  50. package/src/lib/console-state.js +150 -0
  51. package/src/lib/console-view.js +557 -0
  52. package/src/lib/context-slice.js +34 -3
  53. package/src/lib/coverage-model.js +29 -6
  54. package/src/lib/delivery.js +90 -12
  55. package/src/lib/docs-impact.js +37 -2
  56. package/src/lib/fs-ops.js +103 -12
  57. package/src/lib/gates.js +7 -1
  58. package/src/lib/git.js +18 -0
  59. package/src/lib/index-json.js +10 -1
  60. package/src/lib/index-merge.js +90 -0
  61. package/src/lib/install.js +80 -0
  62. package/src/lib/intake-model.js +5 -2
  63. package/src/lib/json-out.js +9 -1
  64. package/src/lib/ledger.js +2 -3
  65. package/src/lib/lock.js +58 -0
  66. package/src/lib/merge-setup.js +55 -0
  67. package/src/lib/metrics-model.js +2 -2
  68. package/src/lib/onboard.js +197 -0
  69. package/src/lib/preset.js +177 -0
  70. package/src/lib/prompt.js +1 -1
  71. package/src/lib/properties.js +149 -0
  72. package/src/lib/runtime.js +19 -7
  73. package/src/lib/scan.js +13 -8
  74. package/src/lib/shell.js +45 -0
  75. package/src/lib/snapshot.js +5 -1
  76. package/src/lib/spec-ops.js +2 -2
  77. package/src/lib/task-card.js +101 -8
  78. package/src/lib/templates-model.js +3 -2
  79. package/src/lib/triage-model.js +75 -0
  80. package/src/lib/validation-model.js +23 -5
  81. package/src/lib/views.js +5 -4
  82. package/src/lib/work-model.js +8 -2
  83. package/src/lib/workspace-agents.js +67 -20
  84. package/src/lib/workspace-guard.js +65 -0
  85. package/src/lib/workspace-locate.js +141 -0
  86. package/src/lib/workspace-tools.js +104 -17
  87. package/src/lib/workspace-view.js +391 -0
  88. package/src/lib/workspace.js +83 -131
  89. package/templates/AGENTS.md.template +9 -5
  90. package/templates/README.md +4 -2
  91. package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
  92. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
  93. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  94. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  95. package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
  96. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  97. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  98. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  99. package/templates/adapters/kiro/README.md +22 -0
  100. package/templates/playbooks/absorb.md.template +39 -16
  101. package/templates/playbooks/bootstrap.md.template +18 -6
  102. package/templates/playbooks/delivery.md.template +12 -6
@@ -0,0 +1,115 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { readdirSync } from "node:fs";
4
+ import { isDir, isFile, move, read, walk, write } from "./fs-ops.js";
5
+ import { parseLedger } from "./ledger.js";
6
+ import { git, GIT_STATE } from "./git.js";
7
+
8
+ // A CHANGE'S NUMBER IS THE PROJECT'S, NOT THE CHECKOUT'S (change 0242).
9
+ //
10
+ // The next number was the highest one on disk plus one. Two people on two
11
+ // branches read the same disk, so both opened `0042-…`, and the merge that
12
+ // brought them together held two changes answering to one number — which is
13
+ // what a commit message, a task card and `doctrina close 0042` all name.
14
+ //
15
+ // The numbers already taken are therefore read from everywhere this clone
16
+ // can see them: the folders, the ledger (an abandoned change leaves only its
17
+ // line — third audit, change 0234), and the branches git knows about. A
18
+ // branch nobody fetched is still invisible, so the merge is checked too:
19
+ // `validate` names an open change that shares its number, and
20
+ // `doctrina change renumber` gives it the next free one.
21
+
22
+ const DATED = /^\d{4}-\d{2}-\d{2}-/;
23
+ const numberOf = (/** @type {string} */ id) => id.match(/^(\d{1,4})(?:-|$)/)?.[1]?.padStart(4, "0") ?? null;
24
+
25
+ /** How far back the other branches are read: a branch older than this was merged or forgotten. */
26
+ export const BRANCH_WINDOW_DAYS = 180;
27
+
28
+ /** Ids of the changes other branches added and this checkout does not hold. */
29
+ function idsOnOtherBranches(changesDir) {
30
+ if (!isDir(changesDir)) return [];
31
+ const log = git(changesDir, ["log", "--all", `--since=${BRANCH_WINDOW_DAYS}.days`, "--diff-filter=A",
32
+ "--name-only", "--format=", "--", "."]);
33
+ if (log.state !== GIT_STATE.OK) return [];
34
+ const ids = new Set();
35
+ for (const line of log.lines) {
36
+ const m = line.match(/(?:^|\/)changes\/(?:archive\/\d{4}-\d{2}-\d{2}-)?(\d{1,4}-[^/]+)\/proposal\.md$/);
37
+ if (m) ids.add(m[1]);
38
+ }
39
+ return [...ids];
40
+ }
41
+
42
+ /**
43
+ * Every change id this clone knows of, by where it was found.
44
+ * @returns {{ open: string[], archived: string[], ledger: string[], branches: string[] }}
45
+ */
46
+ export function knownChangeIds(projectRoot, { branches = true } = {}) {
47
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
48
+ const archiveDir = path.join(changesDir, "archive");
49
+ const folders = (/** @type {string} */ dir) => (isDir(dir)
50
+ ? readdirSync(dir).filter((e) => !e.startsWith(".") && e !== "archive" && isDir(path.join(dir, e)))
51
+ : []);
52
+ const ledgerFile = path.join(archiveDir, "LEDGER.md");
53
+ return {
54
+ open: folders(changesDir),
55
+ archived: folders(archiveDir).map((e) => e.replace(DATED, "")),
56
+ ledger: isFile(ledgerFile) ? parseLedger(read(ledgerFile)).entries.map((e) => e.id) : [],
57
+ branches: branches ? idsOnOtherBranches(changesDir) : [],
58
+ };
59
+ }
60
+
61
+ /** The next free change number, four digits. */
62
+ export function nextChangeNumber(projectRoot) {
63
+ const known = knownChangeIds(projectRoot);
64
+ let max = 0;
65
+ for (const id of [...known.open, ...known.archived, ...known.ledger, ...known.branches]) {
66
+ const n = numberOf(id);
67
+ if (n) max = Math.max(max, Number(n));
68
+ }
69
+ return String(max + 1).padStart(4, "0");
70
+ }
71
+
72
+ /**
73
+ * Open changes whose number another change also carries. An archived pair is
74
+ * history and stays as it is; an open one can still be given its own number.
75
+ * @returns {{ id: string, shares: string }[]}
76
+ */
77
+ export function sharedNumbers(projectRoot) {
78
+ const known = knownChangeIds(projectRoot, { branches: false });
79
+ const out = [];
80
+ for (const id of known.open) {
81
+ const n = numberOf(id);
82
+ if (!n) continue;
83
+ const other = [...known.open, ...known.archived, ...known.ledger].find((o) => o !== id && numberOf(o) === n);
84
+ // Of two open changes that share a number, the one that sorts last moves:
85
+ // one stable answer, whichever of the two a reader runs `validate` from.
86
+ if (other && !(known.open.includes(other) && other > id)) out.push({ id, shares: other });
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Give an open change the next free number: the folder moves, and the id is
93
+ * rewritten in the change's own files. The caller re-derives the index.
94
+ * @returns {{ to: string, rewritten: string[] }}
95
+ */
96
+ export function renumberChange(projectRoot, id) {
97
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
98
+ const to = `${nextChangeNumber(projectRoot)}-${id.replace(/^\d{1,4}-/, "")}`;
99
+ const dir = path.join(changesDir, to);
100
+ move(path.join(changesDir, id), dir);
101
+ const rewritten = [];
102
+ for (const file of walk(dir)) {
103
+ if (!file.endsWith(".md")) continue;
104
+ const text = read(file);
105
+ // The id as a whole, never as the start of a longer one: renumbering
106
+ // `0001-fix` turned a mention of `0001-fix-login` into `0002-fix-login`,
107
+ // a change that does not exist (change 0250).
108
+ const whole = new RegExp(`(?<![A-Za-z0-9-])${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9-])`, "g");
109
+ const next = text.replace(whole, to);
110
+ if (next === text) continue;
111
+ write(file, next, { force: true });
112
+ rewritten.push(path.relative(dir, file).split(path.sep).join("/"));
113
+ }
114
+ return { to, rewritten };
115
+ }
@@ -158,11 +158,11 @@ export function reindexChange(projectRoot, id, date = today()) {
158
158
  export function addDeltaOps(projectRoot, id, capability, lines) {
159
159
  if (lines.length === 0) {
160
160
  return { code: EXIT.USAGE, error: "nothing to add",
161
- hint: "pass --requirement \"<section>: <text>\", --criterion \"<text>\", --bump <level> or an op line" };
161
+ hint: "pass --requirement \"<section>: <text>\", --criterion \"<text>\", --property \"<text>\", --bump <level> or an op line" };
162
162
  }
163
163
  const bad = lines.map((l) => ({ l, op: parseOp(l) })).filter((x) => x.op.error);
164
164
  if (bad.length > 0) {
165
- return { code: EXIT.USAGE, error: "not an operation `apply` can read", details: bad.map((x) => x.op.error) };
165
+ return { code: EXIT.USAGE, error: "not an operation `apply` can read", details: bad.map((x) => String(x.op.error)) };
166
166
  }
167
167
  const specPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
168
168
  if (!isFile(specPath)) {
@@ -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 = /** @type {string | null} */ (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 = /** @type {string | null} */ (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 = /** @type {string | null} */ (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
+ }