doctrina-cli 0.3.0 → 0.6.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,24 @@ 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)
25
+ doctrina work --resume <id> reprint an open change's playbook (no new change)
26
+ doctrina work --from-diff backfill a spec from working-tree changes (code-first)
27
+ doctrina work "<prompt>" --chore spec-less chore (infra/docs/build): no spec-delta steps
23
28
  doctrina spec new <capability> create a capability spec
24
29
  doctrina spec list list specs with version, status, size
30
+ doctrina spec set <cap> [--bump ...] edit spec headers/criterion + resync index (no lockstep)
25
31
  doctrina change new <id> "<title>" open a change proposal
26
- doctrina change apply <id> apply spec deltas (ADDED/REMOVED auto)
32
+ doctrina change apply <id> apply spec deltas (ADDED/REMOVED auto; MODIFIED ops block applies)
27
33
  doctrina change archive <id> archive an applied change
34
+ doctrina change abandon <id> delete an open change + its index entry (ledgered)
28
35
  doctrina change diff <id> preview spec deltas (line diff for MODIFIED)
36
+ doctrina contract new <name> own the integration surface (ports, env, interfaces)
37
+ doctrina contract check verify port collisions, env drift, referenced specs
29
38
  doctrina decision new "<title>" create the next sequential ADR
30
39
  doctrina decision accept <num> flip a proposed ADR to accepted
40
+ doctrina decision land <num> [path...] stamp an accepted ADR as implemented (non-mutating Landed:)
31
41
  doctrina decision supersede <num> "<t>" supersede an existing ADR
32
42
  doctrina decision list list ADRs with status, date, title
33
43
  doctrina skill new <name> scaffold an on-demand procedural-memory skill
@@ -36,8 +46,12 @@ doctrina skill sync mirror skill frontmatter descriptions
36
46
  doctrina analyze <change-id> inspect a change folder before applying
37
47
  doctrina clarify <path> smell-test a Markdown file for ambiguity (--all: tree)
38
48
  doctrina context [<capability>] print the context pack in read order (--concat)
39
- doctrina search <term> [...] search artifacts, grouped by category
40
- doctrina validate schema + artifact-existence checks
49
+ doctrina search <term> [...] search artifacts, grouped by category (ranked)
50
+ doctrina validate schema + artifact-existence + index-drift checks (--fix rebuilds)
51
+ doctrina coverage acceptance criteria with linked evidence (--strict gates; skips = conditional)
52
+ doctrina trace intent provenance: product intent -> specs (--strict gates)
53
+ doctrina verify run project typecheck/test/build checks (the real gate)
54
+ doctrina verify --clean reproducibility lint: clean-checkout footguns (dist/codegen)
41
55
  doctrina templates list enumerate shipped templates
42
56
  doctrina templates check compare project against recommended template shape
43
57
  doctrina templates update additive fixer for check findings (preview; --write applies)
@@ -84,10 +98,17 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
84
98
  - **ADDED:** writes the delta body to the target spec; refuses if the
85
99
  target already exists.
86
100
  - **REMOVED:** deletes the target spec.
