doctrina-cli 0.4.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
@@ -22,11 +22,16 @@ Requires Node.js 20.12 or later.
22
22
  doctrina init scaffold AGENTS.md + .doctrina/ in cwd
23
23
  doctrina intake [<file>] store the full description + bootstrap playbook (clarification gate)
24
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
25
28
  doctrina spec new <capability> create a capability spec
26
29
  doctrina spec list list specs with version, status, size
30
+ doctrina spec set <cap> [--bump ...] edit spec headers/criterion + resync index (no lockstep)
27
31
  doctrina change new <id> "<title>" open a change proposal
28
- 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)
29
33
  doctrina change archive <id> archive an applied change
34
+ doctrina change abandon <id> delete an open change + its index entry (ledgered)
30
35
  doctrina change diff <id> preview spec deltas (line diff for MODIFIED)
31
36
  doctrina contract new <name> own the integration surface (ports, env, interfaces)
32
37
  doctrina contract check verify port collisions, env drift, referenced specs
@@ -42,10 +47,11 @@ doctrina analyze <change-id> inspect a change folder before applyi
42
47
  doctrina clarify <path> smell-test a Markdown file for ambiguity (--all: tree)
43
48
  doctrina context [<capability>] print the context pack in read order (--concat)
44
49
  doctrina search <term> [...] search artifacts, grouped by category (ranked)
45
- doctrina validate schema + artifact-existence checks
46
- doctrina coverage acceptance criteria with linked evidence (--strict gates)
50
+ doctrina validate schema + artifact-existence + index-drift checks (--fix rebuilds)
51
+ doctrina coverage acceptance criteria with linked evidence (--strict gates; skips = conditional)
47
52
  doctrina trace intent provenance: product intent -> specs (--strict gates)
48
53
  doctrina verify run project typecheck/test/build checks (the real gate)
54
+ doctrina verify --clean reproducibility lint: clean-checkout footguns (dist/codegen)
49
55
  doctrina templates list enumerate shipped templates
50
56
  doctrina templates check compare project against recommended template shape
51
57
  doctrina templates update additive fixer for check findings (preview; --write applies)
@@ -92,10 +98,17 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
92
98
  - **ADDED:** writes the delta body to the target spec; refuses if the
93
99
  target already exists.
94
100
  - **REMOVED:** deletes the target spec.
95
- - **MODIFIED:** prints a manual-merge pointer and does not write. The
96
- user merges the delta into the target spec by hand. This is
97
- deliberate; auto-merging is the kind of implicit decision Doctrina
98
- 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.
99
112
 
100
113
  ## `validate` checks
101
114
 
@@ -113,7 +126,9 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
113
126
  12. Skills carry the required frontmatter triple (`name`, `description`, `when`).
114
127
  13. Skills are ≤ 200 lines (warns at > 150).
115
128
  14. Each skill's `name:` matches its filename slug.
116
- 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).
117
132
  16. Each skill's frontmatter description matches `index.json` (drift warn; `skill sync` fixes).
118
133
  17. EARS grammar shape per section in specs declaring `## Requirements (EARS)` (warn).
119
134
  18. Nested `AGENTS.md` files obey the root size caps (warn > 150, error > 200).
@@ -123,12 +138,16 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
123
138
  21. Accepted ADRs anchor to reality via `Evidence:` and/or `Landed:`; cited
124
139
  paths must exist on disk (drift warn).
125
140
 
126
- Exit code: 0 if no errors (warnings allowed), 1 otherwise.
141
+ Exit code: 0 if no errors (warnings allowed), 1 otherwise. `--fix`
142
+ regenerates `index.json` from the tree before validating.
127
143
 
128
144
  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.
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).
132
151
 
133
152
  ## Tests
134
153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.4.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;
@@ -161,38 +207,18 @@ function changeApply(args, _flags) {
161
207
  }
162
208
  }
