doctrina-cli 0.3.0 → 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.
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.3.0 — current release. Zero runtime dependencies
6
+ **Status:** v0.4.0 — current release. Zero runtime dependencies
7
7
  (Node.js standard library only).
8
8
 
9
9
  ## Install
@@ -20,14 +20,19 @@ Requires Node.js 20.12 or later.
20
20
 
21
21
  ```
22
22
  doctrina init scaffold AGENTS.md + .doctrina/ in cwd
23
+ doctrina intake [<file>] store the full description + bootstrap playbook (clarification gate)
24
+ doctrina work "<prompt>" brief prompt -> scaffolded change + work playbook (clarification gate)
23
25
  doctrina spec new <capability> create a capability spec
24
26
  doctrina spec list list specs with version, status, size
25
27
  doctrina change new <id> "<title>" open a change proposal
26
28
  doctrina change apply <id> apply spec deltas (ADDED/REMOVED auto)
27
29
  doctrina change archive <id> archive an applied change
28
30
  doctrina change diff <id> preview spec deltas (line diff for MODIFIED)
31
+ doctrina contract new <name> own the integration surface (ports, env, interfaces)
32
+ doctrina contract check verify port collisions, env drift, referenced specs
29
33
  doctrina decision new "<title>" create the next sequential ADR
30
34
  doctrina decision accept <num> flip a proposed ADR to accepted
35
+ doctrina decision land <num> [path...] stamp an accepted ADR as implemented (non-mutating Landed:)
31
36
  doctrina decision supersede <num> "<t>" supersede an existing ADR
32
37
  doctrina decision list list ADRs with status, date, title
33
38
  doctrina skill new <name> scaffold an on-demand procedural-memory skill
@@ -36,8 +41,11 @@ doctrina skill sync mirror skill frontmatter descriptions
36
41
  doctrina analyze <change-id> inspect a change folder before applying
37
42
  doctrina clarify <path> smell-test a Markdown file for ambiguity (--all: tree)
38
43
  doctrina context [<capability>] print the context pack in read order (--concat)
39
- doctrina search <term> [...] search artifacts, grouped by category
44
+ doctrina search <term> [...] search artifacts, grouped by category (ranked)
40
45
  doctrina validate schema + artifact-existence checks
46
+ doctrina coverage acceptance criteria with linked evidence (--strict gates)
47
+ doctrina trace intent provenance: product intent -> specs (--strict gates)
48
+ doctrina verify run project typecheck/test/build checks (the real gate)
41
49
  doctrina templates list enumerate shipped templates
42
50
  doctrina templates check compare project against recommended template shape
43
51
  doctrina templates update additive fixer for check findings (preview; --write applies)
@@ -109,9 +117,19 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
109
117
  16. Each skill's frontmatter description matches `index.json` (drift warn; `skill sync` fixes).
110
118
  17. EARS grammar shape per section in specs declaring `## Requirements (EARS)` (warn).
111
119
  18. Nested `AGENTS.md` files obey the root size caps (warn > 150, error > 200).
120
+ 19. No two ADR files share the same `NNNN` number (merge-collision error).
121
+ 20. `index.json` records a `framework_version` matching the running CLI
122
+ (stamp-divergence warn; `index rebuild` migrates it).
123
+ 21. Accepted ADRs anchor to reality via `Evidence:` and/or `Landed:`; cited
124
+ paths must exist on disk (drift warn).
112
125
 
113
126
  Exit code: 0 if no errors (warnings allowed), 1 otherwise.
114
127
 
128
+ Two related read-only reports gate separately: `doctrina coverage`
129
+ (each acceptance criterion cites a real test) and `doctrina trace`
130
+ (each product intent has a capability, each capability traces to an
131
+ intent). Both exit 1 under `--strict` for CI.
132
+
115
133
  ## Tests
116
134
 
