doctrina-cli 0.17.0 → 0.18.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.17.0 — current release. Zero runtime dependencies
6
+ **Status:** v0.18.0 — current release. Zero runtime dependencies
7
7
  (Node.js standard library only).
8
8
 
9
9
  ## Install
@@ -25,7 +25,7 @@ surface, by the moment you reach for it:
25
25
  ```
26
26
  Bootstrap init · intake · adapter list|add|remove
27
27
  Orient prime · next · status · context · show · search · why · handoff
28
- Change triage · work · spec new|list|set · change new|apply|archive|check|tick|abandon
28
+ Change triage · work · spec new|list|set · change new|apply|archive|check|tick|abandon|delta
29
29
  contract new|list|check · decision new|accept|land|supersede|list|scope
30
30
  skill new|list|suggest · intent add|list
31
31
  Gate clarify · validate · coverage · trace · review · verify · close · doctor
@@ -72,14 +72,17 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
72
72
  - **REMOVED:** deletes the target spec.
73
73
  - **MODIFIED:** when the delta carries a fenced ` ```ops ` block
74
74
  (`set-header` / `bump-version` / `set-criterion` / `replace-criterion`
75
- / `append-criterion` / `append-requirement` / `replace-requirement`),
75
+ / `append-criterion` / `append-requirement` / `replace-requirement` /
76
+ `remove-requirement` / `remove-criterion`),
76
77
  applies it mechanically to the target spec — all
77
78
  ops or none — and regenerates the index (ADR 0007). Without an ops
78
79
  block it prints a manual-merge pointer and does not write: auto-merging
79
80
  arbitrary prose is the kind of implicit decision Doctrina avoids.
80
81
 
81
82
  On any spec write, the index is rebuilt from the tree so it never drifts
82
- from the applied spec. `doctrina change abandon <id>` is the inverse of
83
+ from the applied spec. `doctrina change delta <id> <capability>` writes the
84
+ ops lines for you (`--requirement`, `--criterion`, `--bump`), dry-run
85
+ against the spec before anything is written. `doctrina change abandon <id>` is the inverse of
83
86
  `change new`: it deletes an open change and its index entry and ledgers
84
87
  the abandonment. `doctrina close <id>` runs apply together with verify,
85
88
  coverage, archive and validate in one attested pass — the definition of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -19,14 +19,14 @@ import { confirm, isInteractive } from "../lib/prompt.js";
19
19
  import { EXIT } from "../lib/exit-codes.js";
20
20
  import { ensureDoctrinaProject, refuseChangeRef } from "../lib/project.js";
21
21
  import { parseOperation, parseCapabilityFromDelta, isUntouchedScaffold } from "../lib/doc-model.js";
22
- import { changeNew } from "../lib/change-ops.js";
22
+ import { changeNew, addDeltaOps } from "../lib/change-ops.js";
23
23
 
24
- const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon"];
24
+ const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "delta"];
25
25
 
26
26
  // Flags this command accepts. Declared HERE, with the command, so
27
27
  // adding a command never requires editing the entrypoint — the gap that
28
28
  // let six flags ship undeclared and silently swallow a positional (C3).
29
- export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec", "verbose"], string: ["reason"] };
29
+ export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec", "verbose"], string: ["reason", "requirement", "criterion", "bump", "op"] };
30
30
 