163
209
 
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).
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.
169
217
  if (writes > 0 || flippedToApplied) {
170
- const index = idx.load(projectRoot);
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
- }
188
- }
189
- }
190
- if (flippedToApplied) {
191
- const entry = index.artifacts.changes.find((ch) => ch.id === id);
192
- if (entry) entry.status = "applied";
193
- }
194
- idx.touch(index, date);
195
- idx.save(projectRoot, index);
218
+ const current = idx.load(projectRoot);
219
+ const derived = deriveIndex(projectRoot, current);
220
+ derived.last_updated = date;
221
+ idx.save(projectRoot, derived);
196
222
  }
197
223
 
198
224
  console.log("");
@@ -305,6 +331,52 @@ function changeArchive(args, flags) {
305
331
  return 0;
306
332
  }
307
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
+
308
380
  function changeDiff(args, _flags) {
309
381
  const id = args[0];
310
382
  if (!id) {
@@ -448,13 +520,31 @@ Usage: doctrina change <subcommand> [args]
448
520
 
449
521
  Subcommands:
450
522
  new <id> "<title>" Open a new change proposal at .doctrina/changes/<id>/
451
- 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.
452
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 "..."]).
453
533
  diff <id> Preview every spec delta: line diff for MODIFIED,
454
534
  summary for ADDED/REMOVED. Read-only.
455
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
+
456
544
  Options:
457
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
458
548
  `;
459
549
 
460
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
  `;
@@ -3,14 +3,15 @@ import process from "node:process";
3
3
  import { readdirSync } from "node:fs";
4
4
  import { exists, isDir, isFile, lineCount, read, relPath, write } from "../lib/fs-ops.js";
5
5
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
6
- import { specHeader } from "../lib/scan.js";
6
+ import { specHeader, deriveIndex } from "../lib/scan.js";
7
+ import { setHeader, bumpVersion, setCriterionMark } from "../lib/spec-ops.js";
7
8
  import * as idx from "../lib/index-json.js";
8
9
  import { today } from "../lib/dates.js";
9
- import { flagBool } from "../lib/args.js";
10
+ import { flagBool, flagString } from "../lib/args.js";
10
11
  import { c } from "../lib/colors.js";
11
12
  import { suggest } from "../lib/suggest.js";
12
13
 
13
- const SUBCOMMANDS = ["new", "list"];
14
+ const SUBCOMMANDS = ["new", "list", "set"];
14
15
 