117
135
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -144,6 +144,7 @@ function changeApply(args, _flags) {
144
144
  // Flip proposal status from "proposed" to "applied" when fully successful.
145
145
  // Zero-delta changes are trivially successful (no work to fail), so they
146
146
  // also flip — otherwise metadata-only changes are stuck at "proposed".
147
+ let flippedToApplied = false;
147
148
  if (errors === 0 && manual === 0) {
148
149
  const proposalPath = path.join(changeDir, "proposal.md");
149
150
  if (exists(proposalPath)) {
@@ -155,30 +156,41 @@ function changeApply(args, _flags) {
155
156
  if (updated !== txt) {
156
157
  write(proposalPath, updated, { force: true });
157
158
  console.log(c.green("status") + " proposal.md → applied");
159
+ flippedToApplied = true;
158
160
  }
159
161
  }
160
162
  }
161
163
 
162
- // Update index for ADDED/REMOVED that affected specs
163
- if (writes > 0) {
164
+ // Update the index when specs changed (ADDED/REMOVED) and/or the proposal
165
+ // flipped to applied. The change entry's status must mirror the proposal —
166
+ // the same value `index rebuild` derives from the file — or the index
167
+ // drifts from the tree in the apply→archive window (caught by
168
+ // `index rebuild --check`, e.g. in the pre-commit hook).
169
+ if (writes > 0 || flippedToApplied) {
164
170
  const index = idx.load(projectRoot);
165
- for (const deltaPath of deltaFiles) {
166
- const text = read(deltaPath);
167
- const op = parseOperation(text);
168
- const capability = parseCapabilityFromDelta(text, deltaPath);
169
- if (!capability) continue;
170
- if (op === "ADDED") {
171
- idx.addSpec(index, {
172
- id: capability,
173
- path: `.doctrina/specs/${capability}/spec.md`,
174
- status: "active",
175
- version: "0.1.0",
176
- last_updated: date,
177
- });
178
- } else if (op === "REMOVED") {
179
- idx.removeSpec(index, capability);
171
+ if (writes > 0) {
172
+ for (const deltaPath of deltaFiles) {
173
+ const text = read(deltaPath);
174
+ const op = parseOperation(text);
175
+ const capability = parseCapabilityFromDelta(text, deltaPath);
176
+ if (!capability) continue;
177
+ if (op === "ADDED") {
178
+ idx.addSpec(index, {
179
+ id: capability,
180
+ path: `.doctrina/specs/${capability}/spec.md`,
181
+ status: "active",
182
+ version: "0.1.0",
183
+ last_updated: date,
184
+ });
185
+ } else if (op === "REMOVED") {
186
+ idx.removeSpec(index, capability);
187
+ }
180
188
  }
181
189
  }
190
+ if (flippedToApplied) {
191
+ const entry = index.artifacts.changes.find((ch) => ch.id === id);
192
+ if (entry) entry.status = "applied";
193
+ }
182
194
  idx.touch(index, date);
183
195
  idx.save(projectRoot, index);
184
196
  }
@@ -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.
@@ -158,7 +172,8 @@ Store the full project description verbatim at .doctrina/intake.md and
158
172
  print the bootstrap playbook — the ordered instruction sequence the host
159
173
  AI agent executes to convert the intake into product.md content and
160
174
  capability specs (see ADR 0005). The CLI itself does no natural-language
161
- interpretation.
175
+ interpretation. A thin/under-specified description is flagged so the agent
176
+ clarifies with the user before converting (advisory, never blocking).
162
177
 
163
178
  Forms:
164
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
  `;
@@ -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
+ `;
@@ -3,6 +3,8 @@ import process from "node:process";
3
3
  import { readdirSync } from "node:fs";
4
4
  import { exists, isDir, isFile, lineCount, read, relPath, walk } from "../lib/fs-ops.js";
5
5
  import * as idx from "../lib/index-json.js";
6
+ import { SCHEMA_VERSION } from "../lib/index-json.js";
7
+ import { cliVersion } from "../lib/version.js";
6
8
  import { c } from "../lib/colors.js";
7
9
  import { parseFrontmatter } from "./skill.js";
8
10
  import { checkEars, isEarsSpec } from "../lib/ears.js";
@@ -45,8 +47,20 @@ export async function run(_positional, _flags) {
45
47
  }
46
48
 
47
49
  if (index) {
48
- if (index.$schema_version !== "0.1.0") {
49
- warnings.push(`index.json $schema_version is "${index.$schema_version}" (expected "0.1.0")`);
50
+ if (index.$schema_version !== SCHEMA_VERSION) {
51
+ warnings.push(`index.json $schema_version is "${index.$schema_version}" (expected "${SCHEMA_VERSION}")`);
52
+ }
53
+ // framework_version stamp divergence (3.6). The index records which CLI
54
+ // manages it; a stamp behind (or absent vs.) the running CLI means the
55
+ // tree may predate the current schema. `index rebuild` migrates it.
56
+ const runningVersion = cliVersion();
57
+ if (!index.framework_version) {
58
+ warnings.push(`index.json has no framework_version (run \`doctrina index rebuild\` to stamp it)`);
59
+ } else if (index.framework_version !== runningVersion) {
60
+ warnings.push(
61
+ `index.json framework_version is "${index.framework_version}" but the running CLI is ` +
62
+ `${runningVersion} (run \`doctrina index rebuild\` to stamp/migrate)`,
63
+ );
50
64
  }
51
65
  if (!index.artifacts) {
52
66
  errors.push("index.json missing artifacts object");
@@ -79,10 +93,20 @@ export async function run(_positional, _flags) {
79
93
 
80
94
  // 5. ADRs have parseable Status: header
81
95
  const adrDir = path.join(projectRoot, ".doctrina", "decisions");
96
+ // Track which files claim each NNNN so a merge-time collision (two branches
97
+ // both allocating the same number) becomes a loud error rather than one ADR
98
+ // silently shadowing the other (C2).
99
+ const adrByNumber = new Map();
82
100
  if (isDir(adrDir)) {
83
101
  for (const f of walk(adrDir)) {
84
102
  if (!f.endsWith(".md")) continue;
85
103
  const base = path.basename(f);
104
+ const numMatch = base.match(/^(\d{4})-/);
105
+ const num = numMatch ? numMatch[1] : null;
106
+ if (num) {
107
+ if (!adrByNumber.has(num)) adrByNumber.set(num, []);
108
+ adrByNumber.get(num).push(relPath(projectRoot, f));
109
+ }
86
110
  // ADR filenames must be NNNN-slug.md (four digits). The entire ADR
87
111
  // toolchain — decision new/accept/supersede, index derivation, and
88
112
  // the orphan check below — keys off this shape; a file like
@@ -105,33 +129,50 @@ export async function run(_positional, _flags) {
105
129
  // 5b. ADR evidence drift. A decision is only true if reality reflects
106
130
  // it; an accepted ADR that points at nothing — or at a file that
107
131
  // no longer exists — is the classic "ADR decrees X, the code
108
- // never did X". Only ADRs that adopt the **Evidence:** header are
109
- // checked, so legacy/process ADRs that opt out stay quiet.
132
+ // never did X". Two header fields anchor a decision to reality:
133
+ // **Evidence:** (cited at any point) and **Landed:** (the
134
+ // non-mutating "this is now implemented and verified here" stamp,
135
+ // written by `doctrina decision land`; 3.4). Both are checked for
136
+ // dangling citations; an accepted ADR is only flagged bare when
137
+ // BOTH are empty. ADRs that adopt neither header opt out entirely.
110
138
  const evidence = listHeader(text, "Evidence");
111
- if (evidence !== null) {
112
- const adrStatus = (listHeader(text, "Status") ?? "").toLowerCase();
113
- const trimmed = evidence.trim();
114
- const bare = trimmed === "—" || trimmed === "-" || trimmed === "";
115
- if (bare) {
116
- if (adrStatus === "accepted") {
139
+ const landed = listHeader(text, "Landed");
140
+ const isBare = (v) => {
141
+ const t = (v ?? "").trim();
142
+ return t === "" || t === "—" || t === "-";
143
+ };
144
+ for (const [field, value] of [["Evidence", evidence], ["Landed", landed]]) {
145
+ if (value === null || isBare(value)) continue;
146
+ for (const token of extractBacktickPaths(value)) {
147
+ const candidates = [path.resolve(path.dirname(f), token), path.resolve(projectRoot, token)];
148
+ if (!candidates.some(exists)) {
117
149
  warnings.push(
118
- `${relPath(projectRoot, f)}: accepted ADR cites no implementation evidence ` +
119
- `(link the file(s) that prove it, or note "n/a — <why>"); a decision with ` +
120
- `nothing behind it may be drift waiting to be superseded`,
150
+ `${relPath(projectRoot, f)}: ${field} cites \`${token}\` which is missing on disk — ` +
151
+ `the decision may have drifted from the code (update it or supersede the ADR)`,
121
152
  );
122
153
  }
123
- } else {
124
- for (const token of extractBacktickPaths(evidence)) {
125
- const candidates = [path.resolve(path.dirname(f), token), path.resolve(projectRoot, token)];
126
- if (!candidates.some(exists)) {
127
- warnings.push(
128
- `${relPath(projectRoot, f)}: Evidence cites \`${token}\` which is missing on disk — ` +
129
- `the decision may have drifted from the code (update the evidence or supersede the ADR)`,
130
- );
131
- }
132
- }
133
154
  }
134
155
  }
156
+ const adrStatus = (listHeader(text, "Status") ?? "").toLowerCase();
157
+ if (adrStatus === "accepted" && evidence !== null && isBare(evidence) && isBare(landed)) {
158
+ warnings.push(
159
+ `${relPath(projectRoot, f)}: accepted ADR cites no implementation evidence ` +
160
+ `(link the file(s) that prove it, run \`doctrina decision land ${num ?? "NNNN"}\` once ` +
161
+ `it ships, or note "n/a — <why>"); a decision with nothing behind it may be drift ` +
162
+ `waiting to be superseded`,
163
+ );
164
+ }
165
+ }
166
+
167
+ // 5c. Duplicate ADR numbers (C2).
168
+ for (const [num, files] of adrByNumber) {
169
+ if (files.length > 1) {
170
+ errors.push(
171
+ `ADR number ${num} is claimed by ${files.length} files (${files.join(", ")}) — ` +
172
+ `two branches likely allocated it concurrently; renumber all but one ` +
173
+ `(the index keys decisions by number, so duplicates silently shadow each other)`,
174
+ );
175
+ }
135
176
  }
136
177
  }
137
178
 
@@ -4,6 +4,7 @@ import { readdirSync } from "node:fs";
4
4
  import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
5
5
  import { flagString } from "../lib/args.js";
6
6
  import { c } from "../lib/colors.js";
7
+ import { assessBrief } from "../lib/clarity.js";
7
8
  import { changeNew } from "./change.js";
8
9
 
9
10
  // `work` is the second half of the no-ceremony path (ADR 0005): a brief
@@ -58,9 +59,10 @@ export async function run(positional, flags) {
58
59
 
59
60
  const matches = pinned ? [] : rankCapabilities(projectRoot, prompt);
60
61
  const capability = pinned ?? matches[0]?.id ?? null;
62
+ const clarity = assessBrief(prompt, { kind: "prompt" });
61
63
 
62
64
  console.log("");
63
- printPlaybook(projectRoot, { id, prompt, pinned, matches, capability });
65
+ printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity });
64
66
  return 0;
65
67
  }
66
68
 
@@ -150,12 +152,21 @@ export function rankCapabilities(projectRoot, prompt) {
150
152
  return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, 3);
151
153
  }
152
154
 
153
- function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability }) {
155
+ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity }) {
154
156
  const capToken = capability ?? "<capability>";
155
157
  console.log(c.bold(`Work playbook — change ${id}`) + c.gray(" — agent-executed (ADR 0005)."));
156
158
  console.log("");
157
159
  console.log(`Prompt: "${prompt}"`);
158
160
 
161
+ // Clarification gate (review Topic A): a thin prompt is the moment to ask
162
+ // the user, not to invent a spec. Advisory — the change is still scaffolded
163
+ // (it is a draft), but the agent is told to resolve the gaps first.
164
+ if (clarity?.thin) {
165
+ console.log("");
166
+ console.log(c.yellow("⚠ thin prompt — clarify with the user before writing spec deltas:"));
167
+ for (const r of clarity.reasons) console.log(` - ${r}`);
168
+ }
169
+
159
170
  if (pinned) {
160
171
  const hasSpec = isFile(path.join(projectRoot, ".doctrina", "specs", pinned, "spec.md"));
161
172
  console.log(`Capability (pinned): ${c.cyan(pinned)}` +
@@ -214,7 +225,8 @@ Turn a brief prompt into a scaffolded change plus the work playbook the
214
225
  host AI agent executes (see ADR 0005). The CLI derives a sequential
215
226
  change id (NNNN-<slug>) from the prompt, opens the change folder,
216
227
  records the prompt under the proposal's "## Why", ranks existing specs
217
- by term overlap as a capability hint, and prints the ordered steps:
228
+ by term overlap as a capability hint, flags a thin/under-specified prompt
229
+ so you clarify before writing deltas (advisory), and prints the steps:
218
230
  context → spec delta → tasks → implement → analyze → apply → verify
219
231
  (verify + coverage) → archive → validate. No natural-language
220
232
  interpretation happens in the CLI.
package/src/index.js CHANGED
@@ -1,11 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import process from "node:process";
3
- import path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { readFileSync } from "node:fs";
6
3
  import { parseArgs } from "./lib/args.js";
7
4
  import { c } from "./lib/colors.js";
8
5
  import { suggest } from "./lib/suggest.js";
6
+ import { cliVersion } from "./lib/version.js";
9
7
 
10
8
  import * as init from "./commands/init.js";
11
9
  import * as spec from "./commands/spec.js";
@@ -27,11 +25,12 @@ import * as work from "./commands/work.js";
27
25
  import * as coverage from "./commands/coverage.js";
28
26
  import * as verify from "./commands/verify.js";
29
27
  import * as contract from "./commands/contract.js";
28
+ import * as trace from "./commands/trace.js";
30
29
 
31
30
  const COMMANDS = {
32
31
  init, spec, change, decision, validate, hooks, analyze, clarify,
33
32
  templates, skill, index: indexCmd, next, metrics, context, search,
34
- intake, work, coverage, verify, contract,
33
+ intake, work, coverage, verify, contract, trace,
35
34
  };
36
35
 
37
36
  const TOP_HELP = `
@@ -51,6 +50,7 @@ Commands:
51
50
  contract check Verify port collisions, env drift, referenced specs
52
51
  decision new Create the next sequentially numbered ADR
53
52
  decision accept Flip a proposed ADR to accepted
53
+ decision land Record that an accepted ADR is now implemented (non-mutating)
54
54
  decision supersede Create a new ADR that supersedes an existing one
55
55
  decision list List ADRs with status, date, and title
56
56
  skill new Scaffold an on-demand procedural memory skill
@@ -62,6 +62,7 @@ Commands:
62
62
  search Search the artifact tree, grouped by category
63
63
  validate Run schema and structural checks
64
64
  coverage Report acceptance criteria with linked evidence (--strict gates)
65
+ trace Report intent provenance: product intent → specs (--strict gates)
65
66
  verify Run project-declared typecheck/test/build checks (the real gate)
66
67
  templates list List the templates shipped by the installed CLI
67
68
  templates check Compare the project against the recommended template shape
@@ -81,7 +82,7 @@ async function main(argv) {
81
82
  });
82
83
 
83
84
  if (flags.get("version") || flags.get("v")) {
84
- console.log(readVersion());
85
+ console.log(cliVersion());
85
86
  return 0;
86
87
  }
87
88
 
@@ -119,14 +120,4 @@ async function main(argv) {
119
120
  }
120
121
  }
121
122
 
122
- function readVersion() {
123
- const here = path.dirname(fileURLToPath(import.meta.url));
124
- const pkgPath = path.resolve(here, "..", "package.json");
125
- try {
126
- return JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0";
127
- } catch {
128
- return "0.0.0";
129
- }
130
- }
131
-
132
123
  main(process.argv.slice(2)).then((code) => process.exit(code ?? 0));
@@ -0,0 +1,49 @@
1
+ // Deterministic under-specification detector for free-text briefs and
2
+ // intakes — the clarification gate (review Topic A). It does NOT judge
3
+ // meaning (that stays the agent's job, ADR 0005); it flags input that is
4
+ // structurally too thin to spec from, so `intake` and `work` prompt the
5
+ // agent to clarify with the user before speculating a spec or change out of
6
+ // too little. Advisory only: it surfaces the gap, it does not block.
7
+
8
+ const WEASEL = /\b(might|could|perhaps|maybe|probably|somehow|approximately|roughly|etc)\b/gi;
9
+ const VAGUE = /\b(some|several|various|appropriate|robust|scalable|nice|good|better|fast|simple|stuff|things?)\b/gi;
10
+
11
+ // Generic verbs/nouns that carry no capability information on their own. A
12
+ // brief made only of these ("add the feature", "do the thing") names nothing
13
+ // concrete to build.
14
+ const FILLER = new Set([
15
+ "the", "and", "for", "with", "this", "that", "something", "anything",
16
+ "make", "add", "create", "build", "implement", "feature", "thing",
17
+ "things", "stuff", "task", "work", "please", "just", "some", "any",
18
+ ]);
19
+
20
+ // Assess a brief/intake. `kind` tunes the thresholds: a one-line work prompt
21
+ // is held to a lower bar than a full project intake.
22
+ export function assessBrief(text, { kind = "prompt" } = {}) {
23
+ const clean = String(text ?? "").replace(/\s+/g, " ").trim();
24
+ const tokens = clean ? clean.split(" ") : [];
25
+ const words = tokens.length;
26
+ const vague = (clean.match(VAGUE) ?? []).length + (clean.match(WEASEL) ?? []).length;
27
+ const meaningful = tokens
28
+ .map((t) => t.toLowerCase().replace(/[^a-z0-9]/g, ""))
29
+ .filter((t) => t.length >= 3 && !FILLER.has(t));
30
+
31
+ const minWords = kind === "intake" ? 20 : 3;
32
+ const maxVague = kind === "intake" ? 4 : 2;
33
+
34
+ const reasons = [];
35
+ if (words < minWords) {
36
+ reasons.push(
37
+ `only ${words} word${words === 1 ? "" : "s"} — say what the behaviour is, ` +
38
+ `what the inputs are, and how you'll know it's done`,
39
+ );
40
+ }
41
+ if (words > 0 && meaningful.length === 0) {
42
+ reasons.push("no concrete terms once filler words are removed — name the actual capability or behaviour");
43
+ }
44
+ if (vague >= maxVague) {
45
+ reasons.push(`${vague} vague/weasel term${vague === 1 ? "" : "s"} — replace with definite, checkable wording`);
46
+ }
47
+
48
+ return { thin: reasons.length > 0, words, reasons };
49
+ }
@@ -1,7 +1,8 @@
1
1
  import path from "node:path";
2
2
  import { read, write, exists } from "./fs-ops.js";
3
+ import { cliVersion } from "./version.js";
3
4
 
4
- const SCHEMA_VERSION = "0.1.0";
5
+ export const SCHEMA_VERSION = "0.1.0";
5
6
 
6
7
  export function indexPath(projectRoot) {
7
8
  return path.join(projectRoot, ".doctrina", "index.json");
@@ -21,6 +22,11 @@ export function load(projectRoot) {
21
22
  }
22
23
 
23
24
  export function save(projectRoot, index) {
25
+ // Stamp the managing framework version on every write (3.6): the index
26
+ // records which CLI last wrote it, so a stale stamp (an index written by an
27
+ // older CLI) is detectable by `doctrina validate` and migrated by
28
+ // `doctrina index rebuild`.
29
+ index.framework_version = cliVersion();
24
30
  const p = indexPath(projectRoot);
25
31
  const text = JSON.stringify(index, null, 2) + "\n";
26
32
  write(p, text, { force: true });
@@ -30,7 +36,7 @@ export function blank(projectName, date) {
30
36
  return {
31
37
  $schema_version: SCHEMA_VERSION,
32
38
  project: projectName,
33
- framework_version: "0.0.0",
39
+ framework_version: cliVersion(),
34
40
  last_updated: date,
35
41
  artifacts: {
36
42
  product: {
package/src/lib/scan.js CHANGED
@@ -81,6 +81,12 @@ export function deriveIndex(projectRoot, current) {
81
81
  const implRaw = specHeader(text, "Implementation");
82
82
  const impl = implRaw ? implRaw.trim() : prev?.implementation;
83
83
  if (impl) entry.implementation = impl;
84
+ // Realizes: the product intent anchors this capability delivers (ADR
85
+ // 0006). Stored as an id list so the provenance link is queryable, not
86
+ // just prose. Header wins over the previous index, like Implementation.
87
+ const realizesRaw = specHeader(text, "Realizes");
88
+ const realizes = realizesRaw ? (realizesRaw.match(/[A-Z]+\d+/g) ?? []) : prev?.realizes;
89
+ if (realizes && realizes.length) entry.realizes = realizes;
84
90
  out.artifacts.specs.push(entry);
85
91
  }
86
92
 
@@ -104,6 +110,8 @@ export function deriveIndex(projectRoot, current) {
104
110
  if (supersedes && supersedes !== "—") entry.supersedes = supersedes;
105
111
  const supersededBy = listHeader(text, "Superseded by");
106
112
  if (supersededBy && supersededBy !== "—") entry.superseded_by = supersededBy;
113
+ const landed = listHeader(text, "Landed");
114
+ if (landed && landed !== "—") entry.landed = landed;
107
115
  out.artifacts.decisions.push(entry);
108
116
  }
109
117
 
@@ -0,0 +1,21 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ // Single source for "which CLI version is running." Read from the package
6
+ // manifest so it tracks the published version with no extra wiring. Used to
7
+ // stamp index.json (so an artifact tree records the framework that manages
8
+ // it — `doctrina validate` warns and `index rebuild` migrates when the stamp
9
+ // falls behind the running CLI) and to print `doctrina --version`.
10
+
11
+ const here = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ export function cliVersion() {
14
+ // src/lib/version.js -> packages/doctrina-cli/package.json
15
+ const pkgPath = path.resolve(here, "..", "..", "package.json");
16
+ try {
17
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0";
18
+ } catch {
19
+ return "0.0.0";
20
+ }
21
+ }
@@ -6,6 +6,7 @@
6
6
  - **Supersedes:** —
7
7
  - **Superseded by:** —
8
8
  - **Evidence:** —
9
+ - **Landed:** —
9
10
 
10
11
  <!--
11
12
  Evidence anchors the decision to reality. Once accepted, cite the file(s)
@@ -14,6 +15,13 @@ that prove this decision is actually implemented, in backticks, e.g.
14
15
  `doctrina validate` warns when cited evidence is missing on disk (the
15
16
  decision drifted from the code) or when an accepted ADR cites none. For a
16
17
  process decision with no code artifact, write "n/a — <why>".
18
+
19
+ Landed is the non-mutating "this decision is now implemented and verified
20
+ here" stamp. Many ADRs are accepted at design time with "Evidence: n/a — no
21
+ implementation yet"; once the capability ships, run
22
+ "doctrina decision land NNNN `path/to/proof.ts` ..." to record the date and
23
+ proof without editing the immutable body. A filled Landed satisfies the
24
+ accepted-ADR evidence check.
17
25
  -->
18
26
 
19
27
  ## Context
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema_version": "0.1.0",
3
3
  "project": "{{PROJECT_NAME}}",
4
- "framework_version": "0.0.0",
4
+ "framework_version": "{{FRAMEWORK_VERSION}}",
5
5
  "last_updated": "{{DATE}}",
6
6
  "artifacts": {
7
7
  "product": {
@@ -28,7 +28,12 @@ Out of scope (deferred or rejected):
28
28
 
29
29
  ## Success criteria
30
30
 
31
- <!-- How you will know this project is working. Prefer measurable signals. -->
31
+ <!--
32
+ How you will know this project is working. Prefer measurable signals.
33
+ Tag a bullet with a short stable id — "- [SC1] ..." — to make it an intent
34
+ anchor that capability specs cite via "**Realizes:** SC1" (`doctrina trace`;
35
+ ADR 0006). Untagged bullets are fine; anchors are opt-in.
36
+ -->
32
37
 
33
38
  ## Delivery order (walking skeleton)
34
39
 
@@ -17,6 +17,10 @@ An "active" spec left at "planned" is an inventory claim with nothing
17
17
  behind it, and `doctrina validate` warns. To record a deliberate gap and
18
18
  silence the warning, add a note after the value, e.g.
19
19
  "**Implementation:** planned — backend deferred to Q3, see ADR 0007".
20
+
21
+ Optional: trace this capability to product intent by adding a header
22
+ "**Realizes:** SC1, SC3" that names the product.md anchors it delivers
23
+ (`doctrina trace`; ADR 0006).
20
24
  -->
21
25
 
22
26
  ## Purpose