doctrina-cli 0.2.1 → 0.3.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
@@ -3,7 +3,7 @@
3
3
  > CLI for the Doctrina framework. The package is published as
4
4
  > `doctrina-cli`; the executable it installs is `doctrina`.
5
5
 
6
- **Status:** v0.1.0 — first working release. Zero runtime dependencies
6
+ **Status:** v0.3.0 — current release. Zero runtime dependencies
7
7
  (Node.js standard library only).
8
8
 
9
9
  ## Install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -193,7 +193,7 @@ function changeApply(args, _flags) {
193
193
  return errors > 0 ? 1 : 0;
194
194
  }
195
195
 
196
- function changeArchive(args, _flags) {
196
+ function changeArchive(args, flags) {
197
197
  const id = args[0];
198
198
  if (!id) {
199
199
  console.error(c.red("error:") + " change archive requires <id>");
@@ -208,6 +208,28 @@ function changeArchive(args, _flags) {
208
208
  return 1;
209
209
  }
210
210
 
211
+ // Verification gate. "Done" is a claim until it is checked. Archiving is
212
+ // the act of declaring a change finished, so it must refuse while any
213
+ // task (including closing steps) or any declared verification item is
214
+ // still unchecked. This is the difference between "boxes marked" and
215
+ // "verification passed" the framework was faulted for collapsing.
216
+ // --force is the escape hatch: it archives anyway and records the gap.
217
+ const force = flagBool(flags, "force", false);
218
+ const blockers = collectArchiveBlockers(changeDir);
219
+ if (blockers.length > 0) {
220
+ if (!force) {
221
+ console.error(c.red("error:") + ` refusing to archive "${id}" — verification incomplete:`);
222
+ for (const b of blockers) console.error(` - ${b}`);
223
+ console.error(
224
+ c.gray("hint: ") +
225
+ "finish and check the items, or pass --force to archive anyway (records the gap)",
226
+ );
227
+ return 1;
228
+ }
229
+ console.log(c.yellow("warn:") + ` archiving "${id}" with verification incomplete (--force):`);
230
+ for (const b of blockers) console.log(c.yellow(" - ") + b);
231
+ }
232
+
211
233
  const date = today();
212
234
  const archiveName = `${date}-${id}`;
213
235
  const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive", archiveName);
@@ -364,6 +386,45 @@ function extractDeltaBody(text) {
364
386
  return text.slice(idxSep + 5).replace(/^\n+/, "");
365
387
  }
366
388
 
389
+ // Reasons a change is not finished enough to archive. Counts unchecked
390
+ // GitHub-style checkboxes (`- [ ]`) in tasks.md (every task, including the
391
+ // closing steps) and in the proposal's "## Verification" section. Returns
392
+ // a list of human-readable blocker strings; empty means clear to archive.
393
+ function collectArchiveBlockers(changeDir) {
394
+ const blockers = [];
395
+ const countUnchecked = (s) => (s.match(/^\s*-\s*\[ \]/gm) ?? []).length;
396
+
397
+ const tasksPath = path.join(changeDir, "tasks.md");
398
+ if (exists(tasksPath)) {
399
+ const n = countUnchecked(read(tasksPath));
400
+ if (n > 0) blockers.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md (closing steps count)`);
401
+ }
402
+
403
+ const proposalPath = path.join(changeDir, "proposal.md");
404
+ if (exists(proposalPath)) {
405
+ const n = countUnchecked(extractSection(read(proposalPath), "Verification"));
406
+ if (n > 0) blockers.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
407
+ }
408
+ return blockers;
409
+ }
410
+
411
+ // Return the body of a "## <name>" section (lines after the heading up to
412
+ // the next "## " heading). Empty string when the section is absent.
413
+ function extractSection(text, name) {
414
+ const lines = text.split(/\r?\n/);
415
+ const head = new RegExp(`^##\\s+${name}\\b`, "i");
416
+ let inSection = false;
417
+ const out = [];
418
+ for (const line of lines) {
419
+ if (/^##\s+/.test(line)) {
420
+ inSection = head.test(line);
421
+ continue;
422
+ }
423
+ if (inSection) out.push(line);
424
+ }
425
+ return out.join("\n");
426
+ }
427
+
367
428
  function ensureDoctrinaProject(projectRoot) {
368
429
  if (!exists(path.join(projectRoot, ".doctrina"))) {
369
430
  throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
@@ -0,0 +1,254 @@
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, write } from "../lib/fs-ops.js";
5
+ import { locateTemplatesDir, substitute } from "../lib/templates.js";
6
+ import { specHeader } from "../lib/scan.js";
7
+ import * as idx from "../lib/index-json.js";
8
+ import { today } from "../lib/dates.js";
9
+ import { flagBool } from "../lib/args.js";
10
+ import { c } from "../lib/colors.js";
11
+ import { suggest } from "../lib/suggest.js";
12
+
13
+ // Contracts are the first-class home for the integration/runtime surface
14
+ // no single capability owns: the port map, the env/dependency contract,
15
+ // and the API/WS/event interface shapes. The criticism this answers: each
16
+ // capability was built against its own spec in isolation, and the seams
17
+ // between them fell in the gap with nobody accountable. `contract check`
18
+ // turns the parts that are mechanically verifiable (port collisions, env
19
+ // drift vs .env.example, referenced specs that must exist) into a gate.
20
+
21
+ const SUBCOMMANDS = ["new", "list", "check"];
22
+
23
+ export async function run(positional, flags) {
24
+ const sub = positional[0];
25
+ switch (sub) {
26
+ case "new":
27
+ return contractNew(positional.slice(1), flags);
28
+ case "list":
29
+ return contractList();
30
+ case "check":
31
+ return contractCheck(positional.slice(1), flags);
32
+ default:
33
+ console.error(c.red("error:") + ` unknown contract subcommand "${sub ?? ""}"`);
34
+ const guess = suggest(sub, SUBCOMMANDS);
35
+ console.error(c.gray("hint: ") + (guess
36
+ ? `did you mean \`doctrina contract ${guess}\`?`
37
+ : `available: ${SUBCOMMANDS.join(", ")}`));
38
+ return 2;
39
+ }
40
+ }
41
+
42
+ function contractNew(args, flags) {
43
+ const name = args[0];
44
+ if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) {
45
+ console.error(c.red("error:") + " contract name must be lowercase letters, digits, or hyphens (e.g. \"system\", \"api\").");
46
+ return 2;
47
+ }
48
+ const force = flagBool(flags, "force", false);
49
+ const projectRoot = process.cwd();
50
+ ensureDoctrinaProject(projectRoot);
51
+
52
+ const targetPath = path.join(projectRoot, ".doctrina", "contracts", `${name}.md`);
53
+ if (exists(targetPath) && !force) {
54
+ console.error(c.red("error:") + ` ${relPath(projectRoot, targetPath)} already exists (pass --force to overwrite)`);
55
+ return 1;
56
+ }
57
+
58
+ const templatesDir = locateTemplatesDir();
59
+ const date = today();
60
+ const body = substitute(read(path.join(templatesDir, "contract.md.template")), { CONTRACT_NAME: name, DATE: date });
61
+ write(targetPath, body, { force });
62
+ console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}`);
63
+
64
+ const index = idx.load(projectRoot);
65
+ idx.addContract(index, {
66
+ id: name,
67
+ path: `.doctrina/contracts/${name}.md`,
68
+ status: "active",
69
+ last_updated: date,
70
+ });
71
+ idx.touch(index, date);
72
+ idx.save(projectRoot, index);
73
+ console.log(c.green("indexed") + ` contract "${name}"`);
74
+ console.log("");
75
+ console.log("Fill in the Ports / Environment / Interfaces tables, then run " + c.cyan("doctrina contract check") + ".");
76
+ return 0;
77
+ }
78
+
79
+ function contractList() {
80
+ const projectRoot = process.cwd();
81
+ ensureDoctrinaProject(projectRoot);
82
+ const dir = path.join(projectRoot, ".doctrina", "contracts");
83
+ const rows = [];
84
+ if (isDir(dir)) {
85
+ for (const f of readdirSync(dir).sort()) {
86
+ if (!f.endsWith(".md")) continue;
87
+ const text = read(path.join(dir, f));
88
+ rows.push({ id: f.replace(/\.md$/, ""), status: specHeader(text, "Status") ?? "active", updated: specHeader(text, "Last updated") ?? "—" });
89
+ }
90
+ }
91
+ if (rows.length === 0) {
92
+ console.log(c.gray("no contracts found in .doctrina/contracts/ (create one with `doctrina contract new <name>`)"));
93
+ return 0;
94
+ }
95
+ console.log(c.bold("Contracts:"));
96
+ console.log("");
97
+ for (const r of rows) console.log(` ${c.cyan(r.id.padEnd(20))} ${r.status.padEnd(10)} ${r.updated}`);
98
+ console.log("");
99
+ console.log(c.gray(`${rows.length} contract${rows.length === 1 ? "" : "s"}`));
100
+ return 0;
101
+ }
102
+
103
+ function contractCheck(args, _flags) {
104
+ const projectRoot = process.cwd();
105
+ ensureDoctrinaProject(projectRoot);
106
+ const dir = path.join(projectRoot, ".doctrina", "contracts");
107
+
108
+ let names;
109
+ if (args[0]) {
110
+ names = [args[0].replace(/\.md$/, "")];
111
+ } else if (isDir(dir)) {
112
+ names = readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, "")).sort();
113
+ } else {
114
+ names = [];
115
+ }
116
+ if (names.length === 0) {
117
+ console.log(c.gray("no contracts to check in .doctrina/contracts/"));
118
+ return 0;
119
+ }
120
+
121
+ // The env contract is checked against .env.example when one exists.
122
+ const envExamplePath = path.join(projectRoot, ".env.example");
123
+ const envExample = isFile(envExamplePath) ? read(envExamplePath) : null;
124
+
125
+ let errors = 0;
126
+ let warnings = 0;
127
+ for (const name of names) {
128
+ const file = path.join(dir, `${name}.md`);
129
+ if (!isFile(file)) {
130
+ console.log(c.red("error: ") + `contract "${name}" not found at ${relPath(projectRoot, file)}`);
131
+ errors += 1;
132
+ continue;
133
+ }
134
+ const text = read(file);
135
+ console.log(c.bold(name) + c.gray(` (${relPath(projectRoot, file)})`));
136
+
137
+ // 1. Port collisions — two services must not claim the same port.
138
+ const ports = parseTable(sectionOf(text, "Ports"));
139
+ if (ports) {
140
+ const portCol = colIndex(ports.headers, "port");
141
+ const svcCol = colIndex(ports.headers, "service");
142
+ const seen = new Map();
143
+ for (const row of ports.rows) {
144
+ const port = (row[portCol] ?? "").trim();
145
+ if (!/^\d+$/.test(port)) continue;
146
+ const svc = (row[svcCol] ?? "?").trim();
147
+ if (seen.has(port)) {
148
+ console.log(c.red(" ✗ ") + `port ${port} is claimed by both "${seen.get(port)}" and "${svc}"`);
149
+ errors += 1;
150
+ } else {
151
+ seen.set(port, svc);
152
+ }
153
+ }
154
+ }
155
+
156
+ // 2. Environment drift — every declared variable must exist in
157
+ // .env.example (when present), so code, example, and infra agree.
158
+ const env = parseTable(sectionOf(text, "Environment"));
159
+ if (env && envExample !== null) {
160
+ const varCol = colIndex(env.headers, "variable");
161
+ for (const row of env.rows) {
162
+ const name2 = (row[varCol] ?? "").trim();
163
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name2)) continue;
164
+ const declared = new RegExp(`^\\s*(export\\s+)?${name2}\\s*=`, "m").test(envExample);
165
+ if (!declared) {
166
+ console.log(c.yellow(" ! ") + `env var ${name2} is in the contract but absent from .env.example`);
167
+ warnings += 1;
168
+ }
169
+ }
170
+ }
171
+
172
+ // 3. Referenced capability specs must exist.
173
+ for (const refCap of referencedCapabilities(sectionOf(text, "References"))) {
174
+ const specPath = path.join(projectRoot, ".doctrina", "specs", refCap, "spec.md");
175
+ if (!isFile(specPath)) {
176
+ console.log(c.red(" ✗ ") + `references spec "${refCap}" but ${relPath(projectRoot, specPath)} does not exist`);
177
+ errors += 1;
178
+ }
179
+ }
180
+ }
181
+
182
+ console.log("");
183
+ if (errors === 0 && warnings === 0) {
184
+ console.log(c.green("ok") + ` ${names.length} contract${names.length === 1 ? "" : "s"} consistent`);
185
+ return 0;
186
+ }
187
+ const summary = `${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}`;
188
+ console.log((errors === 0 ? c.green("ok") : c.red("fail")) + " " + summary);
189
+ return errors === 0 ? 0 : 1;
190
+ }
191
+
192
+ // Return the body of a "## <name>" section, up to the next "## " heading.
193
+ function sectionOf(text, name) {
194
+ const lines = text.split(/\r?\n/);
195
+ const head = new RegExp(`^##\\s+${name}\\b`, "i");
196
+ let inSection = false;
197
+ const out = [];
198
+ for (const line of lines) {
199
+ if (/^##\s+/.test(line)) {
200
+ inSection = head.test(line);
201
+ continue;
202
+ }
203
+ if (inSection) out.push(line);
204
+ }
205
+ return out.join("\n");
206
+ }
207
+
208
+ // Parse a GitHub-flavoured Markdown table into { headers, rows }.
209
+ function parseTable(text) {
210
+ const lines = text.split(/\r?\n/).filter((l) => /^\s*\|/.test(l));
211
+ if (lines.length < 2) return null;
212
+ const cells = (line) => line.trim().replace(/^\||\|$/g, "").split("|").map((cl) => cl.trim());
213
+ const headers = cells(lines[0]);
214
+ const rows = [];
215
+ for (let i = 1; i < lines.length; i++) {
216
+ const row = cells(lines[i]);
217
+ if (row.every((cl) => /^:?-+:?$/.test(cl) || cl === "")) continue; // separator row
218
+ rows.push(row);
219
+ }
220
+ return { headers, rows };
221
+ }
222
+
223
+ function colIndex(headers, name) {
224
+ const i = headers.findIndex((h) => h.toLowerCase() === name.toLowerCase());
225
+ return i < 0 ? 0 : i;
226
+ }
227
+
228
+ // Capability names referenced as `specs/<cap>` (placeholder <...> ignored).
229
+ function referencedCapabilities(text) {
230
+ const out = new Set();
231
+ for (const m of text.matchAll(/specs\/([a-z][a-z0-9-]*)/g)) out.add(m[1]);
232
+ return [...out];
233
+ }
234
+
235
+ function ensureDoctrinaProject(projectRoot) {
236
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
237
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
238
+ }
239
+ }
240
+
241
+ export const help = `
242
+ Usage: doctrina contract <subcommand> [args]
243
+
244
+ Own the integration/runtime surface no single capability spec owns:
245
+ the port map, the environment contract, and API/WS/event interfaces.
246
+
247
+ Subcommands:
248
+ new <name> Scaffold .doctrina/contracts/<name>.md and index it
249
+ list One line per contract: id, status, last updated
250
+ check [<name>] Verify the mechanically checkable parts: no two
251
+ services share a port, every declared env var exists
252
+ in .env.example, and every referenced spec exists.
253
+ Exits 1 on errors (port collisions, missing specs).
254
+ `;
@@ -0,0 +1,179 @@
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 } from "../lib/fs-ops.js";
5
+ import { flagBool } from "../lib/args.js";
6
+ import { c } from "../lib/colors.js";
7
+
8
+ // Traceability report: how many acceptance criteria cite an artifact or
9
+ // test that actually exists on disk. Doctrina otherwise has no link
10
+ // between what a spec promises and what proves it — this command makes
11
+ // that link a first-class, measurable signal (the gap the framework was
12
+ // criticised for: validating form, never truth).
13
+ //
14
+ // Convention: each numbered acceptance criterion may cite its evidence as
15
+ // a backtick path span, e.g.
16
+ // 1. Returns HTTP 429 above the quota — verified by `test/quota.test.ts`.
17
+ // A criterion is "covered" when at least one cited path resolves to a
18
+ // file. A cited path that does not resolve is "dangling" (broken
19
+ // traceability) and reported separately. Criteria with no cited path are
20
+ // "bare" — the spec promises something nothing is shown to satisfy.
21
+
22
+ export async function run(_positional, flags) {
23
+ const projectRoot = process.cwd();
24
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
25
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
26
+ }
27
+ const strict = flagBool(flags, "strict", false);
28
+
29
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
30
+ const reports = [];
31
+ if (isDir(specsDir)) {
32
+ for (const cap of readdirSync(specsDir).sort()) {
33
+ const specPath = path.join(specsDir, cap, "spec.md");
34
+ if (!isFile(specPath)) continue;
35
+ const criteria = extractAcceptanceCriteria(read(specPath));
36
+ if (criteria.length === 0) continue;
37
+ const rows = criteria.map((crit, i) => classify(crit, i + 1, projectRoot, path.dirname(specPath)));
38
+ reports.push({ cap, specPath, rows });
39
+ }
40
+ }
41
+
42
+ if (reports.length === 0) {
43
+ console.log(c.gray("no acceptance criteria found under .doctrina/specs/"));
44
+ return 0;
45
+ }
46
+
47
+ let totalCriteria = 0;
48
+ let totalCovered = 0;
49
+ let totalDangling = 0;
50
+
51
+ console.log(c.bold("Coverage") + c.gray(" — acceptance criteria with linked evidence:"));
52
+ console.log("");
53
+
54
+ for (const rep of reports) {
55
+ const covered = rep.rows.filter((r) => r.kind === "covered").length;
56
+ const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
57
+ totalCriteria += rep.rows.length;
58
+ totalCovered += covered;
59
+ totalDangling += dangling;
60
+
61
+ const danglingNote = dangling > 0 ? c.yellow(` (${dangling} dangling)`) : "";
62
+ console.log(` ${c.cyan(rep.cap.padEnd(20))} ${covered}/${rep.rows.length} criteria${danglingNote}`);
63
+ for (const r of rep.rows) {
64
+ if (r.kind === "covered") continue;
65
+ if (r.kind === "bare") {
66
+ console.log(` ${c.red("✗")} #${r.n} no evidence linked — cite the file/test that proves it in backticks`);
67
+ } else {
68
+ console.log(` ${c.yellow("!")} #${r.n} evidence not found on disk: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
69
+ }
70
+ }
71
+ }
72
+
73
+ const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
74
+ console.log("");
75
+ const summary = `${totalCovered} of ${totalCriteria} acceptance criteria across ${reports.length} spec${reports.length === 1 ? "" : "s"} have linked evidence (${pct}%)`;
76
+ const clean = totalCovered === totalCriteria && totalDangling === 0;
77
+ if (clean) {
78
+ console.log(c.green("ok") + " " + summary);
79
+ return 0;
80
+ }
81
+ const danglingSummary = totalDangling > 0 ? `, ${totalDangling} dangling` : "";
82
+ console.log((strict ? c.red("fail") : c.yellow("gap")) + " " + summary + danglingSummary);
83
+ // A report by default (exit 0); a gate under --strict (exit 1 for CI).
84
+ return strict ? 1 : 0;
85
+ }
86
+
87
+ // Pull the numbered items out of the "## Acceptance criteria" section.
88
+ // Each item may span multiple lines (continuation prose); accumulate until
89
+ // the next number or the next "## " heading. Returns an array of strings.
90
+ function extractAcceptanceCriteria(text) {
91
+ const lines = text.split(/\r?\n/);
92
+ const out = [];
93
+ let inSection = false;
94
+ let buf = null;
95
+ const flush = () => {
96
+ if (buf !== null) out.push(buf.trim());
97
+ buf = null;
98
+ };
99
+ for (const line of lines) {
100
+ if (/^##\s+/.test(line)) {
101
+ // Entering or leaving a section.
102
+ if (inSection) {
103
+ flush();
104
+ inSection = false;
105
+ }
106
+ if (/^##\s+Acceptance criteria\b/i.test(line)) inSection = true;
107
+ continue;
108
+ }
109
+ if (!inSection) continue;
110
+ if (/^\s*\d+\.\s+/.test(line)) {
111
+ flush();
112
+ buf = line.replace(/^\s*\d+\.\s+/, "");
113
+ } else if (buf !== null) {
114
+ // Continuation line of the current criterion.
115
+ if (line.trim() === "") buf += " ";
116
+ else buf += " " + line.trim();
117
+ }
118
+ }
119
+ flush();
120
+ // Drop empty placeholder items (a lone "1." with no text).
121
+ return out.filter((s) => s.length > 0);
122
+ }
123
+
124
+ // Decide whether a single criterion is covered, dangling, or bare.
125
+ function classify(criterion, n, projectRoot, specDir) {
126
+ const cited = extractBacktickPaths(criterion);
127
+ if (cited.length === 0) return { kind: "bare", n };
128
+ const missing = [];
129
+ let resolved = 0;
130
+ for (const token of cited) {
131
+ const candidates = [
132
+ path.resolve(projectRoot, token),
133
+ path.resolve(specDir, token),
134
+ ];
135
+ if (candidates.some(exists)) resolved += 1;
136
+ else missing.push(token);
137
+ }
138
+ if (resolved > 0) return { kind: "covered", n };
139
+ return { kind: "dangling", n, missing };
140
+ }
141
+
142
+ // Backtick spans that look like repository file paths. Mirrors the
143
+ // validate stale-link heuristic: a token is a path if it has a slash or a
144
+ // file extension and is not a URL/placeholder.
145
+ function extractBacktickPaths(text) {
146
+ const out = new Set();
147
+ for (const m of text.matchAll(/`([^`]+)`/g)) {
148
+ const token = m[1].trim();
149
+ if (looksLikePath(token)) out.add(token);
150
+ }
151
+ return [...out];
152
+ }
153
+
154
+ function looksLikePath(s) {
155
+ if (!s) return false;
156
+ if (/^(https?:|mailto:|ftp:|#|@)/i.test(s)) return false;
157
+ if (/[\s<>*?{}]/.test(s)) return false;
158
+ const hasSlash = s.includes("/");
159
+ const hasExt = /\.[a-z0-9]{1,8}$/i.test(s);
160
+ if (!hasSlash && !hasExt) return false;
161
+ if (s.startsWith("-")) return false;
162
+ return true;
163
+ }
164
+
165
+ export const help = `
166
+ Usage: doctrina coverage [--strict]
167
+
168
+ Report how many acceptance criteria across .doctrina/specs/ cite an
169
+ artifact or test that exists on disk. Each numbered criterion may cite
170
+ its evidence as a backtick path span, e.g. \`test/quota.test.ts\`.
171
+
172
+ A criterion is covered when at least one cited path resolves to a file,
173
+ dangling when a cited path is missing on disk, and bare when nothing is
174
+ cited. Read-only.
175
+
176
+ Flags:
177
+ --strict Exit 1 when any criterion is bare or dangling (CI gate).
178
+ Without it the command always exits 0 (a report).
179
+ `;
@@ -110,7 +110,9 @@ export function printBootstrapPlaybook(projectRoot) {
110
110
  console.log(`1. Read ${c.cyan(".doctrina/intake.md")} (raw intent) and ${c.cyan(".doctrina/product.md")}.`);
111
111
  console.log("");
112
112
  console.log("2. Fill every product.md section from the intake: Vision, Problem,");
113
- console.log(" Target users, Scope (in/out), Non-goals, Success criteria.");
113
+ console.log(" Target users, Scope (in/out), Non-goals, Success criteria, and the");
114
+ console.log(" Delivery order — name the ONE end-to-end walking skeleton to build");
115
+ console.log(" and verify before fanning out (depth before breadth).");
114
116
  console.log(" One fact, one home — product.md holds vision, never requirements.");
115
117
  console.log("");
116
118
  console.log("3. Derive the capability list: kebab-case, one per area of behaviour");
@@ -119,22 +121,29 @@ export function printBootstrapPlaybook(projectRoot) {
119
121
  console.log(" then replace the template placeholders in");
120
122
  console.log(" .doctrina/specs/<capability>/spec.md with EARS requirements derived");
121
123
  console.log(" from the intake (Ubiquitous / Event-driven / State-driven /");
122
- console.log(" Unwanted-behavior / Optional). Remove section stubs you do not use,");
123
- console.log(" and write concrete Acceptance criteria.");
124
+ console.log(" Unwanted-behavior / Optional). Keep the two axes honest — leave");
125
+ console.log(" Implementation: planned for what is not built yet; keep aspiration");
126
+ console.log(" under ## Maturity → Future, not in EARS; write concrete Acceptance");
127
+ console.log(" criteria, each [unverified] until a cited test proves it.");
124
128
  console.log("");
125
- console.log("4. Record any architectural decision the intake forces:");
129
+ console.log("4. If the project spans services/front-ends, own the seams between");
130
+ console.log(" them (port map, env, API/WS/event shapes) as a contract:");
131
+ console.log(` ${c.cyan("doctrina contract new <name>")} → fill it → ${c.cyan("doctrina contract check")}`);
132
+ console.log("");
133
+ console.log("5. Record any architectural decision the intake forces (cite Evidence):");
126
134
  console.log(` ${c.cyan("doctrina decision new \"<title>\"")} → edit → ${c.cyan("doctrina decision accept <num>")}`);
127
135
  console.log("");
128
- console.log("5. Quality gates — fix everything they report before moving on:");
136
+ console.log("6. Quality gates — fix everything they report before moving on:");
129
137
  console.log(` ${c.cyan("doctrina clarify --all")}`);
130
138
  console.log(` ${c.cyan("doctrina validate")}`);
139
+ console.log(` ${c.cyan("doctrina coverage")} (and ${c.cyan("doctrina verify --init")} to declare the build gate)`);
131
140
  console.log(" If something in the intake is genuinely ambiguous, ask the user");
132
141
  console.log(" before assuming.");
133
142
  console.log("");
134
- console.log("6. Mark the intake consumed: in .doctrina/intake.md flip");
143
+ console.log("7. Mark the intake consumed: in .doctrina/intake.md flip");
135
144
  console.log(" \"- **Status:** pending\" to \"- **Status:** converted\".");
136
145
  console.log("");
137
- console.log(`7. ${c.cyan("doctrina next")} — follow the recommendation. Implementation then`);
146
+ console.log(`8. ${c.cyan("doctrina next")} — follow the recommendation. Implementation then`);
138
147
  console.log(` flows through ${c.cyan("doctrina work \"<brief prompt>\"")}, one change at a time.`);
139
148
  if (!hasSpecs) {
140
149
  console.log("");
@@ -54,7 +54,10 @@ function skillNew(args, flags) {
54
54
  idx.addSkill(index, {
55
55
  id: name,
56
56
  path: `.doctrina/skills/${name}.md`,
57
- description: "<edit me — one-sentence summary>",
57
+ // Seed the index from the scaffolded frontmatter so a fresh skill does
58
+ // not immediately trip validate's description-drift check. Matches how
59
+ // deriveIndex (index rebuild) reads the description.
60
+ description: parseFrontmatter(body, "description") ?? "<edit me — one-sentence summary>",
58
61
  last_updated: date,
59
62
  });
60
63
  idx.touch(index, date);
@@ -51,13 +51,20 @@ export async function run(positional, flags) {
51
51
  console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}${bug ? " (bug-spec shape)" : ""}`);
52
52
 
53
53
  const index = idx.load(projectRoot);
54
- idx.addSpec(index, {
54
+ // Two axes: a fresh capability scaffold is a draft document for a
55
+ // planned capability. Promote `status` to active once it reflects
56
+ // intent; advance `implementation` as code lands (see `doctrina
57
+ // coverage`). Bug specs are a different shape and stay off the
58
+ // implementation axis — only the capability template carries it.
59
+ const entry = {
55
60
  id: capability,
56
61
  path: `.doctrina/specs/${capability}/spec.md`,
57
- status: "active",
62
+ status: bug ? "active" : "draft",
58
63
  version: "0.1.0",
59
64
  last_updated: date,
60
- });
65
+ };
66
+ if (!bug) entry.implementation = "planned";
67
+ idx.addSpec(index, entry);
61
68
  idx.touch(index, date);
62
69
  idx.save(projectRoot, index);
63
70
  console.log(c.green("indexed") + ` spec "${capability}"`);
@@ -75,10 +82,14 @@ function specList() {
75
82
  const p = path.join(specsDir, cap, "spec.md");
76
83
  if (!isFile(p)) continue;
77
84
  const text = read(p);
85
+ // Show only the leading state word of Implementation, dropping any
86
+ // explanatory note after it ("planned — deferred" -> "planned").
87
+ const implRaw = specHeader(text, "Implementation");
78
88
  rows.push({
79
89
  id: cap,
80
90
  version: specHeader(text, "Version") ?? "—",
81
91
  status: specHeader(text, "Status") ?? "active",
92
+ impl: implRaw ? implRaw.trim().split(/[\s—-]+/)[0] : "—",
82
93
  lines: lineCount(p),
83
94
  updated: specHeader(text, "Last updated") ?? "—",
84
95
  });
@@ -88,10 +99,10 @@ function specList() {
88
99
  console.log(c.gray("no specs found in .doctrina/specs/"));
89
100
  return 0;
90
101
  }
91
- console.log(c.bold("Specs:"));
102
+ console.log(c.bold("Specs:") + c.gray(" (status = document, impl = capability)"));
92
103
  console.log("");
93
104
  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}`);
105
+ console.log(` ${c.cyan(r.id.padEnd(20))} ${r.version.padEnd(8)} ${r.status.padEnd(10)} ${r.impl.padEnd(12)} ${String(r.lines).padStart(4)} lines ${r.updated}`);
95
106
  }
96
107
  console.log("");
97
108
  console.log(c.gray(`${rows.length} spec${rows.length === 1 ? "" : "s"}`));
@@ -89,7 +89,7 @@ function updateTemplates(flags) {
89
89
  if (idx.$schema_version === undefined) describe.push('index.json: add "$schema_version": "0.1.0"');
90
90
  if (idx.project === undefined) describe.push(`index.json: add "project": "${path.basename(projectRoot)}"`);
91
91
  if (idx.artifacts === undefined) describe.push('index.json: add empty "artifacts" object');
92
- const categories = ["specs", "decisions", "changes", "changes_archive", "skills"];
92
+ const categories = ["specs", "decisions", "changes", "changes_archive", "skills", "contracts"];
93
93
  for (const cat of categories) {
94
94
  if (idx.artifacts && idx.artifacts[cat] === undefined) {
95
95
  describe.push(`index.json: add empty artifact category "${cat}"`);