doctrina-cli 0.1.1 → 0.2.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.
@@ -0,0 +1,103 @@
1
+ // Minimal line diff (LCS-based) for `change diff`. Specs are capped at a
2
+ // few hundred lines, so the O(n*m) table is fine and keeps us at zero deps.
3
+
4
+ export function diffLines(aText, bText) {
5
+ const a = splitLines(aText);
6
+ const b = splitLines(bText);
7
+ const n = a.length;
8
+ const m = b.length;
9
+
10
+ // LCS length table.
11
+ const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
12
+ for (let i = n - 1; i >= 0; i--) {
13
+ for (let j = m - 1; j >= 0; j--) {
14
+ table[i][j] = a[i] === b[j]
15
+ ? table[i + 1][j + 1] + 1
16
+ : Math.max(table[i + 1][j], table[i][j + 1]);
17
+ }
18
+ }
19
+
20
+ // Backtrack into a flat op list.
21
+ const ops = [];
22
+ let i = 0;
23
+ let j = 0;
24
+ while (i < n && j < m) {
25
+ if (a[i] === b[j]) {
26
+ ops.push({ type: "same", text: a[i] });
27
+ i += 1;
28
+ j += 1;
29
+ } else if (table[i + 1][j] >= table[i][j + 1]) {
30
+ ops.push({ type: "del", text: a[i] });
31
+ i += 1;
32
+ } else {
33
+ ops.push({ type: "add", text: b[j] });
34
+ j += 1;
35
+ }
36
+ }
37
+ while (i < n) ops.push({ type: "del", text: a[i++] });
38
+ while (j < m) ops.push({ type: "add", text: b[j++] });
39
+ return ops;
40
+ }
41
+
42
+ // Render ops as unified-style hunks with `context` lines around changes.
43
+ export function formatUnified(ops, { context = 3, aLabel = "a", bLabel = "b" } = {}) {
44
+ const out = [`--- ${aLabel}`, `+++ ${bLabel}`];
45
+ const hunks = collectHunks(ops, context);
46
+ let aLine = 1;
47
+ let bLine = 1;
48
+ let cursor = 0;
49
+ for (const [start, end] of hunks) {
50
+ // Advance line counters across the skipped region.
51
+ for (; cursor < start; cursor++) {
52
+ if (ops[cursor].type !== "add") aLine += 1;
53
+ if (ops[cursor].type !== "del") bLine += 1;
54
+ }
55
+ let aLen = 0;
56
+ let bLen = 0;
57
+ for (let k = start; k < end; k++) {
58
+ if (ops[k].type !== "add") aLen += 1;
59
+ if (ops[k].type !== "del") bLen += 1;
60
+ }
61
+ out.push(`@@ -${aLine},${aLen} +${bLine},${bLen} @@`);
62
+ for (; cursor < end; cursor++) {
63
+ const op = ops[cursor];
64
+ const prefix = op.type === "add" ? "+" : op.type === "del" ? "-" : " ";
65
+ out.push(prefix + op.text);
66
+ if (op.type !== "add") aLine += 1;
67
+ if (op.type !== "del") bLine += 1;
68
+ }
69
+ }
70
+ if (hunks.length === 0) out.push("(no differences)");
71
+ return out.join("\n");
72
+ }
73
+
74
+ function collectHunks(ops, context) {
75
+ // Indices of changed ops.
76
+ const changed = [];
77
+ for (let k = 0; k < ops.length; k++) {
78
+ if (ops[k].type !== "same") changed.push(k);
79
+ }
80
+ if (changed.length === 0) return [];
81
+ const hunks = [];
82
+ let start = Math.max(0, changed[0] - context);
83
+ let end = Math.min(ops.length, changed[0] + context + 1);
84
+ for (let c = 1; c < changed.length; c++) {
85
+ const k = changed[c];
86
+ if (k - context <= end) {
87
+ end = Math.min(ops.length, k + context + 1);
88
+ } else {
89
+ hunks.push([start, end]);
90
+ start = Math.max(0, k - context);
91
+ end = Math.min(ops.length, k + context + 1);
92
+ }
93
+ }
94
+ hunks.push([start, end]);
95
+ return hunks;
96
+ }
97
+
98
+ function splitLines(text) {
99
+ const lines = text.split("\n");
100
+ // Drop a single trailing empty line produced by a final newline.
101
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
102
+ return lines;
103
+ }
@@ -0,0 +1,108 @@
1
+ // Structural EARS checks for capability specs. The clarify command catches
2
+ // word-level smells; this catches grammar-shape problems per EARS section.
3
+ // All findings are warnings: specs evolve, validation should not block.
4
+
5
+ const SECTION_RULES = [
6
+ {
7
+ match: /^###\s+Ubiquitous/i,
8
+ name: "Ubiquitous",
9
+ check(req) {
10
+ if (/^when\b/i.test(req)) return "event-shaped requirement (starts with \"When\") — move to ### Event-driven";
11
+ if (/^while\b/i.test(req)) return "state-shaped requirement (starts with \"While\") — move to ### State-driven";
12
+ if (/^where\b/i.test(req)) return "optional-shaped requirement (starts with \"Where\") — move to ### Optional";
13
+ if (!/\bshall\b/i.test(req)) return "missing \"shall\" — ubiquitous form is \"The system shall ...\"";
14
+ return null;
15
+ },
16
+ },
17
+ {
18
+ match: /^###\s+Event-driven/i,
19
+ name: "Event-driven",
20
+ check(req) {
21
+ if (!/^when\b/i.test(req)) return "expected \"When <trigger>, the system shall ...\"";
22
+ if (!/\bshall\b/i.test(req)) return "missing \"shall\" after the When-trigger";
23
+ return null;
24
+ },
25
+ },
26
+ {
27
+ match: /^###\s+State-driven/i,
28
+ name: "State-driven",
29
+ check(req) {
30
+ if (!/^while\b/i.test(req)) return "expected \"While <state>, the system shall ...\"";
31
+ if (!/\bshall\b/i.test(req)) return "missing \"shall\" after the While-state";
32
+ return null;
33
+ },
34
+ },
35
+ {
36
+ match: /^###\s+Unwanted/i,
37
+ name: "Unwanted-behavior",
38
+ check(req) {
39
+ if (!/\bshall\b/i.test(req)) return "missing \"shall\" — unwanted-behavior form is \"The system shall not ...\"";
40
+ if (!/\b(not|no|never)\b/i.test(req)) return "missing a negation — unwanted-behavior requirements forbid something";
41
+ return null;
42
+ },
43
+ },
44
+ {
45
+ match: /^###\s+Optional/i,
46
+ name: "Optional",
47
+ check(req) {
48
+ if (!/^where\b/i.test(req)) return "expected \"Where <feature is present>, the system may ...\"";
49
+ if (!/\bmay\b/i.test(req)) return "missing \"may\" — optional requirements use may, not shall";
50
+ return null;
51
+ },
52
+ },
53
+ ];
54
+
55
+ // Check one spec text. Returns [{ line, message }]. Only applies to specs
56
+ // that declare a "## Requirements (EARS)" section; bug-shape specs and
57
+ // free-form specs are skipped entirely.
58
+ export function checkEars(text) {
59
+ const lines = text.split("\n");
60
+ const findings = [];
61
+
62
+ let inRequirements = false;
63
+ let rule = null;
64
+ let req = null; // { line, text }
65
+
66
+ const flush = () => {
67
+ if (req && rule) {
68
+ const message = rule.check(req.text.trim());
69
+ if (message) findings.push({ line: req.line, message: `${rule.name}: ${message}` });
70
+ }
71
+ req = null;
72
+ };
73
+
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const line = lines[i];
76
+
77
+ if (/^##\s+Requirements \(EARS\)/i.test(line)) {
78
+ inRequirements = true;
79
+ continue;
80
+ }
81
+ if (!inRequirements) continue;
82
+ if (/^##\s+/.test(line)) {
83
+ // Left the Requirements section.
84
+ flush();
85
+ break;
86
+ }
87
+ if (/^###\s+/.test(line)) {
88
+ flush();
89
+ rule = SECTION_RULES.find((r) => r.match.test(line)) ?? null;
90
+ continue;
91
+ }
92
+ if (/^-\s+/.test(line)) {
93
+ flush();
94
+ req = { line: i + 1, text: line.replace(/^-\s+/, "") };
95
+ continue;
96
+ }
97
+ // Continuation of the current bullet (indented text or nested bullet).
98
+ if (req && /^\s+\S/.test(line)) {
99
+ req.text += " " + line.trim();
100
+ }
101
+ }
102
+ flush();
103
+ return findings;
104
+ }
105
+
106
+ export function isEarsSpec(text) {
107
+ return /^##\s+Requirements \(EARS\)/im.test(text);
108
+ }
package/src/lib/fs-ops.js CHANGED
@@ -73,3 +73,15 @@ export function lineCount(p) {
73
73
  if (text.length === 0) return 0;
74
74
  return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
75
75
  }
