doctrina-cli 0.2.2 → 0.4.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.
@@ -8,7 +8,7 @@ import { today, slugify, padNumber } from "../lib/dates.js";
8
8
  import { c } from "../lib/colors.js";
9
9
  import { suggest } from "../lib/suggest.js";
10
10
 
11
- const SUBCOMMANDS = ["new", "supersede", "accept", "list"];
11
+ const SUBCOMMANDS = ["new", "supersede", "accept", "land", "list"];
12
12
 
13
13
  export async function run(positional, _flags) {
14
14
  const sub = positional[0];
@@ -19,6 +19,8 @@ export async function run(positional, _flags) {
19
19
  return decisionSupersede(positional.slice(1));
20
20
  case "accept":
21
21
  return decisionAccept(positional.slice(1));
22
+ case "land":
23
+ return decisionLand(positional.slice(1));
22
24
  case "list":
23
25
  return decisionList();
24
26
  default:
@@ -198,6 +200,81 @@ function decisionAccept(args) {
198
200
  return 0;
199
201
  }
200
202
 
203
+ // Record that an accepted ADR is now implemented and verified, without
204
+ // editing the immutable body. Mutates ONLY the **Landed:** header line (the
205
+ // same "headers are updatable, the decision prose is not" pattern that accept
206
+ // and supersede already use), stamping today's date plus any cited proof
207
+ // paths. This closes the gap where a design-time ADR ("Evidence: n/a — no
208
+ // implementation yet") had no first-class way to record that reality caught up
209
+ // short of a heavyweight supersede. (3.4)
210
+ function decisionLand(args) {
211
+ const target = args[0];
212
+ if (!target) {
213
+ console.error(c.red("error:") + " decision land requires the target ADR number (e.g. 0003)");
214
+ return 2;
215
+ }
216
+ const projectRoot = process.cwd();
217
+ ensureDoctrinaProject(projectRoot);
218
+
219
+ const padded = padNumber(parseInt(target, 10));
220
+ const adrDir = path.join(projectRoot, ".doctrina", "decisions");
221
+ const file = walk(adrDir).find((f) => path.basename(f).startsWith(`${padded}-`));
222
+ if (!file) {
223
+ console.error(c.red("error:") + ` no ADR found with number ${padded} in ${relPath(projectRoot, adrDir)}`);
224
+ return 1;
225
+ }
226
+ const text = read(file);
227
+ const statusMatch = text.match(/^-\s+\*\*Status:\*\*\s+(.+)$/m);
228
+ if (!statusMatch) {
229
+ console.error(c.red("error:") + ` ADR at ${relPath(projectRoot, file)} has no Status: header`);
230
+ return 1;
231
+ }
232
+ const status = statusMatch[1].trim().toLowerCase();
233
+ if (status !== "accepted") {
234
+ console.error(
235
+ c.red("error:") +
236
+ ` ADR ${padded} is "${statusMatch[1].trim()}", not "accepted" — accept it before recording that it landed`,
237
+ );
238
+ return 1;
239
+ }
240
+
241
+ const date = today();
242
+ const proofPaths = args.slice(1).filter(Boolean);
243
+ const cited = proofPaths.map((p) => `\`${p}\``).join(", ");
244
+ const landedValue = cited ? `${date} — ${cited}` : date;
245
+
246
+ // Rewrite the existing Landed header, or insert one (after Evidence, else
247
+ // after Status) for ADRs that predate the field. The body stays untouched.
248
+ let updated;
249
+ if (/^-\s+\*\*Landed:\*\*/m.test(text)) {
250
+ updated = text.replace(/^(-\s+\*\*Landed:\*\*)\s+.*$/m, `$1 ${landedValue}`);
251
+ } else if (/^-\s+\*\*Evidence:\*\*.*$/m.test(text)) {
252
+ updated = text.replace(/^(-\s+\*\*Evidence:\*\*.*)$/m, `$1\n- **Landed:** ${landedValue}`);
253
+ } else {
254
+ updated = text.replace(/^(-\s+\*\*Status:\*\*.*)$/m, `$1\n- **Landed:** ${landedValue}`);
255
+ }
256
+ write(file, updated, { force: true });
257
+ console.log(c.green("landed") + ` ${relPath(projectRoot, file)} on ${date}`);
258
+
259
+ const index = idx.load(projectRoot);
260
+ // Store the same value the header now carries, so the index matches what
261
+ // `index rebuild` would derive from the file (no perpetual drift).
262
+ idx.updateDecision(index, padded, () => ({ landed: landedValue }));
263
+ idx.touch(index, date);
264
+ idx.save(projectRoot, index);
265
+ console.log(c.green("indexed") + ` decision ${padded} landed`);
266
+
267
+ // Parity with `validate`: cited proof that does not resolve is a dangling
268
+ // citation, surfaced now rather than at the next validate.
269
+ for (const p of proofPaths) {
270
+ const candidates = [path.resolve(path.dirname(file), p), path.resolve(projectRoot, p)];
271
+ if (!candidates.some(exists)) {
272
+ console.log(c.yellow("warn:") + ` cited proof \`${p}\` not found on disk`);
273
+ }
274
+ }
275
+ return 0;
276
+ }
277
+
201
278
  function decisionList() {
202
279
  const projectRoot = process.cwd();
203
280
  ensureDoctrinaProject(projectRoot);
@@ -259,6 +336,11 @@ Subcommands:
259
336
  new "<title>" Create the next sequentially numbered ADR
260
337
  accept <number> Flip a proposed ADR to accepted (rewrites
261
338
  only the Status: header) and update the index
339
+ land <number> [path ...] Stamp an accepted ADR as implemented:
340
+ rewrites only the Landed: header with today's
341
+ date plus any cited proof paths. The decision
342
+ body stays immutable. Satisfies the accepted-
343
+ ADR evidence check without a supersede.
262
344
  supersede <number> "<new title>" Create a new ADR that supersedes the target
263
345
  and rewrite the target's Status: header
264
346
  list One line per ADR: number, status, date,
@@ -3,6 +3,7 @@ import process from "node:process";
3
3
  import { exists } from "../lib/fs-ops.js";
4
4
  import * as idx from "../lib/index-json.js";
5
5
  import { deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
6
+ import { cliVersion } from "../lib/version.js";
6
7
  import { today } from "../lib/dates.js";
7
8
  import { flagBool } from "../lib/args.js";
8
9
  import { c } from "../lib/colors.js";
@@ -42,6 +43,11 @@ function rebuild(flags) {
42
43
  }
43
44
 
44
45
  const derived = deriveIndex(projectRoot, current);
46
+ // Migrate the framework stamp to the running CLI (3.6). deriveIndex carries
47
+ // the old value over (so `next` does not nag on a version-only difference);
48
+ // overriding it here lets a stale stamp count as drift, so `index rebuild`
49
+ // both reports and fixes it instead of short-circuiting on "nothing to do".
50
+ derived.framework_version = cliVersion();
45
51
 
46
52
  if (indexesMatch(derived, current)) {
47
53
  console.log(c.green("ok") + " index.json matches the tree (nothing to do)");
@@ -72,6 +78,9 @@ function rebuild(flags) {
72
78
  function describeDrift(current, derived) {
73
79
  const lines = [];
74
80
  if (!current) return ["index.json missing or unreadable"];
81
+ if ((current.framework_version ?? null) !== (derived.framework_version ?? null)) {
82
+ lines.push(`framework_version: ${current.framework_version ?? "unset"} -> ${derived.framework_version}`);
83
+ }
75
84
  const categories = ["specs", "decisions", "changes", "changes_archive", "skills"];
76
85
  for (const cat of categories) {
77
86
  const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
@@ -3,10 +3,11 @@ import process from "node:process";
3
3
  import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
4
4
  import { locateTemplatesDir, loadTemplateTree, materialiseEntry, substitute } from "../lib/templates.js";
5
5
  import { today } from "../lib/dates.js";
6
+ import { cliVersion } from "../lib/version.js";
6
7
  import { flagBool, flagString } from "../lib/args.js";
7
8
  import { c } from "../lib/colors.js";
8
9
  import { ask } from "../lib/prompt.js";
9
- import { writeIntakeFile, printBootstrapPlaybook } from "./intake.js";
10
+ import { writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake } from "./intake.js";
10
11
 
11
12
  const SUPPORTED_AGENTS = [
12
13
  "claude",
@@ -90,6 +91,10 @@ export async function run(_positional, flags) {
90
91
  PROJECT_DESCRIPTION: description,
91
92
  DATE: date,
92
93
  AGENTS_MD_PATH: "AGENTS.md",
94
+ // Stamp the framework version into the scaffolded index.json. init writes
95
+ // it straight from the template (bypassing idx.save), so the token is the
96
+ // stamp point here. (3.6)
97
+ FRAMEWORK_VERSION: cliVersion(),
93
98
  };
94
99
 
95
100
  // Root AGENTS.md — with optional --from base content prepended.
@@ -154,6 +159,7 @@ export async function run(_positional, flags) {
154
159
  console.log(`${c.cyan(".doctrina/product.md")} and capability specs. Any AGENTS.md-aware agent`);
155
160
  console.log(`runs it on its own; reprint anytime with ${c.cyan("doctrina intake")}.`);
156
161
  console.log("");
162
+ warnIfThinIntake(intakeBody);
157
163
  printBootstrapPlaybook(projectRoot);
158
164
  } else {
159
165
  console.log(`Next: edit ${c.cyan("AGENTS.md")} and ${c.cyan(".doctrina/product.md")} for your project.`);
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
4
4
  import { listHeader } from "../lib/scan.js";
5
+ import { assessBrief } from "../lib/clarity.js";
5
6
  import * as idx from "../lib/index-json.js";
6
7
  import { today } from "../lib/dates.js";
7
8
  import { flagBool, flagString } from "../lib/args.js";
@@ -69,10 +70,23 @@ export async function run(positional, flags) {
69
70
  writeIntakeFile(projectRoot, { body, source, projectName, date: today(), force });
70
71
  console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)}`);
71
72
  console.log("");
73
+ warnIfThinIntake(body);
72
74
  printBootstrapPlaybook(projectRoot);
73
75
  return 0;
74
76
  }
75
77
 
78
+ // Clarification gate (review Topic A). A description too thin to spec from is
79
+ // the moment to ask the user, not to let the agent invent requirements. Prints
80
+ // the specific gaps before the bootstrap playbook; advisory, never blocking —
81
+ // the intake is still captured verbatim (the playbook step 6 resolves it).
82
+ export function warnIfThinIntake(body) {
83
+ const assessment = assessBrief(body, { kind: "intake" });
84
+ if (!assessment.thin) return;
85
+ console.log(c.yellow("⚠ thin intake — clarify with the user before converting to specs:"));
86
+ for (const reason of assessment.reasons) console.log(` - ${reason}`);
87
+ console.log("");
88
+ }
89
+
76
90
  // Shared with `init --intake`. Writes the intake verbatim under a small
77
91
  // status header; the agent flips Status to converted at the end of the
78
92
  // playbook, which is how `next` knows the bootstrap is done.
@@ -110,7 +124,9 @@ export function printBootstrapPlaybook(projectRoot) {
110
124
  console.log(`1. Read ${c.cyan(".doctrina/intake.md")} (raw intent) and ${c.cyan(".doctrina/product.md")}.`);
111
125
  console.log("");
112
126
  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.");
127
+ console.log(" Target users, Scope (in/out), Non-goals, Success criteria, and the");
128
+ console.log(" Delivery order — name the ONE end-to-end walking skeleton to build");
129
+ console.log(" and verify before fanning out (depth before breadth).");
114
130
  console.log(" One fact, one home — product.md holds vision, never requirements.");
115
131
  console.log("");
116
132
  console.log("3. Derive the capability list: kebab-case, one per area of behaviour");
@@ -119,22 +135,29 @@ export function printBootstrapPlaybook(projectRoot) {
119
135
  console.log(" then replace the template placeholders in");
120
136
  console.log(" .doctrina/specs/<capability>/spec.md with EARS requirements derived");
121
137
  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.");
138
+ console.log(" Unwanted-behavior / Optional). Keep the two axes honest — leave");
139
+ console.log(" Implementation: planned for what is not built yet; keep aspiration");
140
+ console.log(" under ## Maturity → Future, not in EARS; write concrete Acceptance");
141
+ console.log(" criteria, each [unverified] until a cited test proves it.");
142
+ console.log("");
143
+ console.log("4. If the project spans services/front-ends, own the seams between");
144
+ console.log(" them (port map, env, API/WS/event shapes) as a contract:");
145
+ console.log(` ${c.cyan("doctrina contract new <name>")} → fill it → ${c.cyan("doctrina contract check")}`);
124
146
  console.log("");
125
- console.log("4. Record any architectural decision the intake forces:");
147
+ console.log("5. Record any architectural decision the intake forces (cite Evidence):");
126
148
  console.log(` ${c.cyan("doctrina decision new \"<title>\"")} → edit → ${c.cyan("doctrina decision accept <num>")}`);
127
149
  console.log("");
128
- console.log("5. Quality gates — fix everything they report before moving on:");
150
+ console.log("6. Quality gates — fix everything they report before moving on:");
129
151
  console.log(` ${c.cyan("doctrina clarify --all")}`);
130
152
  console.log(` ${c.cyan("doctrina validate")}`);
153
+ console.log(` ${c.cyan("doctrina coverage")} (and ${c.cyan("doctrina verify --init")} to declare the build gate)`);
131
154
  console.log(" If something in the intake is genuinely ambiguous, ask the user");
132
155
  console.log(" before assuming.");
133
156
  console.log("");
134
- console.log("6. Mark the intake consumed: in .doctrina/intake.md flip");
157
+ console.log("7. Mark the intake consumed: in .doctrina/intake.md flip");
135
158
  console.log(" \"- **Status:** pending\" to \"- **Status:** converted\".");
136
159
  console.log("");
137
- console.log(`7. ${c.cyan("doctrina next")} — follow the recommendation. Implementation then`);
160
+ console.log(`8. ${c.cyan("doctrina next")} — follow the recommendation. Implementation then`);
138
161
  console.log(` flows through ${c.cyan("doctrina work \"<brief prompt>\"")}, one change at a time.`);
139
162
  if (!hasSpecs) {
140
163
  console.log("");
@@ -149,7 +172,8 @@ Store the full project description verbatim at .doctrina/intake.md and
149
172
  print the bootstrap playbook — the ordered instruction sequence the host
150
173
  AI agent executes to convert the intake into product.md content and
151
174
  capability specs (see ADR 0005). The CLI itself does no natural-language
152
- interpretation.
175
+ interpretation. A thin/under-specified description is flagged so the agent
176
+ clarifies with the user before converting (advisory, never blocking).
153
177
 
154
178
  Forms:
155
179
  doctrina intake <file> Ingest a description file
@@ -6,7 +6,14 @@ import { c } from "../lib/colors.js";
6
6
 
7
7
  // Category-aware full-text search across the artifact tree. Answers
8
8
  // "where is X specified / decided / proposed?" without knowing the
9
- // layout. Plain substring matching, every term must hit (AND).
9
+ // layout. Plain substring matching, every term must hit (AND) on a line.
10
+ //
11
+ // Results are ranked, not dumped in file order: each hit is scored (a match
12
+ // in a heading or metadata header, or an adjacent full-phrase match, outranks
13
+ // a bare body line), files are scored by their hits plus a filename bonus, and
14
+ // files within a category print best-first. This mirrors the framework's own
15
+ // "ranked selection over dumping" thesis — at scale the agent reads the most
16
+ // relevant hit first instead of scanning an unordered list.
10
17
 
11
18
  const MAX_MATCHES_PER_FILE = 5;
12
19
 
@@ -16,6 +23,7 @@ export async function run(positional, flags) {
16
23
  console.error(c.red("error:") + " search requires at least one term");
17
24
  return 2;
18
25
  }
26
+ const phrase = terms.join(" ");
19
27
  const includeArchive = flagBool(flags, "archive", false);
20
28
  const projectRoot = process.cwd();
21
29
  if (!exists(path.join(projectRoot, ".doctrina"))) {
@@ -48,16 +56,22 @@ export async function run(positional, flags) {
48
56
  const lines = read(f).split("\n");
49
57
  const hits = [];
50
58
  for (let i = 0; i < lines.length; i++) {
51
- const low = lines[i].toLowerCase();
59
+ const raw = lines[i];
60
+ const low = raw.toLowerCase();
52
61
  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;
62
+ hits.push({ line: i + 1, text: raw.trim(), score: lineScore(raw, low, phrase) });
55
63
  }
56
64
  }
57
- if (hits.length > 0) findings.push({ rel, hits });
65
+ if (hits.length === 0) continue;
66
+ // Keep the highest-scoring lines (best-first), not merely the first few.
67
+ hits.sort((a, b) => b.score - a.score || a.line - b.line);
68
+ const fileScore = hits.reduce((s, h) => s + h.score, 0) + filenameBonus(rel, terms);
69
+ findings.push({ rel, hits: hits.slice(0, MAX_MATCHES_PER_FILE), score: fileScore });
58
70
  }
59
71
 
60
72
  if (findings.length === 0) continue;
73
+ // Most relevant file first; stable tie-break by path.
74
+ findings.sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
61
75
  console.log(c.bold(cat.name));
62
76
  for (const file of findings) {
63
77
  filesWithMatches += 1;
@@ -81,13 +95,34 @@ function truncate(s, n) {
81
95
  return s.length > n ? s.slice(0, n - 1) + "…" : s;
82
96
  }
83
97
 
98
+ // Relevance of a single matching line. A heading or metadata-header line
99
+ // names the topic; an adjacent full-phrase match means the terms occur
100
+ // together, not scattered. Both outrank a bare body line.
101
+ function lineScore(raw, low, phrase) {
102
+ let s = 1;
103
+ if (/^\s*#{1,6}\s/.test(raw)) s += 2;
104
+ if (/\*\*[^*]+:\*\*/.test(raw)) s += 1;
105
+ if (phrase && low.includes(phrase)) s += 2;
106
+ return s;
107
+ }
108
+
109
+ // A file whose name carries the query is almost always the right one;
110
+ // reward a full filename match more than a partial one.
111
+ function filenameBonus(rel, terms) {
112
+ const base = rel.toLowerCase();
113
+ if (terms.every((t) => base.includes(t))) return 3;
114
+ return terms.some((t) => base.includes(t)) ? 1 : 0;
115
+ }
116
+
84
117
  export const help = `
85
118
  Usage: doctrina search <term> [<term> ...] [--archive]
86
119
 
87
120
  Case-insensitive substring search across the artifact tree, grouped
88
121
  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.
122
+ Every term must match on the same line. Within each category, files are
123
+ ranked best-first (heading / metadata / full-phrase / filename matches
124
+ score higher), and the highest-scoring lines per file are shown. The
125
+ change archive is excluded unless --archive is passed.
91
126
 
92
127
  Exits 0 when matches are found, 1 otherwise. Read-only.
93
128
  `;
@@ -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}"`);
@@ -0,0 +1,157 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { readdirSync } from "node:fs";
4
+ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
5
+ import { specHeader } from "../lib/scan.js";
6
+ import { flagBool } from "../lib/args.js";
7
+ import { c } from "../lib/colors.js";
8
+
9
+ // Intent-provenance report (ADR 0006). `coverage` proves a criterion has a
10
+ // test; `trace` proves a capability traces to a stated intent. Together they
11
+ // form the chain product-intent -> capability -> criterion -> test.
12
+ //
13
+ // Conventions (all opt-in; absent markers are "not yet traced", not errors):
14
+ // - product.md tags an intent anchor at the head of a bullet: "- [SC1] ...".
15
+ // Any "[A-Z]+\d+" tag is an anchor (Success criteria, In scope, ...).
16
+ // - a capability spec declares "**Realizes:** SC1, SC3".
17
+ //
18
+ // trace reports three provenance breaks: dropped intent (an anchor no spec
19
+ // realizes), dangling realizes (a spec cites an unknown id), and untraceable
20
+ // spec (an active spec with no Realizes: header). It is read-only; --strict
21
+ // turns any break into a non-zero exit for CI.
22
+ //
23
+ // What it does NOT do (the honest ceiling, per ADR 0006): judge whether a
24
+ // criterion faithfully encodes the intent. That semantic check is deferred to
25
+ // a future LLM-assisted layer and is never claimed by this deterministic gate.
26
+
27
+ const ANCHOR_RE = /[A-Z]+\d+/g;
28
+
29
+ export async function run(_positional, flags) {
30
+ const projectRoot = process.cwd();
31
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
32
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
33
+ }
34
+ const strict = flagBool(flags, "strict", false);
35
+
36
+ const anchors = collectAnchors(projectRoot); // [{ id }], in document order
37
+ const specs = collectSpecs(projectRoot); // [{ cap, status, realizes: [id] }]
38
+
39
+ const anchorIds = new Set(anchors.map((a) => a.id));
40
+ const anyRealizes = specs.some((s) => s.realizes !== null);
41
+
42
+ // The feature is unused: do not nag a project that never opted in.
43
+ if (anchors.length === 0 && !anyRealizes) {
44
+ console.log(
45
+ c.gray(
46
+ "no intent-provenance markers found — tag product.md bullets with `[SC1]` " +
47
+ "and add `**Realizes:** SC1` to the specs that deliver them",
48
+ ),
49
+ );
50
+ return 0;
51
+ }
52
+
53
+ // anchor id -> [capability]
54
+ const realizedBy = new Map();
55
+ const dangling = []; // { cap, id }
56
+ for (const s of specs) {
57
+ if (s.realizes === null) continue;
58
+ for (const id of s.realizes) {
59
+ if (anchorIds.has(id)) {
60
+ if (!realizedBy.has(id)) realizedBy.set(id, []);
61
+ realizedBy.get(id).push(s.cap);
62
+ } else {
63
+ dangling.push({ cap: s.cap, id });
64
+ }
65
+ }
66
+ }
67
+ // Untraceable: an active capability tied to no stated intent.
68
+ const untraceable = specs
69
+ .filter((s) => s.realizes === null && s.status === "active")
70
+ .map((s) => s.cap);
71
+
72
+ console.log(c.bold("Trace") + c.gray(" — product intent → capability provenance:"));
73
+ console.log("");
74
+
75
+ let realized = 0;
76
+ for (const a of anchors) {
77
+ const caps = realizedBy.get(a.id);
78
+ if (caps && caps.length > 0) {
79
+ realized += 1;
80
+ console.log(` ${c.cyan(a.id.padEnd(6))} ${c.green("✓")} realized by: ${caps.sort().join(", ")}`);
81
+ } else {
82
+ console.log(` ${c.cyan(a.id.padEnd(6))} ${c.red("✗")} dropped — no spec realizes this intent`);
83
+ }
84
+ }
85
+
86
+ if (dangling.length > 0 || untraceable.length > 0) console.log("");
87
+ for (const d of dangling) {
88
+ console.log(` ${c.yellow("!")} dangling: spec "${d.cap}" realizes ${d.id} (no such anchor in product.md)`);
89
+ }
90
+ if (untraceable.length > 0) {
91
+ console.log(` ${c.yellow("!")} untraceable specs (no Realizes:): ${untraceable.sort().join(", ")}`);
92
+ }
93
+
94
+ const dropped = anchors.length - realized;
95
+ console.log("");
96
+ const summary =
97
+ `${realized} of ${anchors.length} intent anchor${anchors.length === 1 ? "" : "s"} realized` +
98
+ `; ${dropped} dropped, ${dangling.length} dangling, ${untraceable.length} untraceable`;
99
+ const clean = dropped === 0 && dangling.length === 0 && untraceable.length === 0;
100
+ if (clean) {
101
+ console.log(c.green("ok") + " " + summary);
102
+ return 0;
103
+ }
104
+ console.log((strict ? c.red("fail") : c.yellow("gap")) + " " + summary);
105
+ // A report by default (exit 0); a gate under --strict (exit 1 for CI).
106
+ return strict ? 1 : 0;
107
+ }
108
+
109
+ // Every "[A-Z]+\d+" tag at the head of a bullet in product.md is an intent
110
+ // anchor. Section-agnostic so Success-criteria and In-scope bullets both work.
111
+ function collectAnchors(projectRoot) {
112
+ const productPath = path.join(projectRoot, ".doctrina", "product.md");
113
+ if (!isFile(productPath)) return [];
114
+ const out = [];
115
+ const seen = new Set();
116
+ for (const line of read(productPath).split(/\r?\n/)) {
117
+ const m = line.match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s+/);
118
+ if (m && !seen.has(m[1])) {
119
+ seen.add(m[1]);
120
+ out.push({ id: m[1] });
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+
126
+ function collectSpecs(projectRoot) {
127
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
128
+ if (!isDir(specsDir)) return [];
129
+ const out = [];
130
+ for (const cap of readdirSync(specsDir).sort()) {
131
+ const specPath = path.join(specsDir, cap, "spec.md");
132
+ if (!isFile(specPath)) continue;
133
+ const text = read(specPath);
134
+ const realizesRaw = specHeader(text, "Realizes");
135
+ const realizes = realizesRaw === null ? null : (realizesRaw.match(ANCHOR_RE) ?? []);
136
+ const status = (specHeader(text, "Status") ?? "active").trim().toLowerCase();
137
+ out.push({ cap, status, realizes });
138
+ }
139
+ return out;
140
+ }
141
+
142
+ export const help = `
143
+ Usage: doctrina trace [--strict]
144
+
145
+ Report intent provenance: how product.md intent maps to capability specs
146
+ (ADR 0006). Tag product bullets with an anchor — \`- [SC1] ...\` — and add
147
+ \`**Realizes:** SC1\` to the specs that deliver them.
148
+
149
+ trace reports three provenance breaks:
150
+ dropped intent an anchor that no spec realizes
151
+ dangling realizes a spec cites an id absent from product.md
152
+ untraceable spec an active spec with no Realizes: header
153
+
154
+ Read-only. Exits 0 as a report; with --strict, exits 1 when any break
155
+ exists (CI gate). It checks that the link exists and is complete — not
156
+ that a criterion is faithful to the intent (that stays a human/LLM call).
157
+ `;