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.
package/README.md CHANGED
@@ -21,17 +21,30 @@ Requires Node.js 20.12 or later.
21
21
  ```
22
22
  doctrina init scaffold AGENTS.md + .doctrina/ in cwd
23
23
  doctrina spec new <capability> create a capability spec
24
+ doctrina spec list list specs with version, status, size
24
25
  doctrina change new <id> "<title>" open a change proposal
25
26
  doctrina change apply <id> apply spec deltas (ADDED/REMOVED auto)
26
27
  doctrina change archive <id> archive an applied change
28
+ doctrina change diff <id> preview spec deltas (line diff for MODIFIED)
27
29
  doctrina decision new "<title>" create the next sequential ADR
30
+ doctrina decision accept <num> flip a proposed ADR to accepted
28
31
  doctrina decision supersede <num> "<t>" supersede an existing ADR
32
+ doctrina decision list list ADRs with status, date, title
33
+ doctrina skill new <name> scaffold an on-demand procedural-memory skill
34
+ doctrina skill list list skills with descriptions (read-only)
35
+ doctrina skill sync mirror skill frontmatter descriptions into index.json
29
36
  doctrina analyze <change-id> inspect a change folder before applying
30
- doctrina clarify <path> smell-test a Markdown file for ambiguity
37
+ doctrina clarify <path> smell-test a Markdown file for ambiguity (--all: tree)
38
+ doctrina context [<capability>] print the context pack in read order (--concat)
39
+ doctrina search <term> [...] search artifacts, grouped by category
31
40
  doctrina validate schema + artifact-existence checks
32
41
  doctrina templates list enumerate shipped templates
33
42
  doctrina templates check compare project against recommended template shape
43
+ doctrina templates update additive fixer for check findings (preview; --write applies)
34
44
  doctrina hooks install install pre-commit hook
45
+ doctrina index rebuild regenerate index.json from the files (--check for CI)
46
+ doctrina next print the recommended next workflow actions
47
+ doctrina metrics local git-derived adoption metrics (no network)
35
48
  doctrina --help | -h print the command surface
36
49
  doctrina --version | -v print the package version
37
50
  ```
@@ -44,7 +57,7 @@ Per-command help is available with `doctrina <command> --help`.
44
57
  |------|---------|
45
58
  | `--project-name <name>` | Override the project name (defaults to cwd basename) |
46
59
  | `--project-description <text>` | One-sentence description (prompted if omitted) |
47
- | `--agent <name>` | Install adapter for `claude`, `codex`, `cursor`, or `all` |
60
+ | `--agent <name>` | Install the adapter for one of the twelve supported agents (`claude`, `codex`, `cursor`, `copilot`, `gemini`, `aider`, `windsurf`, `continue`, `amp`, `devin`, `factory`, `jules`) or `all` |
48
61
  | `--date <YYYY-MM-DD>` | Override the system date in artifacts |
49
62
  | `--force` | Overwrite existing files |
50
63
  | `--non-interactive` | Fail instead of prompting for missing required values |
@@ -53,10 +66,12 @@ Per-command help is available with `doctrina <command> --help`.
53
66
 
54
67
  - `--agent claude` writes a `CLAUDE.md` at the project root that
55
68
  `@`-imports `AGENTS.md`. Claude Code picks it up automatically.
56
- - `--agent codex` installs nothing. OpenAI Codex CLI reads `AGENTS.md`
57
- natively.
58
69
  - `--agent cursor` writes `.cursor/rules/00-doctrina.mdc` with
59
- `alwaysApply: true` pointing at `AGENTS.md`.
70
+ `alwaysApply: true` pointing at `AGENTS.md`. `copilot`, `gemini`,
71
+ `aider`, `windsurf`, and `continue` follow the same thin-pointer
72
+ pattern at each agent's native path.
73
+ - `--agent codex` (and `amp`, `devin`, `factory`, `jules`) installs
74
+ nothing: these agents read `AGENTS.md` natively.
60
75
  - `--agent all` installs every adapter.