87
- - **MODIFIED:** prints a manual-merge pointer and does not write. The
88
- user merges the delta into the target spec by hand. This is
89
- deliberate; auto-merging is the kind of implicit decision Doctrina
90
- avoids.
101
+ - **MODIFIED:** when the delta carries a fenced ` ```ops ` block
102
+ (`set-header` / `bump-version` / `set-criterion` / `replace-criterion`
103
+ / `append-criterion`), applies it mechanically to the target spec — all
104
+ ops or none — and regenerates the index (ADR 0007). Without an ops
105
+ block it prints a manual-merge pointer and does not write: auto-merging
106
+ arbitrary prose is the kind of implicit decision Doctrina avoids.
107
+
108
+ On any spec write, the index is rebuilt from the tree so it never drifts
109
+ from the applied spec. `doctrina change abandon <id>` is the inverse of
110
+ `change new`: it deletes an open change and its index entry and ledgers
111
+ the abandonment.
91
112
 
92
113
  ## `validate` checks
93
114
 
@@ -105,12 +126,28 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
105
126
  12. Skills carry the required frontmatter triple (`name`, `description`, `when`).
106
127
  13. Skills are ≤ 200 lines (warns at > 150).
107
128
  14. Each skill's `name:` matches its filename slug.
108
- 15. Each spec's `Version:` header matches the version in `index.json` (drift warn).
129
+ 15. Indexed artifact metadata (spec/decision/change/contract) matches its
130
+ file — a mismatch is an **error** so a green `validate` can't hide the
131
+ drift `index rebuild --check` would catch (G5; `validate --fix` rebuilds).
109
132
  16. Each skill's frontmatter description matches `index.json` (drift warn; `skill sync` fixes).
110
133
  17. EARS grammar shape per section in specs declaring `## Requirements (EARS)` (warn).
111
134
  18. Nested `AGENTS.md` files obey the root size caps (warn > 150, error > 200).
112
-
113
- Exit code: 0 if no errors (warnings allowed), 1 otherwise.
135
+ 19. No two ADR files share the same `NNNN` number (merge-collision error).
136
+ 20. `index.json` records a `framework_version` matching the running CLI
137
+ (stamp-divergence warn; `index rebuild` migrates it).
138
+ 21. Accepted ADRs anchor to reality via `Evidence:` and/or `Landed:`; cited
139
+ paths must exist on disk (drift warn).
140
+
141
+ Exit code: 0 if no errors (warnings allowed), 1 otherwise. `--fix`
142
+ regenerates `index.json` from the tree before validating.
143
+
144
+ Two related read-only reports gate separately: `doctrina coverage`
145
+ (each acceptance criterion cites a real test; a criterion proven only by
146
+ a skipped suite is `conditional` and fails `--strict`) and `doctrina
147
+ trace` (each product intent has a capability, each capability traces to
148
+ an intent). Both exit 1 under `--strict` for CI. `doctrina verify
149
+ --clean` statically lints for clean-checkout footguns (a build-dir entry
150
+ point with no `prepare`; a Prisma dep with no generate on install).
114
151
 
115
152
  ## Tests
116
153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.3.0",
3
+ "version": "0.6.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -4,12 +4,14 @@ import { exists, isDir, lineCount, mkdirp, move, read, relPath, remove, walk, wr
4
4
  import { diffLines, formatUnified } from "../lib/diff.js";
5
5
  import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "../lib/templates.js";
6
6
  import * as idx from "../lib/index-json.js";
7
+ import { deriveIndex } from "../lib/scan.js";
8
+ import { extractOps, applyOps } from "../lib/spec-ops.js";
7
9
  import { today } from "../lib/dates.js";
8
- import { flagBool } from "../lib/args.js";
10
+ import { flagBool, flagString } from "../lib/args.js";
9
11
  import { c } from "../lib/colors.js";
10
12
  import { suggest } from "../lib/suggest.js";
11
13
 
12
- const SUBCOMMANDS = ["new", "apply", "archive", "diff"];
14
+ const SUBCOMMANDS = ["new", "apply", "archive", "diff", "abandon"];
13
15
 