76
+
77
+ // Normalise a path to forward slashes for display and comparison. CLI
78
+ // output is part of the contract (tests, docs, agents parse it), so it
79
+ // must not change shape between platforms.
80
+ export function toPosix(p) {
81
+ return p.split(path.sep).join("/");
82
+ }
83
+
84
+ // path.relative with stable, platform-independent forward slashes.
85
+ export function relPath(from, to) {
86
+ return toPosix(path.relative(from, to));
87
+ }
@@ -0,0 +1,185 @@
1
+ import path from "node:path";
2
+ import { readdirSync } from "node:fs";
3
+ import { isDir, isFile, read, walk } from "./fs-ops.js";
4
+ import { today } from "./dates.js";
5
+ import { parseFrontmatter } from "../commands/skill.js";
6
+ import { parseOperation, parseCapabilityFromDelta } from "../commands/change.js";
7
+
8
+ // Parse a spec-style header line: **Name:** value
9
+ export function specHeader(text, name) {
10
+ const m = text.match(new RegExp(`^\\*\\*${name}:\\*\\*\\s+(.+)$`, "m"));
11
+ return m ? m[1].trim() : null;
12
+ }
13
+
14
+ // Parse an ADR/proposal-style header line: - **Name:** value
15
+ export function listHeader(text, name) {
16
+ const m = text.match(new RegExp(`^-\\s+\\*\\*${name}:\\*\\*\\s+(.+)$`, "m"));
17
+ return m ? m[1].trim() : null;
18
+ }
19
+
20
+ function dirEntries(dir) {
21
+ if (!isDir(dir)) return [];
22
+ return readdirSync(dir).filter((e) => !e.startsWith(".")).sort();
23
+ }
24
+
25
+ // Regenerate the index object from the artifacts on disk. The files are
26
+ // the source of truth; fields with no on-disk source (project name,
27
+ // framework_version, product metadata) are carried over from `current`.
28
+ export function deriveIndex(projectRoot, current) {
29
+ const dot = path.join(projectRoot, ".doctrina");
30
+ const date = today();
31
+ const cur = current?.artifacts ?? {};
32
+
33
+ const out = {
34
+ $schema_version: current?.$schema_version ?? "0.1.0",
35
+ project: current?.project ?? path.basename(projectRoot),
36
+ framework_version: current?.framework_version ?? "0.0.0",
37
+ last_updated: current?.last_updated ?? date,
38
+ artifacts: {
39
+ product: cur.product ?? {
40
+ path: ".doctrina/product.md",
41
+ status: "active",
42
+ version: "0.1.0",
43
+ last_updated: date,
44
+ },
45
+ specs: [],
46
+ decisions: [],
47
+ changes: [],
48
+ changes_archive: [],
49
+ skills: [],
50
+ },
51
+ };
52
+
53
+ // Specs — headers inside each spec.md win over the previous index.
54
+ const specsDir = path.join(dot, "specs");
55
+ for (const cap of dirEntries(specsDir)) {
56
+ const p = path.join(specsDir, cap, "spec.md");
57
+ if (!isFile(p)) continue;
58
+ const text = read(p);
59
+ const prev = (cur.specs ?? []).find((s) => s.id === cap);
60
+ out.artifacts.specs.push({
61
+ id: cap,
62
+ path: `.doctrina/specs/${cap}/spec.md`,
63
+ status: specHeader(text, "Status") ?? prev?.status ?? "active",
64
+ version: specHeader(text, "Version") ?? prev?.version ?? "0.1.0",
65
+ last_updated: specHeader(text, "Last updated") ?? prev?.last_updated ?? date,
66
+ });
67
+ }
68
+
69
+ // Decisions — NNNN-slug.md files; Status/Date headers win.
70
+ const adrDir = path.join(dot, "decisions");
71
+ for (const f of walk(adrDir)) {
72
+ const base = path.basename(f);
73
+ const m = base.match(/^(\d{4})-.*\.md$/);
74
+ if (!m) continue;
75
+ const text = read(f);
76
+ const prev = (cur.decisions ?? []).find((d) => d.id === m[1]);
77
+ const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
78
+ const entry = {
79
+ id: m[1],
80
+ path: `.doctrina/decisions/${base}`,
81
+ title: titleMatch ? titleMatch[1].trim() : prev?.title ?? base,
82
+ status: listHeader(text, "Status") ?? prev?.status ?? "proposed",
83
+ date: listHeader(text, "Date") ?? prev?.date ?? date,
84
+ };
85
+ const supersedes = listHeader(text, "Supersedes");
86
+ if (supersedes && supersedes !== "—") entry.supersedes = supersedes;
87
+ const supersededBy = listHeader(text, "Superseded by");
88
+ if (supersededBy && supersededBy !== "—") entry.superseded_by = supersededBy;
89
+ out.artifacts.decisions.push(entry);
90
+ }
91
+
92
+ // Open changes — every directory except archive/.
93
+ const changesDir = path.join(dot, "changes");
94
+ for (const id of dirEntries(changesDir)) {
95
+ if (id === "archive") continue;
96
+ if (!isDir(path.join(changesDir, id))) continue;
97
+ const prev = (cur.changes ?? []).find((c) => c.id === id);
98
+ const proposalPath = path.join(changesDir, id, "proposal.md");
99
+ const proposal = isFile(proposalPath) ? read(proposalPath) : "";
100
+ const titleMatch = proposal.match(/^#\s+Change\s+\S+\s*[—-]\s*(.+)$/m);
101
+ out.artifacts.changes.push({
102
+ id,
103
+ title: titleMatch ? titleMatch[1].trim() : prev?.title ?? id,
104
+ path: `.doctrina/changes/${id}`,
105
+ status: listHeader(proposal, "Status") ?? prev?.status ?? "proposed",
106
+ opened: listHeader(proposal, "Date") ?? prev?.opened ?? date,
107
+ });
108
+ }
109
+
110
+ // Archived changes — folder name carries the applied date and id.
111
+ const archiveDir = path.join(changesDir, "archive");
112
+ for (const name of dirEntries(archiveDir)) {
113
+ const m = name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
114
+ if (!m || !isDir(path.join(archiveDir, name))) continue;
115
+ const prev = (cur.changes_archive ?? []).find((c) => c.path?.endsWith(name));
116
+ const proposalPath = path.join(archiveDir, name, "proposal.md");
117
+ const proposal = isFile(proposalPath) ? read(proposalPath) : "";
118
+ const titleMatch = proposal.match(/^#\s+Change\s+\S+\s*[—-]\s*(.+)$/m);
119
+ const specsAffected = [];
120
+ for (const deltaPath of walk(path.join(archiveDir, name, "specs"))) {
121
+ if (!deltaPath.endsWith("delta.md")) continue;
122
+ const text = read(deltaPath);
123
+ const capability = parseCapabilityFromDelta(text, deltaPath);
124
+ if (capability) {
125
+ specsAffected.push({ capability, operation: parseOperation(text) ?? "MODIFIED" });
126
+ }
127
+ }
128
+ out.artifacts.changes_archive.push({
129
+ id: m[2],
130
+ title: titleMatch ? titleMatch[1].trim() : prev?.title ?? m[2],
131
+ path: `.doctrina/changes/archive/${name}`,
132
+ status: "applied",
133
+ applied: m[1],
134
+ specs_affected: specsAffected,
135
+ });
136
+ }
137
+
138
+ // Skills — frontmatter description wins.
139
+ const skillsDir = path.join(dot, "skills");
140
+ for (const f of walk(skillsDir)) {
141
+ if (!f.endsWith(".md")) continue;
142
+ const id = path.basename(f, ".md");
143
+ const prev = (cur.skills ?? []).find((s) => s.id === id);
144
+ out.artifacts.skills.push({
145
+ id,
146
+ path: `.doctrina/skills/${id}.md`,
147
+ description: parseFrontmatter(read(f), "description")
148
+ ?? prev?.description ?? "<edit me — one-sentence summary>",
149
+ last_updated: prev?.last_updated ?? date,
150
+ });
151
+ }
152
+
153
+ return out;
154
+ }
155
+
156
+ // Deterministic stringify (sorted keys) so two indexes compare by content,
157
+ // not by key insertion order.
158
+ export function stableStringify(value) {
159
+ if (Array.isArray(value)) {
160
+ return "[" + value.map(stableStringify).join(",") + "]";
161
+ }
162
+ if (value && typeof value === "object") {
163
+ const keys = Object.keys(value).sort();
164
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(value[k])).join(",") + "}";
165
+ }
166
+ return JSON.stringify(value);
167
+ }
168
+
169
+ // Content equality ignoring the root last_updated timestamp. Absent
170
+ // artifact categories compare equal to empty ones so pre-skills indexes
171
+ // are not flagged for a purely structural difference.
172
+ export function indexesMatch(a, b) {
173
+ if (!a || !b) return false;
174
+ const normalize = (x) => {
175
+ const clone = { ...x };
176
+ delete clone.last_updated;
177
+ const arts = { ...(clone.artifacts ?? {}) };
178
+ for (const cat of ["specs", "decisions", "changes", "changes_archive", "skills"]) {
179
+ arts[cat] = arts[cat] ?? [];
180
+ }
181
+ clone.artifacts = arts;
182
+ return clone;
183
+ };
184
+ return stableStringify(normalize(a)) === stableStringify(normalize(b));
185
+ }
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { read, walk, write } from "./fs-ops.js";
3
+ import { read, toPosix, walk, write } from "./fs-ops.js";
4
4
 
5
5
  // Resolve the path to the .doctrina/templates/ directory shipped with this
6
6
  // package. The package layout is:
@@ -59,7 +59,7 @@ export function loadTemplateTree(templatesDir, subdir) {
59
59
  const root = path.join(templatesDir, subdir);
60
60
  const files = walk(root);
61
61
  return files.map((templatePath) => {
62
- const relativePath = path.relative(root, templatePath);
62
+ const relativePath = toPosix(path.relative(root, templatePath));
63
63
  const body = templatePath.endsWith(".gitkeep") ? "" : read(templatePath);
64
64
  return { templatePath, relativePath, body };
65
65
  });
@@ -11,6 +11,21 @@ This repository uses the Doctrina framework for spec-driven, AGENTS.md
11
11
  native multi-agent development. The full framework artifacts live under
12
12
  `.doctrina/`.
13
13
 
14
+ ## Working from intent
15
+
16
+ - **Bootstrap.** If `.doctrina/intake.md` exists with `Status: pending`,
17
+ the specs have not been written yet. Before anything else, run
18
+ `doctrina intake` and execute the printed bootstrap playbook in one
19
+ linear pass: fill `product.md`, derive capabilities, author one EARS
20
+ spec each, run the quality gates (`doctrina clarify --all`,
21
+ `doctrina validate`), then flip the intake to `Status: converted`. Ask
22
+ the user only when the intake is genuinely ambiguous. After conversion
23
+ the specs are the only source of truth — never edit `intake.md` to
24
+ change requirements.
25
+ - **Day-to-day.** Turn a brief request into a scaffolded change with
26
+ `doctrina work "<prompt>"`, then follow the printed work playbook
27
+ (spec delta → tasks → implement → analyze → apply → archive → validate).
28
+
14
29
  ## Stack and tooling
15
30
 
16
31
  <!-- Replace with the project's actual stack. Keep this section short. -->
@@ -38,18 +38,33 @@ doctrina/ the .doctrina/ skeleton
38
38
  changes/.gitkeep
39
39
  changes/archive/.gitkeep
40
40
  decisions/.gitkeep
41
+ skills/.gitkeep
41
42
  templates/.gitkeep
42
- spec.md.template one capability spec
43
+ spec.md.template one capability spec (EARS)
44
+ spec-bug.md.template bug-shape capability spec
45
+ skill.md.template one on-demand skill
43
46
  change/
44
47
  proposal.md.template
45
48
  tasks.md.template
46
49
  design.md.template
47
50
  spec-delta.md.template
48
51
  decision.md.template one ADR (Nygard/MADR)
52
+ hooks/
53
+ pre-commit.sample installed by `doctrina hooks install`
54
+ watch.sample on-save validation wrapper (manual wiring)
49
55
  adapters/
50
56
  claude/CLAUDE.md.template
51
57
  codex/README.md
52
- cursor/rules/00-doctrina.mdc.template
58
+ cursor/.cursor/rules/00-doctrina.mdc.template
59
+ copilot/.github/copilot-instructions.md.template
60
+ gemini/GEMINI.md.template
61
+ aider/CONVENTIONS.md.template
62
+ windsurf/.windsurfrules.template
63
+ continue/.continue/rules/00-doctrina.md.template
64
+ amp/README.md
65
+ devin/README.md
66
+ factory/README.md
67
+ jules/README.md
53
68
  ```
54
69
 
55
70
  ## Adapter strategy
@@ -59,6 +74,12 @@ adapters/
59
74
  | `claude` | `CLAUDE.md` at project root with `@AGENTS.md` import | Claude Code reads `CLAUDE.md` natively and supports `@file` includes. |
60
75
  | `codex` | nothing | OpenAI Codex CLI reads `AGENTS.md` natively; an adapter file would only add drift surface. |
61
76
  | `cursor` | `.cursor/rules/00-doctrina.mdc` with `alwaysApply: true` | Cursor's `.mdc` rules live in a dedicated directory; the file is a thin pointer. |
77
+ | `copilot` | `.github/copilot-instructions.md` | GitHub Copilot's repository-level custom instructions path. |
78
+ | `gemini` | `GEMINI.md` at project root | Gemini CLI's native context file. |
79
+ | `aider` | `CONVENTIONS.md` at project root | Aider reads CONVENTIONS.md as cached read-only context. |
80
+ | `windsurf` | `.windsurfrules` at project root | Windsurf's rules file convention. |
81
+ | `continue` | `.continue/rules/00-doctrina.md` | Continue.dev's rules directory. |
82
+ | `amp` / `devin` / `factory` / `jules` | nothing | AGENTS.md-native (same posture as `codex`); the directory marks the support contract and gives future per-agent affordances a home. |
62
83
 
63
84
  Adapter files stay under 30 lines so they cannot meaningfully drift from
64
85
  `AGENTS.md` and so they do not pay a noticeable per-turn token tax.
@@ -13,6 +13,7 @@
13
13
  "specs": [],
14
14
  "decisions": [],
15
15
  "changes": [],
16
- "changes_archive": []
16
+ "changes_archive": [],
17
+ "skills": []
17
18
  }
18
19
  }