doctrina-cli 0.1.1 → 0.2.1

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,190 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { spawnSync } from "node:child_process";
4
+ import { readdirSync } from "node:fs";
5
+ import { exists, isDir, read, relPath, write } from "../lib/fs-ops.js";
6
+ import { today } from "../lib/dates.js";
7
+ import { flagBool, flagString } from "../lib/args.js";
8
+ import { c } from "../lib/colors.js";
9
+
10
+ // Local-only adoption metrics derived from git history. No network calls,
11
+ // no telemetry: the numbers stay in the repository, versioned like any
12
+ // other artifact. This is the tooling half of docs/en/validation.md.
13
+
14
+ const REEDIT_WINDOW_DAYS = 21;
15
+
16
+ export async function run(_positional, flags) {
17
+ const projectRoot = process.cwd();
18
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
19
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
20
+ }
21
+ const probe = git(projectRoot, ["rev-parse", "--is-inside-work-tree"]);
22
+ if (probe.status !== 0) {
23
+ console.error(c.red("error:") + " not a git repository (metrics are derived from local git history)");
24
+ return 1;
25
+ }
26
+
27
+ const sinceRaw = flagString(flags, "since", "90");
28
+ const since = /^\d+$/.test(sinceRaw) ? `${sinceRaw} days ago` : sinceRaw;
29
+ const save = flagBool(flags, "save", false);
30
+
31
+ const log = git(projectRoot, [
32
+ "log", `--since=${since}`, "--date=unix",
33
+ "--pretty=format:@@%H|%ct|%s", "--name-only",
34
+ ]);
35
+ if (log.status !== 0) {
36
+ console.error(c.red("error:") + ` git log failed: ${log.stderr.trim()}`);
37
+ return 1;
38
+ }
39
+
40
+ const commits = parseLog(log.stdout);
41
+ console.log(c.bold("Doctrina metrics") + c.gray(` (local git history, window: ${since})`));
42
+ console.log("");
43
+ if (commits.length === 0) {
44
+ console.log(c.gray("no commits in the window; nothing to measure"));
45
+ return 0;
46
+ }
47
+
48
+ const snapshot = computeSnapshot(commits, since);
49
+ printReport(snapshot);
50
+
51
+ if (save) {
52
+ const dir = path.join(projectRoot, ".doctrina", "metrics");
53
+ const file = path.join(dir, `${today()}.json`);
54
+ const previous = latestSnapshot(dir, path.basename(file));
55
+ write(file, JSON.stringify(snapshot, null, 2) + "\n", { force: true });
56
+ console.log("");
57
+ console.log(c.green("saved") + ` ${relPath(projectRoot, file)}`);
58
+ if (previous) {
59
+ console.log("");
60
+ console.log(c.bold("Δ vs " + previous.generated + ":"));
61
+ printDelta("revert rate", previous.revert_rate, snapshot.revert_rate);
62
+ printDelta("fix share", previous.fix_share, snapshot.fix_share);
63
+ printDelta(`${REEDIT_WINDOW_DAYS}-day re-edit rate`, previous.reedit_rate, snapshot.reedit_rate);
64
+ }
65
+ }
66
+ return 0;
67
+ }
68
+
69
+ function computeSnapshot(commits, since) {
70
+ const total = commits.length;
71
+ const reverts = commits.filter((cm) => /^Revert\b/.test(cm.subject)).length;
72
+ const fixes = commits.filter((cm) => /^fix[(:!]/.test(cm.subject)).length;
73
+
74
+ // File churn: commits per file, top offenders first.
75
+ const churn = new Map();
76
+ for (const cm of commits) {
77
+ for (const f of cm.files) churn.set(f, (churn.get(f) ?? 0) + 1);
78
+ }
79
+ const topChurn = [...churn.entries()]
80
+ .sort((a, b) => b[1] - a[1])
81
+ .slice(0, 5)
82
+ .map(([file, count]) => ({ file, commits: count }));
83
+
84
+ // Re-edit proxy: a commit counts when it touches a file another commit
85
+ // already touched in the previous REEDIT_WINDOW_DAYS. This is a proxy
86
+ // for rework, not a verdict — iterative work also trips it.
87
+ const windowSec = REEDIT_WINDOW_DAYS * 86400;
88
+ const lastTouch = new Map();
89
+ let reedits = 0;
90
+ for (const cm of [...commits].sort((a, b) => a.ts - b.ts)) {
91
+ const isReedit = cm.files.some((f) => {
92
+ const prev = lastTouch.get(f);
93
+ return prev !== undefined && cm.ts - prev <= windowSec;
94
+ });
95
+ if (isReedit) reedits += 1;
96
+ for (const f of cm.files) lastTouch.set(f, cm.ts);
97
+ }
98
+
99
+ return {
100
+ generated: today(),
101
+ window: since,
102
+ commits: total,
103
+ reverts,
104
+ revert_rate: round(reverts / total),
105
+ fix_share: round(fixes / total),
106
+ reedit_window_days: REEDIT_WINDOW_DAYS,
107
+ reedit_rate: round(reedits / total),
108
+ top_churn: topChurn,
109
+ };
110
+ }
111
+
112
+ function printReport(s) {
113
+ console.log(`commits ${s.commits}`);
114
+ console.log(`reverts ${s.reverts} (${pct(s.revert_rate)})`);
115
+ console.log(`fix-typed commits ${pct(s.fix_share)} of commits (Conventional Commits "fix")`);
116
+ console.log(`${s.reedit_window_days}-day re-edit rate ${pct(s.reedit_rate)} of commits touch a file edited in the prior ${s.reedit_window_days} days`);
117
+ console.log(c.gray(" (proxy for rework — iterative work also counts)"));
118
+ if (s.top_churn.length > 0) {
119
+ console.log("");
120
+ console.log("top churn:");
121
+ for (const t of s.top_churn) {
122
+ console.log(` ${String(t.commits).padStart(4)} ${t.file}`);
123
+ }
124
+ }
125
+ }
126
+
127
+ function printDelta(label, before, after) {
128
+ if (before === undefined || after === undefined) return;
129
+ const diff = after - before;
130
+ const arrow = diff > 0 ? c.red(`+${pct(diff)}`) : diff < 0 ? c.green(pct(diff)) : c.gray("±0");
131
+ console.log(` ${label}: ${pct(before)} → ${pct(after)} (${arrow})`);
132
+ }
133
+
134
+ function latestSnapshot(dir, excludeBase) {
135
+ if (!isDir(dir)) return null;
136
+ const files = readdirSync(dir)
137
+ .filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f) && f !== excludeBase)
138
+ .sort();
139
+ if (files.length === 0) return null;
140
+ try {
141
+ return JSON.parse(read(path.join(dir, files[files.length - 1])));
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ function parseLog(stdout) {
148
+ const commits = [];
149
+ let current = null;
150
+ for (const line of stdout.split("\n")) {
151
+ if (line.startsWith("@@")) {
152
+ const [hash, ts, ...rest] = line.slice(2).split("|");
153
+ current = { hash, ts: Number(ts), subject: rest.join("|"), files: [] };
154
+ commits.push(current);
155
+ } else if (line.trim() !== "" && current) {
156
+ current.files.push(line.trim());
157
+ }
158
+ }
159
+ return commits;
160
+ }
161
+
162
+ function git(cwd, args) {
163
+ return spawnSync("git", args, { cwd, encoding: "utf8" });
164
+ }
165
+
166
+ function round(x) {
167
+ return Math.round(x * 1000) / 1000;
168
+ }
169
+
170
+ function pct(x) {
171
+ return `${(x * 100).toFixed(1)}%`;
172
+ }
173
+
174
+ export const help = `
175
+ Usage: doctrina metrics [--since <days|date>] [--save]
176
+
177
+ Derive adoption metrics from LOCAL git history: commit count, revert
178
+ count and rate, Conventional-Commit fix share, top-churn files, and a
179
+ ${REEDIT_WINDOW_DAYS}-day re-edit proxy rate. No network calls; nothing leaves the repo.
180
+
181
+ Flags:
182
+ --since <n|date> Window: a day count (default 90) or any git-parseable
183
+ date ("2026-01-01", "3 months ago").
184
+ --save Write .doctrina/metrics/YYYY-MM-DD.json and print the
185
+ deltas against the most recent prior snapshot.
186
+
187
+ This is the tooling half of the empirical A/B protocol in
188
+ docs/en/validation.md: snapshot before adopting Doctrina, snapshot each
189
+ month after, compare.
190
+ `;
@@ -0,0 +1,108 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { readdirSync } from "node:fs";
4
+ import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
5
+ import * as idx from "../lib/index-json.js";
6
+ import { deriveIndex, indexesMatch, listHeader } from "../lib/scan.js";
7
+ import { c } from "../lib/colors.js";
8
+
9
+ export async function run(_positional, _flags) {
10
+ const projectRoot = process.cwd();
11
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
12
+ console.log("1. doctrina init — this directory is not a Doctrina project yet");
13
+ return 0;
14
+ }
15
+
16
+ const actions = [];
17
+
18
+ // A pending intake is the very first thing to resolve: until it is
19
+ // converted, product.md and the specs are still empty scaffolding.
20
+ const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
21
+ if (isFile(intakePath)) {
22
+ const status = (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase();
23
+ if (status !== "converted") {
24
+ actions.push("doctrina intake — a pending intake awaits conversion into product.md and specs");
25
+ }
26
+ }
27
+
28
+ // Open changes drive the loop: finish what is started before opening more.
29
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
30
+ if (isDir(changesDir)) {
31
+ for (const id of readdirSync(changesDir).sort()) {
32
+ if (id === "archive" || id.startsWith(".")) continue;
33
+ if (!isDir(path.join(changesDir, id))) continue;
34
+ const proposalPath = path.join(changesDir, id, "proposal.md");
35
+ if (!isFile(proposalPath)) {
36
+ actions.push(`add a proposal.md to .doctrina/changes/${id}/ (open changes need one)`);
37
+ continue;
38
+ }
39
+ const status = listHeader(read(proposalPath), "Status") ?? "proposed";
40
+ if (status === "applied") {
41
+ actions.push(`doctrina change archive ${id} — applied but not archived`);
42
+ continue;
43
+ }
44
+ const tasksPath = path.join(changesDir, id, "tasks.md");
45
+ const unchecked = isFile(tasksPath)
46
+ ? (read(tasksPath).match(/^-\s+\[ \]/gm) ?? []).length
47
+ : 0;
48
+ if (unchecked > 0) {
49
+ actions.push(`complete ${unchecked} open task${unchecked === 1 ? "" : "s"} in .doctrina/changes/${id}/tasks.md`);
50
+ continue;
51
+ }
52
+ const deltas = walk(path.join(changesDir, id, "specs")).filter((p) => p.endsWith("delta.md"));
53
+ if (deltas.length > 0) {
54
+ actions.push(`doctrina analyze ${id}, then doctrina change apply ${id} — tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`);
55
+ } else {
56
+ actions.push(`add spec deltas under .doctrina/changes/${id}/specs/, or apply as metadata-only: doctrina change apply ${id}`);
57
+ }
58
+ }
59
+ }
60
+
61
+ // ADRs stuck in proposed need a human decision.
62
+ const adrDir = path.join(projectRoot, ".doctrina", "decisions");
63
+ for (const f of walk(adrDir)) {
64
+ if (!f.endsWith(".md")) continue;
65
+ const m = path.basename(f).match(/^(\d{4})-/);
66
+ if (!m) continue;
67
+ const status = listHeader(read(f), "Status");
68
+ if (status && status.toLowerCase() === "proposed") {
69
+ actions.push(`review ADR ${m[1]} (${relPath(projectRoot, f)}): doctrina decision accept ${m[1]}, or supersede it`);
70
+ }
71
+ }
72
+
73
+ // Index drift is silent rot; surface it last.
74
+ try {
75
+ const current = idx.load(projectRoot);
76
+ if (!indexesMatch(deriveIndex(projectRoot, current), current)) {
77
+ actions.push("doctrina index rebuild — index.json has drifted from the tree");
78
+ }
79
+ } catch {
80
+ actions.push("doctrina index rebuild — index.json is missing or unreadable");
81
+ }
82
+
83
+ if (actions.length === 0) {
84
+ console.log(c.green("ok") + " no open work.");
85
+ console.log("");
86
+ console.log("Start something:");
87
+ console.log(` doctrina change new <id> "<title>" open a change proposal`);
88
+ console.log(` doctrina spec new <capability> spec a new capability`);
89
+ return 0;
90
+ }
91
+
92
+ console.log(c.bold("Next actions") + c.gray(" (in priority order):"));
93
+ console.log("");
94
+ actions.forEach((a, i) => console.log(`${i + 1}. ${a}`));
95
+ return 0;
96
+ }
97
+
98
+ export const help = `
99
+ Usage: doctrina next
100
+
101
+ Inspect the .doctrina/ tree and print the recommended next workflow
102
+ actions in priority order: open changes (missing proposal, unchecked
103
+ tasks, deltas ready to apply, applied-but-unarchived), ADRs still in
104
+ proposed status, and index drift. Read-only; always exits 0.
105
+
106
+ Intended use: agents and humans run it to resume work without
107
+ re-reading the whole tree.
108
+ `;
@@ -0,0 +1,93 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { exists, isDir, read, relPath, walk } from "../lib/fs-ops.js";
4
+ import { flagBool } from "../lib/args.js";
5
+ import { c } from "../lib/colors.js";
6
+
7
+ // Category-aware full-text search across the artifact tree. Answers
8
+ // "where is X specified / decided / proposed?" without knowing the
9
+ // layout. Plain substring matching, every term must hit (AND).
10
+
11
+ const MAX_MATCHES_PER_FILE = 5;
12
+
13
+ export async function run(positional, flags) {
14
+ const terms = positional.map((t) => t.toLowerCase()).filter(Boolean);
15
+ if (terms.length === 0) {
16
+ console.error(c.red("error:") + " search requires at least one term");
17
+ return 2;
18
+ }
19
+ const includeArchive = flagBool(flags, "archive", false);
20
+ const projectRoot = process.cwd();
21
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
22
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
23
+ }
24
+
25
+ const categories = [
26
+ { name: "specs", dir: path.join(".doctrina", "specs") },
27
+ { name: "decisions", dir: path.join(".doctrina", "decisions") },
28
+ { name: "changes", dir: path.join(".doctrina", "changes"), skip: (rel) => !includeArchive && rel.includes("/archive/") },
29
+ { name: "skills", dir: path.join(".doctrina", "skills") },
30
+ { name: "product", file: path.join(".doctrina", "product.md") },
31
+ { name: "rules", file: "AGENTS.md" },
32
+ ];
33
+
34
+ let totalMatches = 0;
35
+ let filesWithMatches = 0;
36
+
37
+ for (const cat of categories) {
38
+ const files = cat.file
39
+ ? [path.join(projectRoot, cat.file)].filter(exists)
40
+ : isDir(path.join(projectRoot, cat.dir))
41
+ ? walk(path.join(projectRoot, cat.dir)).filter((f) => f.endsWith(".md"))
42
+ : [];
43
+
44
+ const findings = [];
45
+ for (const f of files) {
46
+ const rel = relPath(projectRoot, f);
47
+ if (cat.skip && cat.skip(rel)) continue;
48
+ const lines = read(f).split("\n");
49
+ const hits = [];
50
+ for (let i = 0; i < lines.length; i++) {
51
+ const low = lines[i].toLowerCase();
52
+ if (terms.every((t) => low.includes(t))) {
53
+ hits.push({ line: i + 1, text: lines[i].trim() });
54
+ if (hits.length === MAX_MATCHES_PER_FILE) break;
55
+ }
56
+ }
57
+ if (hits.length > 0) findings.push({ rel, hits });
58
+ }
59
+
60
+ if (findings.length === 0) continue;
61
+ console.log(c.bold(cat.name));
62
+ for (const file of findings) {
63
+ filesWithMatches += 1;
64
+ for (const h of file.hits) {
65
+ totalMatches += 1;
66
+ console.log(` ${c.cyan(file.rel)}:${h.line}: ${truncate(h.text, 100)}`);
67
+ }
68
+ }
69
+ console.log("");
70
+ }
71
+
72
+ if (totalMatches === 0) {
73
+ console.log(c.gray(`no matches for "${positional.join(" ")}"${includeArchive ? "" : " (archive excluded; pass --archive to include it)"}`));
74
+ return 1;
75
+ }
76
+ console.log(c.gray(`${totalMatches} match${totalMatches === 1 ? "" : "es"} in ${filesWithMatches} file${filesWithMatches === 1 ? "" : "s"}`));
77
+ return 0;
78
+ }
79
+
80
+ function truncate(s, n) {
81
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
82
+ }
83
+
84
+ export const help = `
85
+ Usage: doctrina search <term> [<term> ...] [--archive]
86
+
87
+ Case-insensitive substring search across the artifact tree, grouped
88
+ by category (specs, decisions, changes, skills, product, AGENTS.md).
89
+ Every term must match on the same line. The change archive is
90
+ excluded unless --archive is passed.
91
+
92
+ Exits 0 when matches are found, 1 otherwise. Read-only.
93
+ `;
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
- import { exists, isDir, mkdirp, read, walk, write } from "../lib/fs-ops.js";
3
+ import { exists, isDir, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
4
4
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
5
5
  import * as idx from "../lib/index-json.js";
6
6
  import { today } from "../lib/dates.js";
@@ -8,7 +8,7 @@ import { flagBool } from "../lib/args.js";
8
8
  import { c } from "../lib/colors.js";
9
9
  import { suggest } from "../lib/suggest.js";
10
10
 
11
- const SUBCOMMANDS = ["new", "list"];
11
+ const SUBCOMMANDS = ["new", "list", "sync"];
12
12
 
13
13
  export async function run(positional, flags) {
14
14
  const sub = positional[0];
@@ -21,6 +21,7 @@ export async function run(positional, flags) {
21
21
  return 2;
22
22
  }
23
23
  if (sub === "new") return skillNew(positional.slice(1), flags);
24
+ if (sub === "sync") return skillSync();
24
25
  return skillList();
25
26
  }
26
27
 
@@ -38,7 +39,7 @@ function skillNew(args, flags) {
38
39
  mkdirp(skillsDir);
39
40
  const targetPath = path.join(skillsDir, `${name}.md`);
40
41
  if (exists(targetPath) && !force) {
41
- console.error(c.red("error:") + ` ${path.relative(projectRoot, targetPath)} already exists (pass --force to overwrite)`);
42
+ console.error(c.red("error:") + ` ${relPath(projectRoot, targetPath)} already exists (pass --force to overwrite)`);
42
43
  return 1;
43
44
  }
44
45
 
@@ -46,7 +47,7 @@ function skillNew(args, flags) {
46
47
  const tplPath = path.join(templatesDir, "skill.md.template");
47
48
  const body = substitute(read(tplPath), { SKILL_NAME: name });
48
49
  write(targetPath, body, { force });
49
- console.log(c.green("created") + ` ${path.relative(projectRoot, targetPath)}`);
50
+ console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}`);
50
51
 
51
52
  const date = today();
52
53
  const index = idx.load(projectRoot);
@@ -60,7 +61,56 @@ function skillNew(args, flags) {
60
61
  idx.save(projectRoot, index);
61
62
  console.log(c.green("indexed") + ` skill "${name}"`);
62
63
  console.log("");
63
- console.log(c.gray("hint: ") + "fill the description and when frontmatter fields, then update index.json description by hand");
64
+ console.log(c.gray("hint: ") + "fill the description and when frontmatter fields, then run `doctrina skill sync` to update index.json");
65
+ return 0;
66
+ }
67
+
68
+ function skillSync() {
69
+ const projectRoot = process.cwd();
70
+ ensureDoctrinaProject(projectRoot);
71
+
72
+ const skillsDir = path.join(projectRoot, ".doctrina", "skills");
73
+ if (!isDir(skillsDir)) {
74
+ console.log(c.gray("no skills directory; nothing to sync"));
75
+ return 0;
76
+ }
77
+
78
+ const files = walk(skillsDir).filter((f) => f.endsWith(".md"));
79
+ const index = idx.load(projectRoot);
80
+ const date = today();
81
+ let synced = 0;
82
+
83
+ for (const f of files) {
84
+ const id = path.basename(f, ".md");
85
+ const desc = parseFrontmatter(read(f), "description");
86
+ if (!desc) {
87
+ console.log(c.yellow("skip ") + ` ${id} (no description frontmatter)`);
88
+ continue;
89
+ }
90
+ const entry = (index.artifacts.skills ?? []).find((s) => s.id === id);
91
+ if (!entry) {
92
+ idx.addSkill(index, {
93
+ id,
94
+ path: `.doctrina/skills/${id}.md`,
95
+ description: desc,
96
+ last_updated: date,
97
+ });
98
+ console.log(c.green("indexed") + ` ${id}`);
99
+ synced += 1;
100
+ } else if (entry.description !== desc) {
101
+ entry.description = desc;
102
+ entry.last_updated = date;
103
+ console.log(c.green("synced ") + ` ${id}`);
104
+ synced += 1;
105
+ }
106
+ }
107
+
108
+ if (synced > 0) {
109
+ idx.touch(index, date);
110
+ idx.save(projectRoot, index);
111
+ }
112
+ console.log("");
113
+ console.log(c.green("ok") + ` ${synced} skill${synced === 1 ? "" : "s"} synced, ${files.length - synced} already up to date`);
64
114
  return 0;
65
115
  }
66
116
 
@@ -120,6 +170,9 @@ Subcommands:
120
170
  it. Name must match [a-z][a-z0-9-]*.
121
171
  list Print one line per skill with name + description.
122
172
  Read-only.
173
+ sync Copy each skill's frontmatter description into
174
+ .doctrina/index.json so the index never drifts
175
+ from the frontmatter (single source of truth).
123
176
 
124
177
  Skills are written by humans, not generated. See docs/en/skills.md
125
178
  for the design rationale and the distinction from specs / AGENTS.md /
@@ -1,14 +1,16 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
- import { exists, read, write } from "../lib/fs-ops.js";
3
+ import { readdirSync } from "node:fs";
4
+ import { exists, isDir, isFile, lineCount, read, relPath, write } from "../lib/fs-ops.js";
4
5
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
6
+ import { specHeader } from "../lib/scan.js";
5
7
  import * as idx from "../lib/index-json.js";
6
8
  import { today } from "../lib/dates.js";
7
9
  import { flagBool } from "../lib/args.js";
8
10
  import { c } from "../lib/colors.js";
9
11
  import { suggest } from "../lib/suggest.js";
10
12
 
11
- const SUBCOMMANDS = ["new"];
13
+ const SUBCOMMANDS = ["new", "list"];
12
14
 
13
15
  export async function run(positional, flags) {
14
16
  const sub = positional[0];
@@ -20,6 +22,7 @@ export async function run(positional, flags) {
20
22
  : `available: ${SUBCOMMANDS.join(", ")}`));
21
23
  return 2;
22
24
  }
25
+ if (sub === "list") return specList();
23
26
 
24
27
  const capability = positional[1];
25
28
  if (!capability || !/^[a-z][a-z0-9-]*$/.test(capability)) {
@@ -35,7 +38,7 @@ export async function run(positional, flags) {
35
38
  const targetDir = path.join(projectRoot, ".doctrina", "specs", capability);
36
39
  const targetPath = path.join(targetDir, "spec.md");
37
40
  if (exists(targetPath) && !force) {
38
- console.error(c.red("error:") + ` ${path.relative(projectRoot, targetPath)} already exists (pass --force to overwrite)`);
41
+ console.error(c.red("error:") + ` ${relPath(projectRoot, targetPath)} already exists (pass --force to overwrite)`);
39
42
  return 1;
40
43
  }
41
44
 
@@ -45,7 +48,7 @@ export async function run(positional, flags) {
45
48
  const date = today();
46
49
  const body = substitute(read(tplPath), { CAPABILITY: capability, DATE: date });
47
50
  write(targetPath, body, { force });
48
- console.log(c.green("created") + ` ${path.relative(projectRoot, targetPath)}${bug ? " (bug-spec shape)" : ""}`);
51
+ console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}${bug ? " (bug-spec shape)" : ""}`);
49
52
 
50
53
  const index = idx.load(projectRoot);
51
54
  idx.addSpec(index, {
@@ -61,6 +64,40 @@ export async function run(positional, flags) {
61
64
  return 0;
62
65
  }
63
66
 
67
+ function specList() {
68
+ const projectRoot = process.cwd();
69
+ ensureDoctrinaProject(projectRoot);
70
+
71
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
72
+ const rows = [];
73
+ if (isDir(specsDir)) {
74
+ for (const cap of readdirSync(specsDir).sort()) {
75
+ const p = path.join(specsDir, cap, "spec.md");
76
+ if (!isFile(p)) continue;
77
+ const text = read(p);
78
+ rows.push({
79
+ id: cap,
80
+ version: specHeader(text, "Version") ?? "—",
81
+ status: specHeader(text, "Status") ?? "active",
82
+ lines: lineCount(p),
83
+ updated: specHeader(text, "Last updated") ?? "—",
84
+ });
85
+ }
86
+ }
87
+ if (rows.length === 0) {
88
+ console.log(c.gray("no specs found in .doctrina/specs/"));
89
+ return 0;
90
+ }
91
+ console.log(c.bold("Specs:"));
92
+ console.log("");
93
+ for (const r of rows) {
94
+ console.log(` ${c.cyan(r.id.padEnd(20))} ${r.version.padEnd(8)} ${r.status.padEnd(10)} ${String(r.lines).padStart(4)} lines ${r.updated}`);
95
+ }
96
+ console.log("");
97
+ console.log(c.gray(`${rows.length} spec${rows.length === 1 ? "" : "s"}`));
98
+ return 0;
99
+ }
100
+
64
101
  function ensureDoctrinaProject(projectRoot) {
65
102
  if (!exists(path.join(projectRoot, ".doctrina"))) {
66
103
  throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
@@ -68,10 +105,15 @@ function ensureDoctrinaProject(projectRoot) {
68
105
  }
69
106
 
70
107
  export const help = `
71
- Usage: doctrina spec new <capability> [--bug] [--force]
108
+ Usage: doctrina spec <subcommand>
72
109
 
73
- Create a new capability spec under .doctrina/specs/<capability>/spec.md
74
- and add an entry to .doctrina/index.json.
110
+ Subcommands:
111
+ new <capability> [--bug] [--force] Create a capability spec under
112
+ .doctrina/specs/<capability>/spec.md
113
+ and index it.
114
+ list One line per spec: id, version,
115
+ status, line count, last updated.
116
+ Read-only.
75
117
 
76
118
  Options:
77
119
  --bug Scaffold the bug-shape template (current / expected /