61
76
 
62
77
  All adapter files are kept under 30 lines to avoid per-turn token tax
@@ -87,6 +102,13 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
87
102
  9. ADRs are ≤ 300 lines (warns above).
88
103
  10. Spec/ADR files on disk are present in `index.json` (orphan warn).
89
104
  11. Markdown links inside specs/ADRs resolve to real files (stale ref warn).
105
+ 12. Skills carry the required frontmatter triple (`name`, `description`, `when`).
106
+ 13. Skills are ≤ 200 lines (warns at > 150).
107
+ 14. Each skill's `name:` matches its filename slug.
108
+ 15. Each spec's `Version:` header matches the version in `index.json` (drift warn).
109
+ 16. Each skill's frontmatter description matches `index.json` (drift warn; `skill sync` fixes).
110
+ 17. EARS grammar shape per section in specs declaring `## Requirements (EARS)` (warn).
111
+ 18. Nested `AGENTS.md` files obey the root size caps (warn > 150, error > 200).
90
112
 
91
113
  Exit code: 0 if no errors (warnings allowed), 1 otherwise.
92
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
- import { exists, isDir, isFile, read, walk } from "../lib/fs-ops.js";
3
+ import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
4
4
  import { c } from "../lib/colors.js";
5
5
 
6
6
  export async function run(positional, _flags) {
@@ -13,11 +13,11 @@ export async function run(positional, _flags) {
13
13
  const projectRoot = process.cwd();
14
14
  const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
15
15
  if (!isDir(changeDir)) {
16
- console.error(c.red("error:") + ` change "${id}" not found at ${path.relative(projectRoot, changeDir)}`);
16
+ console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
17
17
  return 1;
18
18
  }
19
19
 
20
- console.log(`analyzing ${path.relative(projectRoot, changeDir)}/`);
20
+ console.log(`analyzing ${relPath(projectRoot, changeDir)}/`);
21
21
  console.log("");
22
22
 
23
23
  const results = [];
@@ -56,7 +56,7 @@ export async function run(positional, _flags) {
56
56
  } else {
57
57
  results.push(pass(`${deltaFiles.length} spec delta${deltaFiles.length === 1 ? "" : "s"}:`));
58
58
  for (const deltaPath of deltaFiles) {
59
- const rel = path.relative(projectRoot, deltaPath);
59
+ const rel = relPath(projectRoot, deltaPath);
60
60
  const text = read(deltaPath);
61
61
  const opMatch = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
62
62
  const op = opMatch ? opMatch[1] : null;
@@ -72,7 +72,7 @@ export async function run(positional, _flags) {
72
72
  continue;
73
73
  }
74
74
  const targetSpec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
75
- const targetRel = path.relative(projectRoot, targetSpec);
75
+ const targetRel = relPath(projectRoot, targetSpec);
76
76
  if (op === "ADDED") {
77
77
  if (exists(targetSpec)) {
78
78
  results.push(fail(` ${cap} (ADDED) but target ${targetRel} already exists`, " "));
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
- import { exists, isDir, mkdirp, move, read, remove, walk, write } from "../lib/fs-ops.js";
3
+ import { exists, isDir, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
4
+ import { diffLines, formatUnified } from "../lib/diff.js";
4
5
  import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "../lib/templates.js";
5
6
  import * as idx from "../lib/index-json.js";
6
7
  import { today } from "../lib/dates.js";
@@ -8,7 +9,7 @@ import { flagBool } from "../lib/args.js";
8
9
  import { c } from "../lib/colors.js";
9
10
  import { suggest } from "../lib/suggest.js";
10
11
 
11
- const SUBCOMMANDS = ["new", "apply", "archive"];
12
+ const SUBCOMMANDS = ["new", "apply", "archive", "diff"];
12
13
 
13
14
  export async function run(positional, flags) {
14
15
  const sub = positional[0];
@@ -19,6 +20,8 @@ export async function run(positional, flags) {
19
20
  return changeApply(positional.slice(1), flags);
20
21
  case "archive":
21
22
  return changeArchive(positional.slice(1), flags);
23
+ case "diff":
24
+ return changeDiff(positional.slice(1), flags);
22
25
  default:
23
26
  console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
24
27
  const guess = suggest(sub, SUBCOMMANDS);
@@ -47,7 +50,7 @@ function changeNew(args, flags) {
47
50
 
48
51
  const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
49
52
  if (exists(changeDir) && !force) {
50
- console.error(c.red("error:") + ` change "${id}" already exists at ${path.relative(projectRoot, changeDir)}`);
53
+ console.error(c.red("error:") + ` change "${id}" already exists at ${relPath(projectRoot, changeDir)}`);
51
54
  return 1;
52
55
  }
53
56
 
@@ -59,7 +62,7 @@ function changeNew(args, flags) {
59
62
  for (const entry of tree) {
60
63
  if (entry.relativePath === "spec-delta.md.template") continue;
61
64
  const written = materialiseEntry(entry, changeDir, tokens, { force });
62
- console.log(c.green("created") + ` ${path.relative(projectRoot, written)}`);
65
+ console.log(c.green("created") + ` ${relPath(projectRoot, written)}`);
63
66
  }
64
67
  mkdirp(path.join(changeDir, "specs"));
65
68
 
@@ -85,7 +88,7 @@ function changeApply(args, _flags) {
85
88
 
86
89
  const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
87
90
  if (!isDir(changeDir)) {
88
- console.error(c.red("error:") + ` change "${id}" not found at ${path.relative(projectRoot, changeDir)}`);
91
+ console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
89
92
  return 1;
90
93
  }
91
94
 
@@ -100,7 +103,7 @@ function changeApply(args, _flags) {
100
103
  }
101
104
 
102
105
  for (const deltaPath of deltaFiles) {
103
- const rel = path.relative(changeDir, deltaPath);
106
+ const rel = relPath(changeDir, deltaPath);
104
107
  const text = read(deltaPath);
105
108
  const op = parseOperation(text);
106
109
  const capability = parseCapabilityFromDelta(text, deltaPath);
@@ -113,24 +116,24 @@ function changeApply(args, _flags) {
113
116
 
114
117
  if (op === "ADDED") {
115
118
  if (exists(targetPath)) {
116
- console.error(c.red("error:") + ` ADDED delta targets existing spec ${path.relative(projectRoot, targetPath)} — refusing to overwrite`);
119
+ console.error(c.red("error:") + ` ADDED delta targets existing spec ${relPath(projectRoot, targetPath)} — refusing to overwrite`);
117
120
  errors += 1;
118
121
  continue;
119
122
  }
120
123
  const body = extractDeltaBody(text);
121
124
  write(targetPath, body, { force: false });
122
125
  writes += 1;
123
- console.log(c.green("applied[ADDED]") + ` ${path.relative(projectRoot, targetPath)}`);
126
+ console.log(c.green("applied[ADDED]") + ` ${relPath(projectRoot, targetPath)}`);
124
127
  } else if (op === "REMOVED") {
125
128
  if (!exists(targetPath)) {
126
- console.log(c.yellow("warn:") + ` REMOVED delta targets missing spec ${path.relative(projectRoot, targetPath)} — skipping`);
129
+ console.log(c.yellow("warn:") + ` REMOVED delta targets missing spec ${relPath(projectRoot, targetPath)} — skipping`);
127
130
  continue;
128
131
  }
129
132
  remove(targetPath);
130
133
  writes += 1;
131
- console.log(c.green("applied[REMOVED]") + ` ${path.relative(projectRoot, targetPath)}`);
134
+ console.log(c.green("applied[REMOVED]") + ` ${relPath(projectRoot, targetPath)}`);
132
135
  } else if (op === "MODIFIED") {
133
- console.log(c.yellow("manual[MODIFIED]") + ` merge ${path.relative(projectRoot, deltaPath)} into ${path.relative(projectRoot, targetPath)} by hand`);
136
+ console.log(c.yellow("manual[MODIFIED]") + ` merge ${relPath(projectRoot, deltaPath)} into ${relPath(projectRoot, targetPath)} by hand`);
134
137
  manual += 1;
135
138
  } else {
136
139
  console.error(c.red("error:") + ` unknown Operation "${op}" in ${rel}`);
@@ -201,7 +204,7 @@ function changeArchive(args, _flags) {
201
204
 
202
205
  const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
203
206
  if (!isDir(changeDir)) {
204
- console.error(c.red("error:") + ` change "${id}" not found at ${path.relative(projectRoot, changeDir)}`);
207
+ console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
205
208
  return 1;
206
209
  }
207
210
 
@@ -209,11 +212,11 @@ function changeArchive(args, _flags) {
209
212
  const archiveName = `${date}-${id}`;
210
213
  const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive", archiveName);
211
214
  if (exists(archiveDir)) {
212
- console.error(c.red("error:") + ` archive target already exists: ${path.relative(projectRoot, archiveDir)}`);
215
+ console.error(c.red("error:") + ` archive target already exists: ${relPath(projectRoot, archiveDir)}`);
213
216
  return 1;
214
217
  }
215
218
  move(changeDir, archiveDir);
216
- console.log(c.green("archived") + ` ${path.relative(projectRoot, archiveDir)}`);
219
+ console.log(c.green("archived") + ` ${relPath(projectRoot, archiveDir)}`);
217
220
 
218
221
  // Collect affected specs from delta files for the index entry
219
222
  const deltaFiles = walk(path.join(archiveDir, "specs")).filter((p) => p.endsWith("delta.md"));
@@ -228,11 +231,31 @@ function changeArchive(args, _flags) {
228
231
  // Title from proposal
229
232
  const proposal = path.join(archiveDir, "proposal.md");
230
233
  if (exists(proposal)) {
231
- const firstLine = read(proposal).split("\n", 1)[0] ?? "";
232
- const m = firstLine.match(/^#\s+Change\s+[^—-]+[—-]\s*(.+)$/);
234
+ // First line may end in \r on Windows checkouts (autocrlf); split on
235
+ // either ending so the title regex is not defeated by a stray \r.
236
+ const firstLine = read(proposal).split(/\r?\n/, 1)[0] ?? "";
237
+ // The id itself may contain hyphens (NNNN-slug), so match it as \S+
238
+ // and split on the em-dash/hyphen separator that follows whitespace.
239
+ const m = firstLine.match(/^#\s+Change\s+\S+\s*[—-]\s*(.+)$/);
233
240
  if (m) title = m[1].trim();
234
241
  }
235
242
 
243
+ // Append a one-line summary to the archive ledger so history stays
244
+ // scannable without opening folders (the archive is out of the default
245
+ // read path; the ledger is the cheap way back in).
246
+ const ledgerPath = path.join(projectRoot, ".doctrina", "changes", "archive", "LEDGER.md");
247
+ if (!exists(ledgerPath)) {
248
+ write(ledgerPath,
249
+ "# Change ledger\n\n" +
250
+ "One line per archived change, newest last. Appended by\n" +
251
+ "`doctrina change archive`; edit freely, the CLI only appends.\n\n");
252
+ }
253
+ const specsSummary = specsAffected.length > 0
254
+ ? ` (specs: ${specsAffected.map((s) => `${s.capability} ${s.operation}`).join(", ")})`
255
+ : "";
256
+ write(ledgerPath, read(ledgerPath) + `- ${date} — ${id} — ${title}${specsSummary}\n`, { force: true });
257
+ console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerPath)}`);
258
+
236
259
  const index = idx.load(projectRoot);
237
260
  idx.moveChangeToArchive(index, id, {
238
261
  id,
@@ -248,6 +271,74 @@ function changeArchive(args, _flags) {
248
271
  return 0;
249
272
  }
250
273
 
274
+ function changeDiff(args, _flags) {
275
+ const id = args[0];
276
+ if (!id) {
277
+ console.error(c.red("error:") + " change diff requires <id>");
278
+ return 2;
279
+ }
280
+ const projectRoot = process.cwd();
281
+ ensureDoctrinaProject(projectRoot);
282
+
283
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
284
+ if (!isDir(changeDir)) {
285
+ console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
286
+ return 1;
287
+ }
288
+
289
+ const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
290
+ if (deltaFiles.length === 0) {
291
+ console.log(c.gray("no spec deltas in this change; nothing to diff"));
292
+ return 0;
293
+ }
294
+
295
+ let errors = 0;
296
+ for (const deltaPath of deltaFiles) {
297
+ const rel = relPath(changeDir, deltaPath);
298
+ const text = read(deltaPath);
299
+ const op = parseOperation(text);
300
+ const capability = parseCapabilityFromDelta(text, deltaPath);
301
+ if (!op || !capability) {
302
+ console.error(c.red("error:") + ` cannot parse Operation or capability in ${rel}`);
303
+ errors += 1;
304
+ continue;
305
+ }
306
+ const targetPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
307
+ const targetRel = relPath(projectRoot, targetPath);
308
+ const body = extractDeltaBody(text);
309
+ const bodyLines = body.split("\n").filter((l, i, a) => !(i === a.length - 1 && l === "")).length;
310
+
311
+ console.log("");
312
+ if (op === "ADDED") {
313
+ const conflict = exists(targetPath) ? c.red(" (conflict: target already exists)") : "";
314
+ console.log(c.green(`ADDED`) + ` ${targetRel} (+${bodyLines} lines)${conflict}`);
315
+ } else if (op === "REMOVED") {
316
+ if (!exists(targetPath)) {
317
+ console.log(c.yellow("REMOVED") + ` ${targetRel} (target already missing)`);
318
+ } else {
319
+ console.log(c.red("REMOVED") + ` ${targetRel} (-${lineCount(targetPath)} lines)`);
320
+ }
321
+ } else {
322
+ if (!exists(targetPath)) {
323
+ console.error(c.red("error:") + ` MODIFIED delta targets missing spec ${targetRel}`);
324
+ errors += 1;
325
+ continue;
326
+ }
327
+ console.log(c.yellow("MODIFIED") + ` ${targetRel}`);
328
+ console.log(c.gray("note: the delta body is a fragment to merge — '-' lines are current"));
329
+ console.log(c.gray(" spec content absent from the delta, not necessarily removals."));
330
+ const out = formatUnified(diffLines(read(targetPath), body), {
331
+ aLabel: targetRel,
332
+ bLabel: relPath(projectRoot, deltaPath),
333
+ });
334
+ console.log(out);
335
+ }
336
+ }
337
+
338
+ console.log("");
339
+ return errors > 0 ? 1 : 0;
340
+ }
341
+
251
342
  function parseOperation(text) {
252
343
  const m = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
253
344
  if (!m) return null;
@@ -286,7 +377,13 @@ Subcommands:
286
377
  new <id> "<title>" Open a new change proposal at .doctrina/changes/<id>/
287
378
  apply <id> Apply spec deltas (ADDED writes, REMOVED deletes, MODIFIED is manual)
288
379
  archive <id> Move the change to .doctrina/changes/archive/YYYY-MM-DD-<id>/
380
+ diff <id> Preview every spec delta: line diff for MODIFIED,
381
+ summary for ADDED/REMOVED. Read-only.
289
382
 
290
383
  Options:
291
384
  --force Overwrite existing files (where applicable)
292
385
  `;
386
+
387
+ // Re-export parsers so scan.js (index rebuild) can reuse them, and the
388
+ // scaffold so `work` can open a change without duplicating the logic.
389
+ export { parseOperation, parseCapabilityFromDelta, changeNew };
@@ -1,6 +1,8 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
- import { isFile, read } from "../lib/fs-ops.js";
3
+ import { readdirSync } from "node:fs";
4
+ import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
5
+ import { flagBool } from "../lib/args.js";
4
6
  import { c } from "../lib/colors.js";
5
7
 
6
8
  const RULES = [
@@ -25,22 +27,90 @@ const RULES = [
25
27
  },
26
28
  ];
27
29
 
28
- export async function run(positional, _flags) {
30
+ export async function run(positional, flags) {
31
+ const projectRoot = process.cwd();
32
+
33
+ if (flagBool(flags, "all", false)) return clarifyAll(projectRoot);
34
+
29
35
  const target = positional[0];
30
36
  if (!target) {
31
- console.error(c.red("error:") + " clarify requires a path to a Markdown file");
37
+ console.error(c.red("error:") + " clarify requires a path to a Markdown file (or --all)");
32
38
  return 2;
33
39
  }
34
- const projectRoot = process.cwd();
35
40
  const fullPath = path.resolve(projectRoot, target);
36
41
  if (!isFile(fullPath)) {
37
42
  console.error(c.red("error:") + ` file not found: ${target}`);
38
43
  return 1;
39
44
  }
40
45
 
41
- console.log(`clarify ${path.relative(projectRoot, fullPath)}`);
46
+ console.log(`clarify ${relPath(projectRoot, fullPath)}`);
47
+ console.log("");
48
+
49
+ const smells = scanFile(fullPath);
50
+ for (const s of smells) {
51
+ console.log(`${c.yellow("⚠")} line ${s.line}: ${s.rule} "${s.token}" — ${s.hint}`);
52
+ }
53
+
54
+ console.log("");
55
+ if (smells.length === 0) {
56
+ console.log(c.green("ok") + " no smells found");
57
+ return 0;
58
+ }
59
+ const word = smells.length === 1 ? "smell" : "smells";
60
+ console.log(c.red("fail") + ` ${smells.length} ${word}`);
61
+ return 1;
62
+ }
63
+
64
+ // Walk every living document — product, specs, open changes, skills —
65
+ // and clarify each. ADRs are immutable and the archive is history, so
66
+ // neither is included; AGENTS.md is dense command text, a different
67
+ // register from prose specs.
68
+ function clarifyAll(projectRoot) {
69
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
70
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
71
+ }
72
+ const files = [];
73
+ const productPath = path.join(projectRoot, ".doctrina", "product.md");
74
+ if (isFile(productPath)) files.push(productPath);
75
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
76
+ if (isDir(specsDir)) {
77
+ for (const cap of readdirSync(specsDir).sort()) {
78
+ const p = path.join(specsDir, cap, "spec.md");
79
+ if (isFile(p)) files.push(p);
80
+ }
81
+ }
82
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
83
+ if (isDir(changesDir)) {
84
+ for (const id of readdirSync(changesDir).sort()) {
85
+ if (id === "archive" || id.startsWith(".")) continue;
86
+ if (!isDir(path.join(changesDir, id))) continue;
87
+ files.push(...walk(path.join(changesDir, id)).filter((f) => f.endsWith(".md")));
88
+ }
89
+ }
90
+ const skillsDir = path.join(projectRoot, ".doctrina", "skills");
91
+ files.push(...walk(skillsDir).filter((f) => f.endsWith(".md")));
92
+
93
+ let total = 0;
94
+ for (const f of files) {
95
+ const smells = scanFile(f);
96
+ if (smells.length === 0) continue;
97
+ total += smells.length;
98
+ const rel = relPath(projectRoot, f);
99
+ for (const s of smells) {
100
+ console.log(`${c.yellow("⚠")} ${rel}:${s.line}: ${s.rule} "${s.token}" — ${s.hint}`);
101
+ }
102
+ }
103
+
42
104
  console.log("");
105
+ if (total === 0) {
106
+ console.log(c.green("ok") + ` no smells in ${files.length} living document${files.length === 1 ? "" : "s"}`);
107
+ return 0;
108
+ }
109
+ console.log(c.red("fail") + ` ${total} smell${total === 1 ? "" : "s"} across ${files.length} documents`);
110
+ return 1;
111
+ }
43
112
 
113
+ function scanFile(fullPath) {
44
114
  const text = read(fullPath);
45
115
  const skippedRanges = collectSkippedRanges(text);
46
116
  const smells = [];
@@ -80,19 +150,7 @@ export async function run(positional, _flags) {
80
150
  }
81
151
 
82
152
  smells.sort((a, b) => a.line - b.line);
83
-
84
- for (const s of smells) {
85
- console.log(`${c.yellow("⚠")} line ${s.line}: ${s.rule} "${s.token}" — ${s.hint}`);
86
- }
87
-
88
- console.log("");
89
- if (smells.length === 0) {
90
- console.log(c.green("ok") + " no smells found");
91
- return 0;
92
- }
93
- const word = smells.length === 1 ? "smell" : "smells";
94
- console.log(c.red("fail") + ` ${smells.length} ${word}`);
95
- return 1;
153
+ return smells;
96
154
  }
97
155
 
98
156
  // Build ranges to skip: fenced code blocks, HTML comments, inline backticks.
@@ -135,12 +193,17 @@ function findAcceptanceLine(text) {
135
193
 
136
194
  export const help = `
137
195
  Usage: doctrina clarify <path>
196
+ doctrina clarify --all
138
197
 
139
198
  Read a Markdown file and report weasel words, vague quantifiers,
140
199
  placeholders, and empty Acceptance criteria sections. Exits 0 when
141
200
  no smells are found, 1 otherwise. Matches inside fenced code
142
201
  blocks, HTML comments, and inline backtick spans are skipped.
143
202
 
203
+ With --all, every living document is scanned in one pass:
204
+ product.md, capability specs, open changes, and skills. ADRs
205
+ (immutable) and the archive (history) are excluded. CI-friendly.
206
+
144
207
  Intended use: run before \`doctrina change apply\` or before
145
208
  opening a PR. The command never modifies files.
146
209
  `;
@@ -0,0 +1,129 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { readdirSync } from "node:fs";
4
+ import { exists, isDir, isFile, lineCount, read, relPath, walk } from "../lib/fs-ops.js";
5
+ import { listHeader } from "../lib/scan.js";
6
+ import { parseFrontmatter } from "./skill.js";
7
+ import { flagBool } from "../lib/args.js";
8
+ import { c } from "../lib/colors.js";
9
+
10
+ // Materialise the AGENTS.md read order as a command: print the exact
11
+ // context pack for a task, in the order an agent should read it. This is
12
+ // the context-engineering thesis turned into tooling — selection over
13
+ // dumping, ranked by the documented read order.
14
+
15
+ export async function run(positional, flags) {
16
+ const capability = positional[0] ?? null;
17
+ const concat = flagBool(flags, "concat", false);
18
+ const projectRoot = process.cwd();
19
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
20
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
21
+ }
22
+
23
+ if (capability && !/^[a-z][a-z0-9-]*$/.test(capability)) {
24
+ console.error(c.red("error:") + ` invalid capability "${capability}" (lowercase letters, digits, hyphens)`);
25
+ return 2;
26
+ }
27
+
28
+ const pack = [];
29
+ const pushFile = (rel, note) => {
30
+ const full = path.join(projectRoot, rel);
31
+ if (isFile(full)) pack.push({ rel, lines: lineCount(full), note });
32
+ };
33
+
34
+ // 1-2. Root rules and product truth — always first. Displayed paths are
35
+ // POSIX strings (path.join would emit backslashes on Windows).
36
+ pushFile("AGENTS.md", "root rules");
37
+ pushFile(".doctrina/product.md", "product truth");
38
+
39
+ // 3. The capability spec under work.
40
+ let specMissing = false;
41
+ if (capability) {
42
+ const specRel = `.doctrina/specs/${capability}/spec.md`;
43
+ if (isFile(path.join(projectRoot, specRel))) {
44
+ pushFile(specRel, `spec: ${capability}`);
45
+ } else {
46
+ specMissing = true;
47
+ }
48
+ }
49
+
50
+ // 4. Open changes (their proposal, tasks, and deltas).
51
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
52
+ if (isDir(changesDir)) {
53
+ for (const id of readdirSync(changesDir).sort()) {
54
+ if (id === "archive" || id.startsWith(".")) continue;
55
+ if (!isDir(path.join(changesDir, id))) continue;
56
+ for (const f of walk(path.join(changesDir, id))) {
57
+ if (!f.endsWith(".md")) continue;
58
+ pushFile(relPath(projectRoot, f), `open change: ${id}`);
59
+ }
60
+ }
61
+ }
62
+
63
+ // 5. Accepted ADRs only — superseded/withdrawn stay out of the pack.
64
+ const adrDir = path.join(projectRoot, ".doctrina", "decisions");
65
+ for (const f of walk(adrDir)) {
66
+ if (!f.endsWith(".md")) continue;
67
+ const status = (listHeader(read(f), "Status") ?? "").toLowerCase();
68
+ if (status === "accepted") pushFile(relPath(projectRoot, f), "accepted ADR");
69
+ }
70
+
71
+ // 6. Skills are on-demand: list name + description, never the body.
72
+ const onDemand = [];
73
+ const skillsDir = path.join(projectRoot, ".doctrina", "skills");
74
+ for (const f of walk(skillsDir)) {
75
+ if (!f.endsWith(".md")) continue;
76
+ const text = read(f);
77
+ onDemand.push({
78
+ rel: relPath(projectRoot, f),
79
+ name: parseFrontmatter(text, "name") ?? path.basename(f, ".md"),
80
+ description: parseFrontmatter(text, "description") ?? "<missing description>",
81
+ });
82
+ }
83
+
84
+ if (concat) {
85
+ for (const item of pack) {
86
+ console.log(`===== ${item.rel} (${item.note}) =====`);
87
+ console.log("");
88
+ console.log(read(path.join(projectRoot, item.rel)).trimEnd());
89
+ console.log("");
90
+ }
91
+ } else {
92
+ console.log(c.bold("Context pack") + (capability ? c.gray(` (capability: ${capability})`) : "") + c.gray(" — read in this order:"));
93
+ console.log("");
94
+ pack.forEach((item, i) => {
95
+ console.log(`${i + 1}. ${c.cyan(item.rel.padEnd(56))} ${String(item.lines).padStart(4)} lines ${c.gray(item.note)}`);
96
+ });
97
+ const total = pack.reduce((sum, item) => sum + item.lines, 0);
98
+ console.log("");
99
+ console.log(c.gray(`${pack.length} files, ${total} lines total · archive excluded by design`));
100
+ if (onDemand.length > 0) {
101
+ console.log("");
102
+ console.log(c.bold("On-demand skills") + c.gray(" (load the body only when the task matches):"));
103
+ for (const s of onDemand) {
104
+ console.log(` ${c.cyan(s.name.padEnd(24))} ${s.description} ${c.gray(s.rel)}`);
105
+ }
106
+ }
107
+ }
108
+
109
+ if (specMissing) {
110
+ console.error("");
111
+ console.error(c.yellow("warn:") + ` no spec for capability "${capability}" — create one with \`doctrina spec new ${capability}\``);
112
+ }
113
+ return 0;
114
+ }
115
+
116
+ export const help = `
117
+ Usage: doctrina context [<capability>] [--concat]
118
+
119
+ Print the exact context pack for a task, in the documented read
120
+ order: AGENTS.md, product.md, the capability spec (when given), open
121
+ changes, accepted ADRs. Skills are listed name + description only —
122
+ they are on-demand by design. The change archive is excluded.
123
+
124
+ Flags:
125
+ --concat Print the file contents (with separators) instead of the
126
+ file list — ready to pipe into an agent.
127
+
128
+ Read-only; always exits 0.
129
+ `;