15
16
  export async function run(positional, flags) {
16
17
  const sub = positional[0];
@@ -23,6 +24,7 @@ export async function run(positional, flags) {
23
24
  return 2;
24
25
  }
25
26
  if (sub === "list") return specList();
27
+ if (sub === "set") return specSet(positional.slice(1), flags);
26
28
 
27
29
  const capability = positional[1];
28
30
  if (!capability || !/^[a-z][a-z0-9-]*$/.test(capability)) {
@@ -109,6 +111,82 @@ function specList() {
109
111
  return 0;
110
112
  }
111
113
 
114
+ // Edit a spec's bookkeeping headers (and a criterion mark) and resync the
115
+ // index in one step — the inverse drift footgun the review hit (G8): a manual
116
+ // "bump the version in the spec, then bump it again in index.json" lockstep.
117
+ // Reuses the same structured ops as `change apply` (ADR 0007), so the header
118
+ // and the index can never diverge after a `spec set`.
119
+ function specSet(args, flags) {
120
+ const capability = args[0];
121
+ if (!capability || !/^[a-z][a-z0-9-]*$/.test(capability)) {
122
+ console.error(c.red("error:") + " spec set requires a <capability> (lowercase letters, digits, hyphens)");
123
+ return 2;
124
+ }
125
+ const projectRoot = process.cwd();
126
+ ensureDoctrinaProject(projectRoot);
127
+
128
+ const specPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
129
+ if (!isFile(specPath)) {
130
+ console.error(c.red("error:") + ` no spec at ${relPath(projectRoot, specPath)} (create it with \`doctrina spec new ${capability}\`)`);
131
+ return 1;
132
+ }
133
+
134
+ const impl = flagString(flags, "implementation");
135
+ const status = flagString(flags, "status");
136
+ const bump = flagString(flags, "bump");
137
+ const criterion = flagString(flags, "criterion");
138
+ if (impl === undefined && status === undefined && bump === undefined && criterion === undefined) {
139
+ console.error(c.red("error:") + " spec set needs at least one of --implementation, --status, --bump, --criterion");
140
+ console.error(c.gray("hint: ") + `example: doctrina spec set ${capability} --implementation "verified — \`src/x.js\`" --bump minor`);
141
+ return 2;
142
+ }
143
+
144
+ let text = read(specPath);
145
+ const applied = [];
146
+ const errors = [];
147
+ const apply = (res) => {
148
+ if (res.error) errors.push(res.error);
149
+ else { text = res.text; applied.push(res.summary); }
150
+ };
151
+ // Order: headers, then version, then criterion — independent edits, all or
152
+ // none (a failed op leaves the spec untouched, like `change apply`).
153
+ if (status !== undefined) apply(setHeader(text, "Status", status));
154
+ if (impl !== undefined) apply(setHeader(text, "Implementation", impl));
155
+ if (bump !== undefined) {
156
+ if (!["major", "minor", "patch"].includes(bump)) errors.push(`--bump needs major|minor|patch (got "${bump}")`);
157
+ else apply(bumpVersion(text, bump));
158
+ }
159
+ if (criterion !== undefined) {
160
+ const m = String(criterion).match(/^(\d+)\s*:\s*(.+)$/);
161
+ if (!m) errors.push(`--criterion needs "<n>:<mark>" (got "${criterion}")`);
162
+ else apply(setCriterionMark(text, Number(m[1]), m[2].trim()));
163
+ }
164
+
165
+ if (errors.length > 0) {
166
+ console.error(c.red("error:") + ` ${errors.length} operation error${errors.length === 1 ? "" : "s"} — spec left untouched:`);
167
+ for (const e of errors) console.error(` - ${e}`);
168
+ return 1;
169
+ }
170
+
171
+ // Stamp Last updated when the header exists (best-effort, never an error).
172
+ const date = today();
173
+ const lu = setHeader(text, "Last updated", date);
174
+ if (!lu.error) text = lu.text;
175
+
176
+ write(specPath, text, { force: true });
177
+ console.log(c.green("updated") + ` ${relPath(projectRoot, specPath)}`);
178
+ for (const s of applied) console.log(c.gray(` · ${s}`));
179
+
180
+ // Single source of truth: regenerate the index from the now-updated tree so
181
+ // the spec headers and the index can never drift (no manual lockstep — G8).
182
+ const index = idx.load(projectRoot);
183
+ const derived = deriveIndex(projectRoot, index);
184
+ derived.last_updated = date;
185
+ idx.save(projectRoot, derived);
186
+ console.log(c.green("indexed") + ` spec "${capability}" synced`);
187
+ return 0;
188
+ }
189
+
112
190
  function ensureDoctrinaProject(projectRoot) {
113
191
  if (!exists(path.join(projectRoot, ".doctrina"))) {
114
192
  throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
@@ -125,9 +203,17 @@ Subcommands:
125
203
  list One line per spec: id, version,
126
204
  status, line count, last updated.
127
205
  Read-only.
206
+ set <capability> [opts] Edit a spec's headers / a criterion
207
+ mark and resync the index in one step
208
+ (no manual lockstep bump — G8).
128
209
 
129
210
  Options:
130
- --bug Scaffold the bug-shape template (current / expected /
131
- unchanged behaviour) instead of the EARS capability spec.
132
- --force Overwrite an existing spec.
211
+ --bug Scaffold the bug-shape template (current / expected /
212
+ unchanged behaviour) instead of the EARS capability spec.
213
+ --force Overwrite an existing spec (with \`new\`).
214
+ --implementation "..." With \`set\`: set the Implementation header.
215
+ --status "..." With \`set\`: set the Status header.
216
+ --bump major|minor|patch With \`set\`: bump the spec Version.
217
+ --criterion "<n>:<mark>" With \`set\`: set criterion n's [mark]
218
+ (e.g. --criterion "2:verified").
133
219
  `;