31
31
  export async function run(positional, flags) {
32
32
  const sub = positional[0];
@@ -44,6 +44,8 @@ export async function run(positional, flags) {
44
44
  return changeTick(positional.slice(1), flags);
45
45
  case "abandon":
46
46
  return await changeAbandon(positional.slice(1), flags);
47
+ case "delta":
48
+ return changeDelta(positional.slice(1), flags);
47
49
  default:
48
50
  console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
49
51
  const guess = suggest(sub, SUBCOMMANDS);
@@ -398,6 +400,49 @@ async function changeCheck(id, { verbose = false } = {}) {
398
400
  // `tick <id> --all` checks everything (tasks.md + the proposal's
399
401
  // ## Verification). Ticking is a claim of completion — the honest gate is
400
402
  // still archive/verify; this only removes the mechanical friction.
403
+ // `change delta <id> <capability> [ops]` — write ops lines into the change's
404
+ // MODIFIED delta through the grammar `apply` reads (change 0205). Rendering
405
+ // only; lib/change-ops.js decides.
406
+ function changeDelta(args, flags) {
407
+ const [id, capability, ...rest] = args;
408
+ if (!id || !capability) {
409
+ console.error(c.red("error:") + " change delta requires <id> <capability> and at least one operation");
410
+ console.error(c.gray("hint: ") + "doctrina change delta <id> <capability> --requirement \"event: When <trigger>, the system shall <action>.\" --criterion \"[unverified] <signal> — verified by `<test>`\" --bump minor");
411
+ return EXIT.USAGE;
412
+ }
413
+ const refused = refuseChangeRef(id);
414
+ if (refused !== null) return refused;
415
+ const projectRoot = process.cwd();
416
+ ensureDoctrinaProject(projectRoot);
417
+ if (!isDir(path.join(projectRoot, ".doctrina", "changes", id))) {
418
+ console.error(c.red("error:") + ` change "${id}" not found at .doctrina/changes/${id}`);
419
+ return EXIT.USAGE;
420
+ }
421
+ // In the order a reader expects them: requirement, criterion, any raw op,
422
+ // and the bump last.
423
+ const lines = [];
424
+ const requirement = flagString(flags, "requirement");
425
+ const criterion = flagString(flags, "criterion");
426
+ const raw = flagString(flags, "op") ?? (rest.length > 0 ? rest.join(" ") : undefined);
427
+ const bump = flagString(flags, "bump");
428
+ if (requirement) lines.push(`append-requirement ${requirement}`);
429
+ if (criterion) lines.push(`append-criterion ${criterion}`);
430
+ if (raw) lines.push(raw);
431
+ if (bump) lines.push(`bump-version ${bump}`);
432
+
433
+ const r = addDeltaOps(projectRoot, id, capability, lines);
434
+ if (r.code !== EXIT.OK) {
435
+ console.error(c.red("error:") + ` ${r.error}`);
436
+ for (const d of r.details ?? []) console.error(` - ${d}`);
437
+ if (r.hint) console.error(c.gray("hint: ") + r.hint);
438
+ return r.code;
439
+ }
440
+ console.log(c.green(r.created ? "created" : "updated") + ` ${r.deltaRel}` +
441
+ c.gray(` — ${lines.length} op${lines.length === 1 ? "" : "s"} added; the block now holds ${r.block.length} (${r.block.join(", ")}), and applies cleanly`));
442
+ console.log(c.gray("Preview the merge: ") + c.cyan(`doctrina change check ${id} --verbose`));
443
+ return EXIT.OK;
444
+ }
445
+
401
446
  function changeTick(args, flags) {
402
447
  const id = args[0];
403
448
  if (!id) {
@@ -768,6 +813,13 @@ Subcommands:
768
813
  or every one with --all. No args = list only.
769
814
  abandon <id> Delete an open change folder and its index entry, and
770
815
  record the abandonment in the ledger ([--reason "..."]).
816
+ delta <id> <capability> [--requirement "<section>: <text>"] [--criterion "<text>"]
817
+ [--bump major|minor|patch] [--op "<op line>" | <op line>]
818
+ Write ops into the change's MODIFIED delta for the
819
+ capability — created from the template when absent —
820
+ parsed by the grammar apply reads and dry-run against
821
+ the spec first: nothing is written unless the whole
822
+ block applies. A second --bump replaces the first.
771
823
 
772
824
  apply / archive / check accept multiple ids (batch close of a backlog); the
773
825
  exit code is the worst per-id result.
@@ -780,9 +832,12 @@ A MODIFIED delta may carry a fenced operations block that \`apply\` executes:
780
832
  append-criterion [unverified] new signal — verified by \`test/x.test.ts\`
781
833
  append-requirement event: When <trigger>, the system shall <action>.
782
834
  replace-requirement ubiquitous 2: The system shall <action>.
835
+ remove-requirement state 1: superseded by event 3
836
+ remove-criterion 4: proven by criterion 2 since 0.9
783
837
  \`\`\`
784
838
  append-* ops resolve numbering/position at apply time, so concurrent open
785
- changes appending to the same spec cannot collide.
839
+ changes appending to the same spec cannot collide. \`change delta\` writes
840
+ these lines for you, one call per addition.
786
841
 
787
842
  Options:
788
843
  --force Overwrite existing files (where applicable)
@@ -2,18 +2,18 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { appendFileSync } from "node:fs";
5
- import { exists, isDir, isFile, read, walk } from "../lib/fs-ops.js";
5
+ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
6
6
  import { flagBool } from "../lib/args.js";
7
7
  import { c } from "../lib/colors.js";
8
- import { parseCapabilityFromDelta } from "../lib/doc-model.js";
9
8
  import { printAdrCheckpoint, acceptedDecisionCount } from "../lib/adr-guard.js";
10
9
  import { docsRemedy, checkDocsImpact, checkChangelogImpact } from "../lib/docs-impact.js";
11
10
  import { collectRuntimeFindings } from "../lib/runtime.js";
12
11
  import { derivedImplementations, implementationMismatch, summarize } from "../lib/coverage-model.js";
13
- import { specHeader, dependentsOf } from "../lib/scan.js";
12
+ import { specHeader, dependentsOf, deltaCapabilities } from "../lib/scan.js";
14
13
  import { sequence, sequenceLabels, stepRerun } from "../lib/gates.js";
15
14
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
16
15
  import { refuseChangeRef } from "../lib/project.js";
16
+ import { unfinishedChanges } from "../lib/snapshot.js";
17
17
  import * as analyze from "./analyze.js";
18
18
  import * as change from "./change.js";
19
19
  import * as verify from "./verify.js";
@@ -23,7 +23,7 @@ import * as validate from "./validate.js";
23
23
  import * as indexRebuild from "./index-rebuild.js";
24
24
  import { appendLedgerLine, docsGapLine, ledgerPath as ledgerFile } from "../lib/ledger.js";
25
25
  import * as review from "./review.js";
26
- import * as skill from "./skill.js";
26
+ import { skillCandidates } from "../lib/skill-candidates.js";
27
27
 
28
28
  // One-command close (review 2026-06-27 passive-user feature #2). The work
29
29
  // playbook lists the closing sequence — analyze → apply → runtime → verify →
@@ -65,9 +65,62 @@ export async function run(positional, flags) {
65
65
  ? c.green("ok") + ` all ${positional.length} changes closed`
66
66
  : c.red("fail") + " at least one close stopped — see above");
67
67
  }
68
+ reportSkills(projectRoot, positional);
69
+ reportUnfinished(projectRoot, positional);
68
70
  return worst;
69
71
  }
70
72
 
73
+ // Capture the lesson while it is fresh (operator review §4.7) — the lesson
74
+ // THIS close produced (change 0202). The close used to print the whole
75
+ // `skill suggest` listing, per id: the same thirteen candidates after every
76
+ // close, which a field review of 0.17 named as noise nobody read. Now a
77
+ // candidate born from a change just closed gets its line; every other one is
78
+ // a count pointing at the full listing. Once per run, batch or not.
79
+ // Advisory — a listing, never a gate, and a failure never un-closes anything.
80
+ function reportSkills(projectRoot, closing) {
81
+ let candidates;
82
+ try {
83
+ candidates = skillCandidates(projectRoot);
84
+ } catch {
85
+ return;
86
+ }
87
+ if (candidates.length === 0) return;
88
+ const ids = new Set(closing);
89
+ const fresh = candidates.filter((cand) => cand.source === "change" && ids.has(cand.id));
90
+ const waiting = candidates.length - fresh.length;
91
+ console.log("");
92
+ console.log(c.gray("──── skills (advisory)"));
93
+ for (const cand of fresh) {
94
+ console.log(c.yellow(" ! ") + `${cand.id} is fix-shaped — capture the lesson while it is fresh: ` +
95
+ c.cyan(`doctrina skill new ${cand.slug}`));
96
+ }
97
+ if (waiting > 0) {
98
+ console.log(c.gray(` ${waiting} ${fresh.length > 0 ? "older " : ""}candidate${waiting === 1 ? "" : "s"} waiting — \`doctrina skill suggest\``));
99
+ }
100
+ }
101
+
102
+ // The other open changes, said at the one moment someone is looking (change
103
+ // 0201). A field review of 0.17: change 0078 shipped, stayed open, and no
104
+ // close after it said a word. A change that looks finished — applied, or
105
+ // every task ticked — and one open a week or more get a line each; the rest
106
+ // are a count. Advisory: it never moves the exit code.
107
+ function reportUnfinished(projectRoot, closing) {
108
+ const others = unfinishedChanges(projectRoot, { exclude: closing });
109
+ if (others.length === 0) return;
110
+ const flagged = others.filter((ch) => ch.reason !== null);
111
+ console.log("");
112
+ console.log(c.gray(`──── other open changes (advisory) — ${others.length}`));
113
+ for (const ch of flagged) {
114
+ const age = ch.ageDays !== null ? `, opened ${ch.ageDays}d ago` : "";
115
+ const how = ch.finished ? ` — ${c.cyan(`doctrina close ${ch.id}`)}` : "";
116
+ console.log(c.yellow(" ! ") + `${ch.id}: ${ch.reason}${age}${how}`);
117
+ }
118
+ const quiet = others.length - flagged.length;
119
+ if (quiet > 0) {
120
+ console.log(c.gray(` ${quiet} more open and recent — \`doctrina next\``));
121
+ }
122
+ }
123
+
71
124
  async function closeOne(projectRoot, id, flags) {
72
125
  const refused = refuseChangeRef(id);
73
126
  if (refused !== null) return refused;
@@ -138,13 +191,17 @@ async function closeOne(projectRoot, id, flags) {
138
191
  },
139
192
 
140
193
  // The review runs against the working tree, which for a close IS the
141
- // change's diff: the work is done and not yet archived. Its exit code is
194
+ // change's diff: the work is done and not yet archived. It is told which
195
+ // change it is reviewing (change 0200): the deltas it carries are the
196
+ // spec updates, merged one step later by `apply`, and code under a
197
+ // capability the change carries no delta for is one line, not one
198
+ // break per capability. Its exit code is
142
199
  // discarded on purpose — the step is declared advisory, and an advisory
143
200
  // step that could still move the close's result would be advisory in name
144
201
  // only. A crash is caught by the loop and reported the same way.
145
202
  review: {
146
203
  run: async () => {
147
- await review.run([], new Map());
204
+ await review.run([], new Map([["change", id]]));
148
205
  return 0;
149
206
  },
150
207
  },
@@ -237,7 +294,10 @@ async function closeOne(projectRoot, id, flags) {
237
294
  // verify is conditional: skipped with a loud note when no verify.json is
238
295
  // declared, because a project may not have wired the real gate yet.
239
296
  verify: verifyConfigured
240
- ? { run: () => verify.run([], new Map()) }
297
+ // --reuse (change 0203): the suite the agent just ran by hand, or the
298
+ // one the previous id of this batch ran, on byte-identical code, is
299
+ // not run a second time. Anything that differs runs everything.
300
+ ? { run: () => verify.run([], new Map([["reuse", true]])) }
241
301
  : { skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
242
302
 
243
303
  coverage: {
@@ -270,7 +330,9 @@ async function closeOne(projectRoot, id, flags) {
270
330
  // one session with an empty `## [Unreleased]` behind them.
271
331
  const log = checkChangelogImpact(projectRoot, changeDir);
272
332
  if (log.ok) {
273
- console.log(c.green("ok") + ` ${log.reason}`);
333
+ // One line when both answers are the same sentence (change 0202):
334
+ // "touches no documented surface" twice read as two checks.
335
+ if (log.reason !== r.reason) console.log(c.green("ok") + ` ${log.reason}`);
274
336
  return 0;
275
337
  }
276
338
  console.error(c.red("error:") + ` this change ${log.reason}:`);
@@ -404,16 +466,6 @@ async function closeOne(projectRoot, id, flags) {
404
466
  }
405
467
  }
406
468
 
407
- // Capture the lesson while it is fresh (operator review §4.7): surface the
408
- // fix-shaped lessons not yet written as skills, right where the operator
409
- // just proved they finish what they start. Advisory — a listing, never a
410
- // gate, and a suggest failure never un-closes the change.
411
- console.log("");
412
- console.log(c.gray("──── skills (advisory)"));
413
- try {
414
- await skill.run(["suggest"], new Map());
415
- } catch { /* advisory only */ }
416
-
417
469
  console.log("");
418
470
  console.log(c.green(`✓ change ${id} closed`) + c.gray(closingClaim(ran, skippedSteps)));
419
471
  console.log(c.gray("Next: ") + c.cyan("doctrina next"));
@@ -484,14 +536,7 @@ function reportDependentCoverage(projectRoot, touched) {
484
536
  }
485
537
 
486
538
  function touchedCapabilities(projectRoot, id) {
487
- const specsDir = path.join(projectRoot, ".doctrina", "changes", id, "specs");
488
- const caps = new Set();
489
- for (const deltaPath of walk(specsDir)) {
490
- if (!deltaPath.endsWith("delta.md")) continue;
491
- const cap = parseCapabilityFromDelta(read(deltaPath), deltaPath);
492
- if (cap) caps.add(cap);
493
- }
494
- return [...caps].sort();
539
+ return deltaCapabilities(path.join(projectRoot, ".doctrina", "changes", id));
495
540
  }
496
541
 
497
542
  // The sequence is DECLARED once, in lib/gates.js, and rendered here. It used
@@ -522,7 +567,9 @@ first failure with the exact command to rerun:
522
567
 
523
568
  ${SEQUENCE}
524
569
 
525
- then a closing skill-suggest listing (advisory).
570
+ then two advisory notes, once per run: the skill candidate a just-closed
571
+ fix-shaped change produced (the others are a count), and the other open
572
+ changes that look finished or are a week old.
526
573
 
527
574
  The coverage gate is scoped to the capabilities the change's deltas touch,
528
575
  so a deliberately deferred spec elsewhere cannot block an unrelated close;
@@ -82,6 +82,7 @@ export const RUNNERS = {
82
82
  "change check": () => import("./change.js"),
83
83
  "change apply": () => import("./change.js"),
84
84
  "change archive": () => import("./change.js"),
85
+ close: () => import("./close.js"),
85
86
  "index rebuild": () => import("./index-rebuild.js"),
86
87
  };
87
88
 
@@ -8,7 +8,8 @@ import { c } from "../lib/colors.js";
8
8
  import { rankCapabilitiesByDiff } from "../lib/work-model.js";
9
9
  import { readLedger, churnByCapability } from "../lib/ledger.js";
10
10
  import { changedFiles, windowCutoff, historyState, refExists } from "../lib/git.js";
11
- import { dependentsOf } from "../lib/scan.js";
11
+ import { dependentsOf, deltaCapabilities } from "../lib/scan.js";
12
+ import { refuseChangeRef } from "../lib/project.js";
12
13
  import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
13
14
  import { summarize as coverageSummary } from "../lib/coverage-model.js";
14
15
  import { summarize as traceSummary } from "../lib/trace-model.js";
@@ -37,7 +38,7 @@ const CHURN_NOTABLE = 3;
37
38
  // bury every other finding under a file listing.
38
39
  const ORPHAN_LIST_LIMIT = 5;
39
40
 
40
- export const flags = { boolean: ["json", "strict"], string: ["diff"] };
41
+ export const flags = { boolean: ["json", "strict"], string: ["diff", "change"] };
41
42
 
42
43
  export async function run(_positional, flags) {
43
44
  const projectRoot = process.cwd();
@@ -60,8 +61,26 @@ export async function run(_positional, flags) {
60
61
  return EXIT.USAGE;
61
62
  }
62
63
 
64
+ // --change <id>: review the tree AS that change's close sees it (change
65
+ // 0200). Its deltas are the spec updates — merged one step later, by
66
+ // `apply` — so the capabilities they target count as updated.
67
+ const changeId = flagString(flags, "change");
68
+ /** @type {Set<string> | null} */
69
+ let changeCaps = null;
70
+ if (changeId !== undefined) {
71
+ const refused = refuseChangeRef(changeId);
72
+ if (refused !== null) return refused;
73
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", changeId);
74
+ if (!isDir(changeDir)) {
75
+ console.error(c.red("error:") + ` change "${changeId}" not found at .doctrina/changes/${changeId}`);
76
+ return EXIT.USAGE;
77
+ }
78
+ changeCaps = new Set(deltaCapabilities(changeDir));
79
+ }
80
+
63
81
  const { all, sourceFiles } = reviewScope(projectRoot, against);
64
- console.log(c.bold("Review") + c.gray(against ? ` — vs ${against}` : " — working-tree changes"));
82
+ console.log(c.bold("Review") + c.gray(against ? ` — vs ${against}` : " — working-tree changes") +
83
+ (changeId ? c.gray(` · change ${changeId}`) : ""));
65
84
  console.log("");
66
85
 
67
86
  if (all.length === 0) {
@@ -85,10 +104,18 @@ export async function run(_positional, flags) {
85
104
  );
86
105
  const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity });
87
106
  const touched = new Set([...specChanged, ...ranked.map((m) => m.id)]);
88
- for (const m of ranked) {
89
- if (!specChanged.has(m.id)) {
90
- breaks.push(`code under capability "${m.id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${m.id}/spec.md\` still describes the code (update it, or note why not)`);
107
+ const stale = ranked.map((m) => m.id).filter((id) => !specChanged.has(id) && !changeCaps?.has(id));
108
+ if (changeCaps === null) {
109
+ for (const id of stale) {
110
+ breaks.push(`code under capability "${id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${id}/spec.md\` still describes the code (update it, or note why not)`);
91
111
  }
112
+ } else if (stale.length > 0) {
113
+ // Reviewed as a change: code under capabilities it carries no delta for
114
+ // is either this change forgetting a delta or other work lying in the
115
+ // tree — the review cannot tell which, so it says both, ONCE. One break
116
+ // per capability, repeated on every close, is how the warning became
117
+ // noise nobody read (field review, 0.17).
118
+ breaks.push(`code changed under ${stale.length === 1 ? "a capability" : `${stale.length} capabilities`} this change carries no delta for: ${stale.map((id) => `"${id}"`).join(", ")} — if this change made those edits, add a delta (or note why the spec still holds); if other work did, it is not this change's (commit or stash it)`);
92
119
  }
93
120
 
94
121
  // 1b. Dependents of touched capabilities (the machine-readable
@@ -107,7 +134,10 @@ export async function run(_positional, flags) {
107
134
  if (touched.size > 0) {
108
135
  const since = windowCutoff(CHURN_WINDOW_DAYS);
109
136
  const churn = churnByCapability(readLedger(projectRoot).entries, { since })
110
- .filter((row) => touched.has(row.capability) && row.changes >= CHURN_NOTABLE);
137
+ // Reviewed as a change, only the capabilities it updates: churn is
138
+ // context for the spec being changed, and four lines of it about
139
+ // ground the change merely stands near was more noise per close.
140
+ .filter((row) => (changeCaps ?? touched).has(row.capability) && row.changes >= CHURN_NOTABLE);
111
141
  for (const row of churn) {
112
142
  notes.push(`capability "${row.capability}" landed ${row.changes} changes in the last ${CHURN_WINDOW_DAYS} days (last ${row.last}) — history, not a verdict: read it as "this area is moving", not "this area is wrong"`);
113
143
  }
@@ -219,7 +249,7 @@ function runContractCheck(projectRoot) {
219
249
  }
220
250
 
221
251
  export const help = `
222
- Usage: doctrina review [--diff <ref>] [--strict]
252
+ Usage: doctrina review [--diff <ref>] [--change <id>] [--strict]
223
253
 
224
254
  Deterministic conformance review of your changes against the spec / ADR /
225
255
  contract tree. By default it reviews the working-tree changes (tracked +
@@ -232,6 +262,15 @@ citing missing proof, product intent realized by no spec, and contract
232
262
  port/reference collisions. It checks conformance shape, NOT semantic
233
263
  fidelity (whether the code does what the spec says stays a human/LLM call).
234
264
 
265
+ --change <id> reviews the tree as that change's close does: the capabilities
266
+ its deltas target count as updated (the close merges them one step later),
267
+ and code under capabilities it carries no delta for is reported in one line —
268
+ this change forgot a delta, or other work is lying in the tree.
269
+
270
+ A file's owner is the spec whose \`**Source:**\` claims it; failing that, the
271
+ spec citing its exact path; failing both, the path and basename inference,
272
+ from specs that declare no Source.
273
+
235
274
  Read-only. Exits 0 as a report; with --strict, exits 1 when any hard break
236
275
  exists (CI gate). Notes (advisory) never fail the gate.
237
276
  `;
@@ -1,7 +1,6 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { readdirSync } from "node:fs";
5
4
  import { exists, isDir, isFile, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
6
5
  import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
7
6
  import * as idx from "../lib/index-json.js";
@@ -9,10 +8,10 @@ import { today } from "../lib/dates.js";
9
8
  import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
10
9
  import { c } from "../lib/colors.js";
11
10
  import { suggest } from "../lib/suggest.js";
11
+ import { skillCandidates } from "../lib/skill-candidates.js";
12
12
  import { notADoctrinaProject } from "../lib/exit-codes.js";
13
13
  import { parseFrontmatter } from "../lib/doc-model.js";
14
- import { git, GIT_STATE } from "../lib/git.js";
15
- import { FIX_SHAPED, FIX_SHAPED_SUBJECT, fold, skillSlug } from "../lib/lexicon.js";
14
+ import { fold, skillSlug } from "../lib/lexicon.js";
16
15
  import { artifactNameError } from "../lib/names.js";
17
16
 
18
17
  const SUBCOMMANDS = ["new", "list", "sync", "suggest"];
@@ -237,57 +236,8 @@ function skillSuggest(args, flags) {
237
236
  return writeErrorDraft(projectRoot, draft, doWrite);
238
237
  }
239
238
 
239
+ const candidates = skillCandidates(projectRoot, { since, limit: GIT_LOG_LIMIT });
240
240
  const skillsDir = path.join(projectRoot, ".doctrina", "skills");
241
- const existingFiles = isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")) : [];
242
- const existing = new Set(existingFiles.map((f) => path.basename(f, ".md")));
243
- // A lesson may already be captured under a DIFFERENT slug (0.11.0 field
244
- // review item 11: the human names the skill after the lesson, not after the
245
- // change id — "tela-preta-crash-de-render..." for change 0049). Two extra
246
- // dedup signals: an existing skill whose body cites the candidate's change
247
- // id / commit hash, and token-overlap similarity between the slugs.
248
- const existingBodies = existingFiles.map((f) => ({ slug: path.basename(f, ".md"), body: read(f) }));
249
- const captured = (cand) => {
250
- if (existing.has(cand.slug)) return true;
251
- for (const sk of existingBodies) {
252
- if (cand.id && sk.body.includes(cand.id)) return true;
253
- if (cand.from && sk.body.includes(cand.from)) return true;
254
- if (slugSimilarity(cand.slug, sk.slug) >= 0.5) return true;
255
- }
256
- return false;
257
- };
258
-
259
- // Two deterministic sources, deduped by slug. `seen` guards against a lesson
260
- // appearing twice (e.g. a fix committed and later archived as a change).
261
- /** @type {SkillCandidate[]} */
262
- const candidates = [];
263
- const seen = new Set();
264
-
265
- // Source 1 — archived change proposals with a fix-shaped folder name.
266
- const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
267
- if (isDir(archiveDir)) {
268
- for (const name of readdirSync(archiveDir).sort()) {
269
- if (!isDir(path.join(archiveDir, name))) continue;
270
- const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
271
- if (!FIX_SHAPED.test(id)) continue;
272
- const slug = skillSlug(id);
273
- if (seen.has(slug)) continue;
274
- /** @type {SkillCandidate} */
275
- const cand = { id, slug, source: "change", from: name };
276
- if (captured(cand)) continue; // captured (any slug) or cited by an existing skill
277
- const proposal = path.join(archiveDir, name, "proposal.md");
278
- cand.why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
279
- seen.add(slug);
280
- candidates.push(cand);
281
- }
282
- }
283
-
284
- // Source 2 — fix-shaped commits in the git history (ADR 0013). Archive wins a
285
- // slug collision: a `## Why` paragraph is a richer seed than a subject line.
286
- for (const cand of gitFixCommits(projectRoot, { since, limit: GIT_LOG_LIMIT })) {
287
- if (seen.has(cand.slug) || captured(cand)) continue;
288
- seen.add(cand.slug);
289
- candidates.push(cand);
290
- }
291
241
 
292
242
  if (candidates.length === 0) {
293
243
  console.log(c.gray("no uncaptured fix-shaped lessons found in .doctrina/changes/archive/ or the git history"));
@@ -361,80 +311,6 @@ function seedLabel(cand) {
361
311
  : `Candidate from change "${cand.id}"`;
362
312
  }
363
313
 
364
- // Source 2 for `skill suggest`: fix-shaped commits in the git history (ADR
365
- // 0013). Deterministic — a regex on the subject, never a read of the diff
366
- // (ADR 0005 semantic-fidelity ceiling). Returns [] silently when this is not a
367
- // git repo, git is absent, or the --since ref is unknown, so the command
368
- // degrades to archive-only rather than erroring.
369
- function gitFixCommits(projectRoot, { since, limit }) {
370
- // US (0x1f) between fields, RS (0x1e) between records — robust against any
371
- // newline or punctuation inside a subject or body.
372
- const format = "%H%x1f%s%x1f%b%x1e";
373
- const args = ["log", "--no-merges", `--format=${format}`];
374
- if (since) args.push(`${since}..HEAD`);
375
- else args.push("-n", String(limit));
376
-
377
- const r = git(projectRoot, args);
378
- if (r.state !== GIT_STATE.OK || !r.stdout) return [];
379
-
380
- /** @type {SkillCandidate[]} */
381
- const out = [];
382
- const seen = new Set();
383
- for (const record of r.stdout.split("\x1e")) {
384
- const [sha, subject = "", body = ""] = record.replace(/^\r?\n/, "").split("\x1f");
385
- if (!sha || !sha.trim()) continue;
386
- if (!FIX_SHAPED_SUBJECT.test(subject)) continue;
387
- const slug = commitSlug(subject);
388
- if (!slug || seen.has(slug)) continue; // first commit wins a duplicate lesson
389
- seen.add(slug);
390
- const firstBodyLine = body.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
391
- out.push({
392
- id: slug,
393
- slug,
394
- source: "commit",
395
- ref: sha.trim().slice(0, 9),
396
- why: subject.trim() + (firstBodyLine ? ` — ${firstBodyLine}` : ""),
397
- });
398
- }
399
- return out;
400
- }
401
-
402
- // Derive a skill slug from a commit subject: drop a conventional-commit
403
- // "type(scope): " prefix and hand the rest to the shared slug, which folds.
404
- function commitSlug(subject) {
405
- return skillSlug(subject.replace(/^[a-z]+(?:\([^)]*\))?!?\s*:\s*/i, ""));
406
- }
407
-
408
- // Token-overlap similarity between two kebab slugs (Jaccard over meaningful
409
- // tokens). "0049-fix-tela-preta-render" vs "tela-preta-crash-de-render-sem-
410
- // error-boundary" share tela/preta/render → high overlap even though the
411
- // human renamed the skill after the lesson. Deterministic; never semantic.
412
- function slugSimilarity(a, b) {
413
- const toks = (s) => new Set(
414
- String(s).split("-").filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !SLUG_NOISE.has(t)),
415
- );
416
- const A = toks(a);
417
- const B = toks(b);
418
- if (A.size === 0 || B.size === 0) return 0;
419
- let inter = 0;
420
- for (const t of A) if (B.has(t)) inter += 1;
421
- return inter / (A.size + B.size - inter);
422
- }
423
-
424
- // Fix-shaped verbs and connectives carry no lesson identity — without this,
425
- // every "fix-..." candidate looks 30% similar to every "fix-..." skill.
426
- const SLUG_NOISE = new Set([
427
- "fix", "bug", "hotfix", "patch", "the", "and", "for", "with", "sem", "com",
428
- "que", "nao", "por", "para", "una", "uma", "dos", "das",
429
- ]);
430
-
431
- function firstWhyLine(proposalText) {
432
- const m = proposalText.match(/##\s*Why\s*\r?\n+([^\r\n][^\r\n]*)/i);
433
- if (!m) return "";
434
- const line = m[1].trim();
435
- return /^<!--/.test(line) ? "" : line;
436
- }
437
-
438
314
  function skillNew(args, flags) {
439
315
  const name = args[0];
440
316
  const nameError = artifactNameError(name, "skill name");