14
16
  export async function run(positional, flags) {
15
17
  const sub = positional[0];
@@ -22,6 +24,8 @@ export async function run(positional, flags) {
22
24
  return changeArchive(positional.slice(1), flags);
23
25
  case "diff":
24
26
  return changeDiff(positional.slice(1), flags);
27
+ case "abandon":
28
+ return changeAbandon(positional.slice(1), flags);
25
29
  default:
26
30
  console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
27
31
  const guess = suggest(sub, SUBCOMMANDS);
@@ -45,6 +49,11 @@ function changeNew(args, flags) {
45
49
  }
46
50
 
47
51
  const force = flagBool(flags, "force", false);
52
+ // A chore is a spec-less change (infra / docs / build / migration) — review
53
+ // G9. It runs the full proposal → apply → archive → ledger lifecycle (so the
54
+ // history shows it) without forcing a fake spec delta. The empty specs/ dir
55
+ // is kept so `change apply`'s zero-delta path flips it to applied unchanged.
56
+ const chore = flagBool(flags, "chore", false) || flagBool(flags, "no-spec", false);
48
57
  const projectRoot = process.cwd();
49
58
  ensureDoctrinaProject(projectRoot);
50
59
 
@@ -66,14 +75,29 @@ function changeNew(args, flags) {
66
75
  }
67
76
  mkdirp(path.join(changeDir, "specs"));
68
77
 
78
+ // Stamp the proposal so a chore is honest in the artifact, not just the CLI.
79
+ if (chore) {
80
+ const proposalPath = path.join(changeDir, "proposal.md");
81
+ if (exists(proposalPath)) {
82
+ const txt = read(proposalPath);
83
+ const updated = txt.replace(/^(-\s+\*\*Affects specs:\*\*).*$/m, "$1 (none — chore)");
84
+ if (updated !== txt) write(proposalPath, updated, { force: true });
85
+ }
86
+ }
87
+
69
88
  const index = idx.load(projectRoot);
70
89
  idx.addChange(index, { id, title, path: `.doctrina/changes/${id}`, status: "proposed", opened: date });
71
90
  idx.touch(index, date);
72
91
  idx.save(projectRoot, index);
73
92
 
74
93
  console.log("");
75
- console.log(c.bold("Change opened.") + " Add spec deltas under " +
76
- c.cyan(`.doctrina/changes/${id}/specs/<capability>/delta.md`));
94
+ if (chore) {
95
+ console.log(c.bold("Chore opened.") + " No spec deltas expected — implement, check the tasks, then " +
96
+ c.cyan(`doctrina change apply ${id}`) + " and " + c.cyan(`doctrina change archive ${id}`) + ".");
97
+ } else {
98
+ console.log(c.bold("Change opened.") + " Add spec deltas under " +
99
+ c.cyan(`.doctrina/changes/${id}/specs/<capability>/delta.md`));
100
+ }
77
101
  return 0;
78
102
  }
79
103
 
@@ -133,8 +157,30 @@ function changeApply(args, _flags) {
133
157
  writes += 1;
134
158
  console.log(c.green("applied[REMOVED]") + ` ${relPath(projectRoot, targetPath)}`);
135
159
  } else if (op === "MODIFIED") {
136
- console.log(c.yellow("manual[MODIFIED]") + ` merge ${relPath(projectRoot, deltaPath)} into ${relPath(projectRoot, targetPath)} by hand`);
137
- manual += 1;
160
+ // A MODIFIED delta is applied mechanically when it carries a structured
161
+ // `ops` block (set-header / bump-version / set-criterion / ...; F3);
162
+ // otherwise it falls back to the historical manual-merge pointer, so
163
+ // prose deltas keep working unchanged.
164
+ const ops = extractOps(text);
165
+ if (ops.length === 0) {
166
+ console.log(c.yellow("manual[MODIFIED]") + ` merge ${relPath(projectRoot, deltaPath)} into ${relPath(projectRoot, targetPath)} by hand (no ops block)`);
167
+ manual += 1;
168
+ } else if (!exists(targetPath)) {
169
+ console.error(c.red("error:") + ` MODIFIED delta targets missing spec ${relPath(projectRoot, targetPath)}`);
170
+ errors += 1;
171
+ } else {
172
+ const result = applyOps(read(targetPath), ops);
173
+ if (result.errors.length > 0) {
174
+ console.error(c.red("error:") + ` ${result.errors.length} operation error${result.errors.length === 1 ? "" : "s"} in ${rel} — spec left untouched:`);
175
+ for (const e of result.errors) console.error(` - ${e}`);
176
+ errors += 1;
177
+ } else {
178
+ write(targetPath, result.text, { force: true });
179
+ writes += 1;
180
+ console.log(c.green("applied[MODIFIED]") + ` ${relPath(projectRoot, targetPath)} (${result.applied.length} op${result.applied.length === 1 ? "" : "s"})`);
181
+ for (const s of result.applied) console.log(c.gray(` · ${s}`));
182
+ }
183
+ }
138
184
  } else {
139
185
  console.error(c.red("error:") + ` unknown Operation "${op}" in ${rel}`);
140
186
  errors += 1;
@@ -144,6 +190,7 @@ function changeApply(args, _flags) {
144
190
  // Flip proposal status from "proposed" to "applied" when fully successful.
145
191
  // Zero-delta changes are trivially successful (no work to fail), so they
146
192
  // also flip — otherwise metadata-only changes are stuck at "proposed".
193
+ let flippedToApplied = false;
147
194
  if (errors === 0 && manual === 0) {
148
195
  const proposalPath = path.join(changeDir, "proposal.md");
149
196
  if (exists(proposalPath)) {
@@ -155,32 +202,23 @@ function changeApply(args, _flags) {
155
202
  if (updated !== txt) {
156
203
  write(proposalPath, updated, { force: true });
157
204
  console.log(c.green("status") + " proposal.md → applied");
205
+ flippedToApplied = true;
158
206
  }
159
207
  }
160
208
  }
161
209
 
162
- // Update index for ADDED/REMOVED that affected specs
163
- if (writes > 0) {
164
- 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);
180
- }
181
- }
182
- idx.touch(index, date);
183
- idx.save(projectRoot, index);
210
+ // Update the index whenever specs changed (ADDED / REMOVED / MODIFIED-ops)
211
+ // or the proposal flipped to applied. The files are the source of truth, so
212
+ // rather than patch entry by entry — the old path that let a spec's
213
+ // version/implementation text drift from the index, and forced a manual
214
+ // lockstep bump (F3 / G5 / G7 / G8) — the index is rebuilt from the
215
+ // now-updated tree in one shot. This keeps `index rebuild --check` green
216
+ // immediately after apply.
217
+ if (writes > 0 || flippedToApplied) {
218
+ const current = idx.load(projectRoot);
219
+ const derived = deriveIndex(projectRoot, current);
220
+ derived.last_updated = date;
221
+ idx.save(projectRoot, derived);
184
222
  }
185
223
 
186
224
  console.log("");
@@ -293,6 +331,52 @@ function changeArchive(args, flags) {
293
331
  return 0;
294
332
  }
295
333
 
334
+ // Abandon an open change: delete the folder AND the index entry in one step,
335
+ // and record the abandonment in the ledger so the history shows the dead end
336
+ // instead of a silent gap. This is the missing inverse of `change new` — the
337
+ // review had to delete a stray folder and hand-edit index.json (G6).
338
+ function changeAbandon(args, flags) {
339
+ const id = args[0];
340
+ if (!id) {
341
+ console.error(c.red("error:") + " change abandon requires <id>");
342
+ return 2;
343
+ }
344
+ const projectRoot = process.cwd();
345
+ ensureDoctrinaProject(projectRoot);
346
+
347
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
348
+ if (!isDir(changeDir)) {
349
+ console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
350
+ return 1;
351
+ }
352
+
353
+ const reason = flagString(flags, "reason") ?? "";
354
+ const date = today();
355
+
356
+ remove(changeDir);
357
+ console.log(c.green("removed") + ` ${relPath(projectRoot, changeDir)}`);
358
+
359
+ // Ledger line so the abandonment is part of the visible history.
360
+ const ledgerPath = path.join(projectRoot, ".doctrina", "changes", "archive", "LEDGER.md");
361
+ if (!exists(ledgerPath)) {
362
+ write(ledgerPath,
363
+ "# Change ledger\n\n" +
364
+ "One line per archived change, newest last. Appended by\n" +
365
+ "`doctrina change archive`; edit freely, the CLI only appends.\n\n");
366
+ }
367
+ const reasonSuffix = reason ? ` — ${reason}` : "";
368
+ write(ledgerPath, read(ledgerPath) + `- ${date} — ${id} — abandoned${reasonSuffix}\n`, { force: true });
369
+ console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerPath)}`);
370
+
371
+ // Drop the change entry; rebuild from the tree so nothing drifts.
372
+ const current = idx.load(projectRoot);
373
+ const derived = deriveIndex(projectRoot, current);
374
+ derived.last_updated = date;
375
+ idx.save(projectRoot, derived);
376
+ console.log(c.green("indexed") + ` change "${id}" removed from index.json`);
377
+ return 0;
378
+ }
379
+
296
380
  function changeDiff(args, _flags) {
297
381
  const id = args[0];
298
382
  if (!id) {
@@ -436,13 +520,31 @@ Usage: doctrina change <subcommand> [args]
436
520
 
437
521
  Subcommands:
438
522
  new <id> "<title>" Open a new change proposal at .doctrina/changes/<id>/
439
- apply <id> Apply spec deltas (ADDED writes, REMOVED deletes, MODIFIED is manual)
523
+ (--chore / --no-spec: a spec-less change for infra /
524
+ docs / build that still gets a proposal + ledger)
525
+ apply <id> Apply spec deltas: ADDED writes, REMOVED deletes,
526
+ MODIFIED with an \`\`\`ops block is applied mechanically
527
+ (set-header / bump-version / set-criterion / ...),
528
+ MODIFIED without one prints a manual-merge pointer.
529
+ On any spec write the index is rebuilt from the tree.
440
530
  archive <id> Move the change to .doctrina/changes/archive/YYYY-MM-DD-<id>/
531
+ abandon <id> Delete an open change folder and its index entry, and
532
+ record the abandonment in the ledger ([--reason "..."]).
441
533
  diff <id> Preview every spec delta: line diff for MODIFIED,
442
534
  summary for ADDED/REMOVED. Read-only.
443
535
 
536
+ A MODIFIED delta may carry a fenced operations block that \`apply\` executes:
537
+ \`\`\`ops
538
+ set-header Implementation: verified — durable adapter (\`src/db.ts\`)
539
+ bump-version minor
540
+ set-criterion 1: verified
541
+ append-criterion [unverified] new signal — verified by \`test/x.test.ts\`
542
+ \`\`\`
543
+
444
544
  Options:
445
545
  --force Overwrite existing files (where applicable)
546
+ --chore, --no-spec With new, open a spec-less chore change
547
+ --reason "<text>" With abandon, the reason recorded in the ledger
446
548
  `;
447
549
 
448
550
  // Re-export parsers so scan.js (index rebuild) can reuse them, and the
@@ -15,9 +15,10 @@ import { c } from "../lib/colors.js";
15
15
  // a backtick path span, e.g.
16
16
  // 1. Returns HTTP 429 above the quota — verified by `test/quota.test.ts`.
17
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.
18
+ // real proof, "conditional" when its only resolving proof is a test file
19
+ // whose suite is skipped (a `describe.skip`/`xit`/`@pytest.mark.skip` that
20
+ // proves nothing — review G3: existence ≠ a passing test), "dangling" when
21
+ // a cited path does not resolve, and "bare" when nothing is cited.
21
22
 
22
23
  export async function run(_positional, flags) {
23
24
  const projectRoot = process.cwd();
@@ -47,6 +48,7 @@ export async function run(_positional, flags) {
47
48
  let totalCriteria = 0;
48
49
  let totalCovered = 0;
49
50
  let totalDangling = 0;
51
+ let totalConditional = 0;
50
52
 
51
53
  console.log(c.bold("Coverage") + c.gray(" — acceptance criteria with linked evidence:"));
52
54
  console.log("");
@@ -54,16 +56,23 @@ export async function run(_positional, flags) {
54
56
  for (const rep of reports) {
55
57
  const covered = rep.rows.filter((r) => r.kind === "covered").length;
56
58
  const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
59
+ const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
57
60
  totalCriteria += rep.rows.length;
58
61
  totalCovered += covered;
59
62
  totalDangling += dangling;
63
+ totalConditional += conditional;
60
64
 
61
- const danglingNote = dangling > 0 ? c.yellow(` (${dangling} dangling)`) : "";
62
- console.log(` ${c.cyan(rep.cap.padEnd(20))} ${covered}/${rep.rows.length} criteria${danglingNote}`);
65
+ const notes = [];
66
+ if (conditional > 0) notes.push(c.yellow(`${conditional} conditional`));
67
+ if (dangling > 0) notes.push(c.yellow(`${dangling} dangling`));
68
+ const note = notes.length > 0 ? ` (${notes.join(", ")})` : "";
69
+ console.log(` ${c.cyan(rep.cap.padEnd(20))} ${covered}/${rep.rows.length} criteria${note}`);
63
70
  for (const r of rep.rows) {
64
71
  if (r.kind === "covered") continue;
65
72
  if (r.kind === "bare") {
66
73
  console.log(` ${c.red("✗")} #${r.n} no evidence linked — cite the file/test that proves it in backticks`);
74
+ } else if (r.kind === "conditional") {
75
+ console.log(` ${c.yellow("!")} #${r.n} evidence is a skipped test (proves nothing): ${r.skipped.map((m) => `\`${m}\``).join(", ")}`);
67
76
  } else {
68
77
  console.log(` ${c.yellow("!")} #${r.n} evidence not found on disk: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
69
78
  }
@@ -73,14 +82,18 @@ export async function run(_positional, flags) {
73
82
  const pct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
74
83
  console.log("");
75
84
  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;
85
+ const clean = totalCovered === totalCriteria && totalDangling === 0 && totalConditional === 0;
77
86
  if (clean) {
78
87
  console.log(c.green("ok") + " " + summary);
79
88
  return 0;
80
89
  }
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).
90
+ const extras = [];
91
+ if (totalConditional > 0) extras.push(`${totalConditional} conditional`);
92
+ if (totalDangling > 0) extras.push(`${totalDangling} dangling`);
93
+ const extraSummary = extras.length > 0 ? `, ${extras.join(", ")}` : "";
94
+ console.log((strict ? c.red("fail") : c.yellow("gap")) + " " + summary + extraSummary);
95
+ // A report by default (exit 0); a gate under --strict (exit 1 for CI). A
96
+ // conditional criterion fails the gate too: a skipped test is not proof.
84
97
  return strict ? 1 : 0;
85
98
  }
86
99
 
@@ -121,22 +134,60 @@ function extractAcceptanceCriteria(text) {
121
134
  return out.filter((s) => s.length > 0);
122
135
  }
123
136
 
124
- // Decide whether a single criterion is covered, dangling, or bare.
137
+ // Decide whether a single criterion is covered, conditional, dangling, or bare.
125
138
  function classify(criterion, n, projectRoot, specDir) {
126
139
  const cited = extractBacktickPaths(criterion);
127
140
  if (cited.length === 0) return { kind: "bare", n };
128
141
  const missing = [];
129
- let resolved = 0;
142
+ const resolved = [];
130
143
  for (const token of cited) {
131
144
  const candidates = [
132
145
  path.resolve(projectRoot, token),
133
146
  path.resolve(specDir, token),
134
147
  ];
135
- if (candidates.some(exists)) resolved += 1;
136
- else missing.push(token);
148
+ const hit = candidates.find(exists);
149
+ if (!hit) {
150
+ missing.push(token);
151
+ continue;
152
+ }
153
+ const isTest = looksLikeTestFile(token);
154
+ const skipped = isTest && isFile(hit) ? testSuiteIsSkipped(read(hit)) : false;
155
+ resolved.push({ token, isTest, skipped });
156
+ }
157
+ if (resolved.length === 0) return { kind: "dangling", n, missing };
158
+ // Real proof = a non-test artifact, or a test file whose suite runs. If the
159
+ // only thing that resolves is a skipped test, the criterion is conditional.
160
+ const hasRealProof = resolved.some((r) => !r.isTest || !r.skipped);
161
+ if (hasRealProof) return { kind: "covered", n };
162
+ return { kind: "conditional", n, skipped: resolved.map((r) => r.token) };
163
+ }
164
+
165
+ // A cited path is a test file when it sits under a tests directory or carries
166
+ // a test/spec/e2e filename marker — the only files skip-detection applies to.
167
+ function looksLikeTestFile(token) {
168
+ if (/(?:^|\/)(?:tests?|__tests__|specs?|e2e)\//i.test(token)) return true;
169
+ if (/(?:\.|_|-)(?:test|spec|e2e)\.[a-z0-9]+$/i.test(token)) return true;
170
+ if (/(?:^|\/)test_[^/]+\.py$/i.test(token)) return true;
171
+ return false;
172
+ }
173
+
174
+ // Heuristic, dependency-free "is this whole test file gated off?" check: the
175
+ // first test construct in the file is a skip/todo (e.g. the Prisma e2e suite
176
+ // wrapped in `describe.skip`). A file whose first suite runs is treated as
177
+ // real proof even if it has an incidental `it.skip` later — that keeps the
178
+ // false-positive rate low without parsing the file. Python skip decorators
179
+ // and a module-level `pytest.skip` count too.
180
+ function testSuiteIsSkipped(text) {
181
+ const m = text.match(
182
+ /\b(x(?:describe|context|it|test)|(?:describe|context|suite|it|test|specify)\s*\.\s*(?:skip|todo)|(?:describe|context|suite|it|test|specify))\s*\(/,
183
+ );
184
+ if (m) {
185
+ const head = m[1];
186
+ if (/^x/.test(head) || /\.\s*(?:skip|todo)/.test(head)) return true;
137
187
  }
138
- if (resolved > 0) return { kind: "covered", n };
139
- return { kind: "dangling", n, missing };
188
+ if (/@(?:pytest\.mark\.skip|unittest\.skip)\b/.test(text)) return true;
189
+ if (/^\s*pytest\.skip\s*\(/m.test(text)) return true;
190
+ return false;
140
191
  }
141
192
 
142
193
  // Backtick spans that look like repository file paths. Mirrors the
@@ -169,11 +220,13 @@ Report how many acceptance criteria across .doctrina/specs/ cite an
169
220
  artifact or test that exists on disk. Each numbered criterion may cite
170
221
  its evidence as a backtick path span, e.g. \`test/quota.test.ts\`.
171
222
 
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.
223
+ A criterion is covered when a cited path resolves to real proof,
224
+ conditional when its only resolving proof is a test file whose suite is
225
+ skipped (\`describe.skip\` / \`xit\` / \`@pytest.mark.skip\` — proves
226
+ nothing), dangling when a cited path is missing on disk, and bare when
227
+ nothing is cited. Read-only.
175
228
 
176
229
  Flags:
177
- --strict Exit 1 when any criterion is bare or dangling (CI gate).
178
- Without it the command always exits 0 (a report).
230
+ --strict Exit 1 when any criterion is bare, dangling, or conditional
231
+ (CI gate). Without it the command always exits 0 (a report).
179
232
  `;
@@ -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]));