doctrina-cli 0.15.0 → 0.16.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.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -199
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +7 -75
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +121 -160
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +109 -6
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +210 -24
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +238 -11
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/contract.md.template +7 -0
  82. package/templates/hooks/pre-commit.sample +6 -2
  83. package/templates/playbooks/bootstrap.md.template +43 -0
  84. package/templates/playbooks/chore.md.template +19 -0
  85. package/templates/playbooks/work.md.template +76 -0
@@ -1,14 +1,11 @@
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
- import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
6
- import { listHeader } from "../lib/scan.js";
7
- import { collectStatus } from "./status.js";
8
- import { computeActions } from "./next.js";
9
- import { acceptedDecisions, productSection } from "./constitution.js";
10
- import { c } from "../lib/colors.js";
4
+ import { exists } from "../lib/fs-ops.js";
5
+ import { flagBool } from "../lib/args.js";
11
6
  import { notADoctrinaProject } from "../lib/exit-codes.js";
7
+ import { collectSnapshot } from "../lib/snapshot.js";
8
+ import { renderView } from "../lib/views.js";
12
9
 
13
10
  // Session primer: the ~40-line read that orients an agent at the start of a
14
11
  // session — where things stand, what the standing rules are, what work is
@@ -16,102 +13,30 @@ import { notADoctrinaProject } from "../lib/exit-codes.js";
16
13
  // It sits between `status` (numbers only) and `context --concat` (everything):
17
14
  // enough to act, cheap enough to run every session. Strictly read-only; every
18
15
  // line is assembled from artifacts that already own the fact (no new home).
16
+ //
17
+ // Since change 0037 the primer is a VIEW over lib/snapshot.js, rendered by
18
+ // lib/views.js and reachable as `doctrina status --view prime`. This module
19
+ // is the name AGENTS.md tells agents to run, and nothing else: no collection,
20
+ // no formatting, and nothing for another command to import out of.
19
21
 
20
22
  // Flags this command accepts. Declared HERE, with the command, so
21
23
  // adding a command never requires editing the entrypoint — the gap that
22
24
  // let six flags ship undeclared and silently swallow a positional (C3).
23
- export const flags = { boolean: ["json"], string: [] };
25
+ export const flags = { boolean: ["json", "rules"], string: [] };
24
26
 
25
- export async function run(_positional, _flags) {
27
+ export async function run(_positional, flags) {
26
28
  const projectRoot = process.cwd();
27
29
  if (!exists(path.join(projectRoot, ".doctrina"))) {
28
30
  throw notADoctrinaProject();
29
31
  }
30
-
31
- const s = collectStatus(projectRoot);
32
-
33
- console.log(c.bold("Doctrina prime") + c.gray(` — ${s.project} (framework ${s.stamp ?? "—"} / CLI ${s.cli})`));
34
- console.log("");
35
-
36
- // One-line gate digest.
37
- const cov = `${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria})`;
38
- const tr = s.trace.anchors === 0 ? "no anchors" : `${s.trace.realized}/${s.trace.anchors}`;
39
- const verify = s.verify.configured ? `${s.verify.checks} verify checks` : "verify not configured";
40
- console.log(c.bold("Gates ") + `index ${s.indexState} · coverage ${cov} · trace ${tr} · ${verify}`);
41
- const implBreak = Object.entries(s.specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
42
- console.log(
43
- c.bold("Work ") +
44
- `${s.specs.total} specs${implBreak ? ` (${implBreak})` : ""} · ${s.specs.openChanges} open change${s.specs.openChanges === 1 ? "" : "s"} · ` +
45
- `${s.decisions.total} decisions · ${s.skills} skills`,
46
- );
47
-
48
- // Standing rules, titles only — `constitution` has the full text.
49
- const adrs = acceptedDecisions(projectRoot);
50
- console.log("");
51
- console.log(c.bold("Rules") + c.gray(` (${adrs.length} accepted ADRs — \`doctrina constitution\` for detail)`));
52
- for (const a of adrs) console.log(` ${c.cyan(a.id)} ${a.title}`);
53
- const nonGoals = productSection(projectRoot, "Non-goals");
54
- if (nonGoals.length > 0) {
55
- console.log(c.gray(` + ${nonGoals.length} non-goal${nonGoals.length === 1 ? "" : "s"} declared in product.md`));
56
- }
57
-
58
- // Open work, with unchecked-task counts — the resume points.
59
- console.log("");
60
- console.log(c.bold("Open work"));
61
- const open = openChanges(projectRoot);
62
- if (open.length === 0) {
63
- console.log(c.gray(" none — `doctrina work \"<prompt>\"` opens the next change"));
64
- } else {
65
- for (const ch of open) {
66
- const tasks = ch.tasksTotal > 0 ? ` · tasks ${ch.tasksDone}/${ch.tasksTotal}` : "";
67
- console.log(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.status}${tasks})`)}`);
68
- }
69
- }
70
-
71
- // Next actions (top 5 — `doctrina next` for the full list).
72
- const actions = computeActions(projectRoot);
73
- console.log("");
74
- console.log(c.bold("Next"));
75
- if (actions.length === 0) {
76
- console.log(c.gray(" nothing pending — pick up new work"));
77
- } else {
78
- actions.slice(0, 5).forEach((a, i) => console.log(` ${i + 1}. ${a}`));
79
- if (actions.length > 5) console.log(c.gray(` … ${actions.length - 5} more — \`doctrina next\``));
80
- }
81
-
82
- console.log("");
83
- console.log(c.gray("Read deeper: `doctrina context [<cap>] --concat` · `doctrina why <cap>` · `doctrina show <ref>`"));
32
+ // --rules: the standing rules in full, which is what `constitution` printed
33
+ // (change 0049). The primer names the ADRs and counts the non-goals; this
34
+ // prints both lists, from the same collection, so the two cannot disagree.
35
+ const view = flagBool(flags, "rules", false) ? "rules" : "prime";
36
+ for (const line of renderView(view, collectSnapshot(projectRoot))) console.log(line);
84
37
  return 0;
85
38
  }
86
39
 
87
- // Open changes with their proposal status/title and task progress.
88
- export function openChanges(projectRoot) {
89
- const changesDir = path.join(projectRoot, ".doctrina", "changes");
90
- const out = [];
91
- if (!isDir(changesDir)) return out;
92
- for (const id of readdirSync(changesDir).sort()) {
93
- if (id === "archive" || id.startsWith(".")) continue;
94
- if (!isDir(path.join(changesDir, id))) continue;
95
- const proposalPath = path.join(changesDir, id, "proposal.md");
96
- const proposal = isFile(proposalPath) ? read(proposalPath) : null;
97
- const title = proposal?.match(/^#\s+(?:Change\s+[^—-]*[—-]\s*)?(.+)$/m)?.[1]?.trim() ?? null;
98
- const status = proposal ? (listHeader(proposal, "Status") ?? "proposed") : "no proposal.md";
99
- const tasksPath = path.join(changesDir, id, "tasks.md");
100
- let tasksDone = 0, tasksTotal = 0, unchecked = [];
101
- if (isFile(tasksPath)) {
102
- for (const line of read(tasksPath).split(/\r?\n/)) {
103
- const m = line.match(/^-\s+\[([ xX])\]\s+(.*)$/);
104
- if (!m) continue;
105
- tasksTotal += 1;
106
- if (m[1] === " ") unchecked.push(m[2].trim());
107
- else tasksDone += 1;
108
- }
109
- }
110
- out.push({ id, title, status, tasksDone, tasksTotal, unchecked });
111
- }
112
- return out;
113
- }
114
-
115
40
  export const help = `
116
41
  Usage: doctrina prime
117
42
 
@@ -120,6 +45,14 @@ titles + non-goal count), open work with task progress, and the top
120
45
  next actions — the ~40-line read that orients an agent at session
121
46
  start without paying for the full context pack.
122
47
 
48
+ The same view as \`doctrina status --view prime\`; both render one
49
+ collection of the tree, so they cannot disagree.
50
+
51
+ --rules Print the standing rules in full instead of the primer: every
52
+ accepted ADR and every declared non-goal. The same lines
53
+ \`doctrina constitution\` printed, from the same collection —
54
+ that command is deprecated and delegates here.
55
+
123
56
  Read-only; always exits 0. Deeper reads: \`doctrina context\`,
124
57
  \`doctrina why <cap>\`, \`doctrina show <ref>\`.
125
58
  `;
@@ -1,24 +1,30 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { spawnSync } from "node:child_process";
5
4
  import { exists } from "../lib/fs-ops.js";
6
- import * as idx from "../lib/index-json.js";
7
- import { collectStatus } from "./status.js";
8
- import { openChanges } from "./prime.js";
9
- import { flagString } from "../lib/args.js";
10
- import { today } from "../lib/dates.js";
5
+ import { flagBool, flagString } from "../lib/args.js";
11
6
  import { c } from "../lib/colors.js";
12
7
  import { notADoctrinaProject } from "../lib/exit-codes.js";
8
+ import { collectSnapshot } from "../lib/snapshot.js";
9
+ import { renderView } from "../lib/views.js";
10
+ import { gitWindow, historyState, windowCutoff } from "../lib/git.js";
11
+ import { draftAgentChangelog, renderDraft } from "../lib/agent-changelog.js";
12
+ import { collectMetrics } from "../lib/metrics-model.js";
13
+ import { cliVersion } from "../lib/version.js";
13
14
 
14
15
  // Periodic digest in Markdown — the standup/PR-description view: what was
15
16
  // archived, where the gates stand, and what local git says about the period.
16
17
  // Read-only, no network; the git section degrades silently outside a repo.
18
+ //
19
+ // Since change 0037 it is a VIEW over the shared snapshot (lib/snapshot.js →
20
+ // lib/views.js), also reachable as `doctrina status --view report --since N`,
21
+ // and its two git probes go through lib/git.js rather than a private
22
+ // spawnSync that could read a refusal as an empty answer.
17
23
 
18
24
  // Flags this command accepts. Declared HERE, with the command, so
19
25
  // adding a command never requires editing the entrypoint — the gap that
20
26
  // let six flags ship undeclared and silently swallow a positional (C3).
21
- export const flags = { boolean: ["json"], string: ["since"] };
27
+ export const flags = { boolean: ["json", "agent-changelog"], string: ["since"] };
22
28
 
23
29
  export async function run(_positional, flags) {
24
30
  const projectRoot = process.cwd();
@@ -26,117 +32,42 @@ export async function run(_positional, flags) {
26
32
  throw notADoctrinaProject();
27
33
  }
28
34
 
29
- const sinceRaw = flagString(flags, "since") ?? "7";
30
- const days = Number.parseInt(sinceRaw, 10);
35
+ const sinceRaw = flagString(flags, "since");
36
+ const days = Number.parseInt(sinceRaw ?? "7", 10);
31
37
  if (!Number.isFinite(days) || days <= 0) {
32
38
  console.error(c.red("error:") + ` --since expects a positive day count, got "${sinceRaw}"`);
33
39
  return 2;
34
40
  }
35
- const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
36
- const cutoffIso = cutoff.toISOString().slice(0, 10);
37
41
 
38
- const s = collectStatus(projectRoot);
39
-
40
- console.log(`# Doctrina report — ${s.project} (${cutoffIso} → ${today()})`);
41
- console.log("");
42
-
43
- // Gates.
44
- console.log("## Gates");
45
- console.log("");
46
- console.log(`- index: ${s.indexState} · framework stamp: ${s.stamp ?? "—"} (CLI ${s.cli})`);
47
- console.log(`- coverage: ${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria} criteria)` +
48
- (s.coverage.totalDangling ? ` — ${s.coverage.totalDangling} dangling` : "") +
49
- (s.coverage.totalConditional ? ` — ${s.coverage.totalConditional} conditional` : ""));
50
- console.log(`- trace: ${s.trace.anchors === 0 ? "no anchors declared" : `${s.trace.realized}/${s.trace.anchors} anchors realized`}`);
51
- console.log(`- verify: ${s.verify.configured ? `${s.verify.checks} checks declared` : "not configured"}`);
52
-
53
- // Work shipped in the window (from the index ledger) + work in flight.
54
- console.log("");
55
- console.log("## Changes");
56
- console.log("");
57
- const archived = archivedSince(projectRoot, cutoffIso);
58
- if (archived.length === 0) {
59
- console.log(`- archived in period: none`);
60
- } else {
61
- console.log(`- archived in period: ${archived.length}`);
62
- for (const ch of archived) console.log(` - ${ch.applied} — \`${ch.id}\` ${ch.title}`);
63
- }
64
- const open = openChanges(projectRoot);
65
- if (open.length === 0) {
66
- console.log("- open now: none");
67
- } else {
68
- console.log(`- open now: ${open.length}`);
69
- for (const ch of open) {
70
- const tasks = ch.tasksTotal > 0 ? ` (tasks ${ch.tasksDone}/${ch.tasksTotal})` : "";
71
- console.log(` - \`${ch.id}\`${ch.title ? ` ${ch.title}` : ""}${tasks}`);
72
- }
42
+ // --agent-changelog: a DRAFT of the block `upgrade --write` writes into
43
+ // AGENTS.md, proposed from what the archived changes said they touched.
44
+ // A different report for a different audience — five bullets for an
45
+ // arriving agent, not a digest for a standup — so it replaces the view
46
+ // rather than appending to it. Authorship stays human (ADR 0005): the
47
+ // command proposes, a person cuts and rewrites.
48
+ if (flagBool(flags, "agent-changelog", false)) {
49
+ const draft = draftAgentChangelog(projectRoot, { days: sinceRaw === undefined ? null : days });
50
+ for (const line of renderDraft(draft, cliVersion())) console.log(line);
51
+ return 0;
73
52
  }
74
53
 
75
- // Artifact counts, for trend-watching between reports.
76
- console.log("");
77
- console.log("## Artifacts");
78
- console.log("");
79
- console.log(`- specs: ${s.specs.total} · decisions: ${s.decisions.total}` +
80
- (s.decisions.proposed ? ` (${s.decisions.proposed} proposed)` : "") +
81
- ` · skills: ${s.skills}`);
82
-
83
- // Local git window — commits, fix share, top churn. No network.
84
- console.log("");
85
- console.log("## Git (local, last " + days + " days)");
86
- console.log("");
87
- const git = gitWindow(projectRoot, days);
88
- if (!git) {
89
- console.log("- no git history available (not a repository, or git not installed)");
90
- } else {
91
- console.log(`- commits: ${git.commits} (${git.fixes} fix-shaped, ${git.commits ? Math.round((git.fixes / git.commits) * 100) : 0}%)`);
92
- if (git.churn.length > 0) {
93
- console.log(`- top-churn files:`);
94
- for (const [file, n] of git.churn) console.log(` - ${file} (${n} touches)`);
95
- }
96
- }
97
-
98
- console.log("");
99
- console.log(`*Generated read-only by \`doctrina report --since ${days}\`; deeper numbers: \`doctrina metrics\`.*`);
54
+ const options = {
55
+ days,
56
+ cutoffIso: windowCutoff(days),
57
+ git: gitWindow(projectRoot, days),
58
+ // Why there is no window, when there is none — the same door `metrics`
59
+ // and `context` already ask, so the three cannot disagree about the
60
+ // state of one repository (change 0080).
61
+ gitState: historyState(projectRoot),
62
+ // The same snapshot `metrics` renders, for the same window (change
63
+ // 0050). One definition of "revert rate", so the digest and the metrics
64
+ // command cannot report two different numbers for one period.
65
+ metrics: collectMetrics(projectRoot, `${days} days ago`),
66
+ };
67
+ for (const line of renderView("report", collectSnapshot(projectRoot), options)) console.log(line);
100
68
  return 0;
101
69
  }
102
70
 
103
- function archivedSince(projectRoot, cutoffIso) {
104
- let index;
105
- try {
106
- index = idx.load(projectRoot);
107
- } catch {
108
- return [];
109
- }
110
- return (index.artifacts?.changes_archive ?? [])
111
- .filter((ch) => typeof ch.applied === "string" && ch.applied >= cutoffIso)
112
- .sort((a, b) => String(a.applied).localeCompare(String(b.applied)));
113
- }
114
-
115
- function gitWindow(projectRoot, days) {
116
- const log = spawnSync("git", ["log", `--since=${days} days ago`, "--pretty=%s"], {
117
- cwd: projectRoot,
118
- encoding: "utf8",
119
- });
120
- if (log.status !== 0) return null;
121
- const subjects = log.stdout.split(/\r?\n/).filter((l) => l.trim().length > 0);
122
- const fixes = subjects.filter((sub) => /^(fix|bug|hotfix|patch)(\(|:|!)/i.test(sub)).length;
123
-
124
- const files = spawnSync("git", ["log", `--since=${days} days ago`, "--name-only", "--pretty=format:"], {
125
- cwd: projectRoot,
126
- encoding: "utf8",
127
- });
128
- const counts = new Map();
129
- if (files.status === 0) {
130
- for (const line of files.stdout.split(/\r?\n/)) {
131
- const f = line.trim();
132
- if (!f) continue;
133
- counts.set(f, (counts.get(f) ?? 0) + 1);
134
- }
135
- }
136
- const churn = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
137
- return { commits: subjects.length, fixes, churn };
138
- }
139
-
140
71
  export const help = `
141
72
  Usage: doctrina report [--since <days>]
142
73
 
@@ -146,4 +77,15 @@ work with task progress, artifact counts, and a local-git summary
146
77
  (commits, fix share, top-churn files). Made for standups and PR
147
78
  descriptions. Read-only; no network. \`doctrina metrics\` has the
148
79
  deeper git-derived numbers.
80
+
81
+ The same view as \`doctrina status --view report --since <days>\`.
82
+
83
+ --agent-changelog Draft the AGENTS.md "What changed" block instead of
84
+ the digest: one candidate bullet per archived change
85
+ that touched a documented surface (a command, a flag,
86
+ an exit code), newest first, capped at the block's
87
+ five bullets. The window is "since the last tag"
88
+ unless --since names one. It PROPOSES; a person cuts
89
+ and rewrites, because what an agent must do
90
+ differently is a judgement the CLI does not make.
149
91
  `;
@@ -1,16 +1,17 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { spawnSync } from "node:child_process";
5
4
  import { readdirSync } from "node:fs";
6
5
  import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
7
6
  import { flagBool, flagString } from "../lib/args.js";
8
7
  import { c } from "../lib/colors.js";
9
- import { rankCapabilitiesByDiff } from "./work.js";
10
- import { parseDependsOn } from "../lib/scan.js";
11
- import { notADoctrinaProject } from "../lib/exit-codes.js";
12
- import { summarize as coverageSummary } from "./coverage.js";
13
- import { summarize as traceSummary } from "./trace.js";
8
+ import { rankCapabilitiesByDiff } from "../lib/work-model.js";
9
+ import { readLedger, churnByCapability } from "../lib/ledger.js";
10
+ import { changedFiles, windowCutoff, historyState, refExists } from "../lib/git.js";
11
+ import { dependentsOf } from "../lib/scan.js";
12
+ import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
13
+ import { summarize as coverageSummary } from "../lib/coverage-model.js";
14
+ import { summarize as traceSummary } from "../lib/trace-model.js";
14
15
 
15
16
  // Deterministic conformance review (review 2026-06-27 passive-user feature #3).
16
17
  // Given the working tree (or a diff against a ref), report STRUCTURAL breaks
@@ -25,6 +26,17 @@ import { summarize as traceSummary } from "./trace.js";
25
26
  // Flags this command accepts. Declared HERE, with the command, so
26
27
  // adding a command never requires editing the entrypoint — the gap that
27
28
  // let six flags ship undeclared and silently swallow a positional (C3).
29
+ // How far back the churn note looks, and how many landed changes make it
30
+ // worth saying at all. A window rather than "all time": a capability that
31
+ // moved nine times two years ago is history, not news.
32
+ const CHURN_WINDOW_DAYS = 60;
33
+ const CHURN_NOTABLE = 3;
34
+
35
+ // How many unclaimed files the orphan note names before it summarises the
36
+ // rest. Long enough to act on, short enough that a wide refactor does not
37
+ // bury every other finding under a file listing.
38
+ const ORPHAN_LIST_LIMIT = 5;
39
+
28
40
  export const flags = { boolean: ["json", "strict"], string: ["diff"] };
29
41
 
30
42
  export async function run(_positional, flags) {
@@ -35,7 +47,20 @@ export async function run(_positional, flags) {
35
47
  const strict = flagBool(flags, "strict", false);
36
48
  const against = flagString(flags, "diff"); // optional git ref to diff against
37
49
 
38
- const { all, sourceFiles } = changedFiles(projectRoot, against);
50
+ // A ref the git cannot resolve is a filter that matches nothing, and the
51
+ // review used to read it as "nothing changed" — exit 0, `--strict`
52
+ // included, on a tree where a valid ref reported breaks. A CI job running
53
+ // `doctrina review --diff main --strict` stayed green forever on a shallow
54
+ // clone with no local `main` (change 0092). Only asked inside a usable
55
+ // repository: outside one the command still stays silent rather than
56
+ // accusing.
57
+ if (against && historyState(projectRoot).usable && !refExists(projectRoot, against)) {
58
+ console.error(c.red("error:") + ` --diff names a ref this repository cannot resolve: "${against}"`);
59
+ console.error(c.gray("hint: ") + "check the branch or commit name — a ref that resolves to nothing is not an empty diff");
60
+ return EXIT.USAGE;
61
+ }
62
+
63
+ const { all, sourceFiles } = reviewScope(projectRoot, against);
39
64
  console.log(c.bold("Review") + c.gray(against ? ` — vs ${against}` : " — working-tree changes"));
40
65
  console.log("");
41
66
 
@@ -69,23 +94,39 @@ export async function run(_positional, flags) {
69
94
  // 1b. Dependents of touched capabilities (the machine-readable
70
95
  // **Depends on:** header): a spec that builds on something you changed
71
96
  // may silently no longer hold. Advisory — a pointer, not a verdict.
72
- const specsDir = path.join(projectRoot, ".doctrina", "specs");
73
- if (isDir(specsDir) && touched.size > 0) {
74
- for (const cap of readdirSync(specsDir).sort()) {
75
- if (touched.has(cap)) continue;
76
- const specPath = path.join(specsDir, cap, "spec.md");
77
- if (!isFile(specPath)) continue;
78
- const deps = parseDependsOn(read(specPath)).filter((d) => touched.has(d));
79
- if (deps.length > 0) {
80
- notes.push(`capability "${cap}" depends on touched ${deps.map((d) => `"${d}"`).join(", ")} — confirm it still holds (\`doctrina why ${cap}\`)`);
81
- }
97
+ for (const dep of dependentsOf(projectRoot, touched)) {
98
+ notes.push(`capability "${dep.capability}" depends on touched ${dep.dependsOn.map((d) => `"${d}"`).join(", ")} — confirm it still holds (\`doctrina why ${dep.capability}\`)`);
99
+ }
100
+
101
+ // 1c. How often each touched capability has moved lately, from the archive
102
+ // ledger (change 0046). Reported as a NUMBER and never as a verdict:
103
+ // frequent change can mean a spec that was drawn badly or a spec that
104
+ // is simply where the work is, and nothing here can tell those apart
105
+ // (ADR 0005). It is context for the human reading the review, not a
106
+ // finding — so it goes in the notes even when the count is high.
107
+ if (touched.size > 0) {
108
+ const since = windowCutoff(CHURN_WINDOW_DAYS);
109
+ const churn = churnByCapability(readLedger(projectRoot).entries, { since })
110
+ .filter((row) => touched.has(row.capability) && row.changes >= CHURN_NOTABLE);
111
+ for (const row of churn) {
112
+ 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"`);
82
113
  }
83
114
  }
84
115
 
85
- // 2. New source files that map to no capability at all — code with no home
86
- // in any spec (a likely new, unspecced capability).
87
- if (ranked.length === 0 && sourceFiles.length > 0) {
88
- notes.push(`changed code maps to no existing capability spec — if this is a new capability, scaffold it: \`doctrina spec new <capability>\` (or \`doctrina work --from-diff\`)`);
116
+ // 2. Changed source files that map to no capability at all — code with no
117
+ // home in any spec. Reported PER FILE (change 0077): the old form fired
118
+ // only when the WHOLE diff matched nothing, so one incidental match —
119
+ // any file under `docs/`, which matches the `docs` capability because
120
+ // the directory is named after it — silenced the finding for every other
121
+ // file in the change. A review of an adapter change reported
122
+ // "Capabilities touched: docs" and said nothing about the adapter.
123
+ const orphans = sourceFiles.filter(
124
+ (f) => rankCapabilitiesByDiff(projectRoot, [f], { limit: 1 }).length === 0,
125
+ );
126
+ if (orphans.length > 0) {
127
+ const shown = orphans.slice(0, ORPHAN_LIST_LIMIT).map((f) => `\`${f}\``).join(", ");
128
+ const more = orphans.length > ORPHAN_LIST_LIMIT ? `, and ${orphans.length - ORPHAN_LIST_LIMIT} more` : "";
129
+ notes.push(`${orphans.length} changed file(s) belong to no capability: ${shown}${more} — claim them with a \`**Source:**\` header on the owning spec, or scaffold the capability they are (\`doctrina spec new <capability>\`)`);
89
130
  }
90
131
 
91
132
  // 3. Coverage — acceptance criteria whose cited proof is missing/skipped.
@@ -133,21 +174,12 @@ export async function run(_positional, flags) {
133
174
  }
134
175
 
135
176
  // Changed paths between a base (a git ref, or HEAD + untracked for the working
136
- // tree) and now. Returns the full list and the subset outside .doctrina/.
137
- function changedFiles(projectRoot, against) {
138
- const run = (args) => {
139
- const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
140
- if (r.error || r.status !== 0) return [];
141
- return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
142
- };
143
- const set = new Set();
144
- if (against) {
145
- for (const f of run(["diff", "--name-only", against])) set.add(f);
146
- } else {
147
- for (const f of run(["diff", "--name-only", "HEAD"])) set.add(f);
148
- for (const f of run(["ls-files", "--others", "--exclude-standard"])) set.add(f);
149
- }
150
- const all = [...set];
177
+ // tree) and now, split into the full list and the subset outside .doctrina/.
178
+ // The git question itself goes through the one door (lib/git.js).
179
+ function reviewScope(projectRoot, against) {
180
+ // Against a named ref, untracked files are noise: the question is what this
181
+ // branch changed, not what is lying around uncommitted.
182
+ const all = changedFiles(projectRoot, { since: against, untracked: !against }).files;
151
183
  const sourceFiles = all.filter((f) => !f.replace(/\\/g, "/").startsWith(".doctrina/"));
152
184
  return { all, sourceFiles };
153
185
  }
@@ -4,6 +4,7 @@ import process from "node:process";
4
4
  import { exists, isDir, read, relPath, walk } from "../lib/fs-ops.js";
5
5
  import { flagBool } from "../lib/args.js";
6
6
  import { c } from "../lib/colors.js";
7
+ import { fold } from "../lib/lexicon.js";
7
8
  import { notADoctrinaProject } from "../lib/exit-codes.js";
8
9
 
9
10
  // Category-aware full-text search across the artifact tree. Answers
@@ -25,7 +26,13 @@ const MAX_MATCHES_PER_FILE = 5;
25
26
  export const flags = { boolean: ["json", "archive"], string: [] };
26
27
 
27
28
  export async function run(positional, flags) {
28
- const terms = positional.map((t) => t.toLowerCase()).filter(Boolean);
29
+ // Folded, not merely lowercased (third audit, finding 8). `search` matched
30
+ // raw substrings, so an accent decided the answer: `search patrimonio`
31
+ // found nothing in a spec that says "patrimônio" — and typing without the
32
+ // accent is the common case, not the rare one. The shared lexicon (ADR
33
+ // 0040) already folds for `work` and `context --for`; `search` was the
34
+ // one retrieval surface still deciding on its own.
35
+ const terms = positional.map((t) => fold(t)).filter(Boolean);
29
36
  if (terms.length === 0) {
30
37
  console.error(c.red("error:") + " search requires at least one term");
31
38
  return 2;
@@ -64,7 +71,7 @@ export async function run(positional, flags) {
64
71
  const hits = [];
65
72
  for (let i = 0; i < lines.length; i++) {
66
73
  const raw = lines[i];
67
- const low = raw.toLowerCase();
74
+ const low = fold(raw);
68
75
  if (terms.every((t) => low.includes(t))) {
69
76
  hits.push({ line: i + 1, text: raw.trim(), score: lineScore(raw, low, phrase) });
70
77
  }
@@ -92,7 +99,14 @@ export async function run(positional, flags) {
92
99
 
93
100
  if (totalMatches === 0) {
94
101
  console.log(c.gray(`no matches for "${positional.join(" ")}"${includeArchive ? "" : " (archive excluded; pass --archive to include it)"}`));
95
- return 1;
102
+ // Not finding is the answer, not a failure. Exit 1 is GATE in the
103
+ // five-class contract — "fix the work, retry the same command" — and
104
+ // this command fixes nothing. The `insight` spec says it outright: a
105
+ // VIEW assembles what is there and never refuses anything, and the
106
+ // other six listings (spec, decision, skill, contract, templates,
107
+ // intent) all exit 0 on an empty result. The grep convention is a
108
+ // shell habit; this CLI declared its own contract (change 0093).
109
+ return 0;
96
110
  }
97
111
  console.log(c.gray(`${totalMatches} match${totalMatches === 1 ? "" : "es"} in ${filesWithMatches} file${filesWithMatches === 1 ? "" : "s"}`));
98
112
  return 0;
@@ -116,7 +130,7 @@ function lineScore(raw, low, phrase) {
116
130
  // A file whose name carries the query is almost always the right one;
117
131
  // reward a full filename match more than a partial one.
118
132
  function filenameBonus(rel, terms) {
119
- const base = rel.toLowerCase();
133
+ const base = fold(rel);
120
134
  if (terms.every((t) => base.includes(t))) return 3;
121
135
  return terms.some((t) => base.includes(t)) ? 1 : 0;
122
136
  }
@@ -6,7 +6,8 @@ import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
6
6
  import { parseAcceptanceCriteria } from "../lib/criteria.js";
7
7
  import { suggest } from "../lib/suggest.js";
8
8
  import { c } from "../lib/colors.js";
9
- import { notADoctrinaProject } from "../lib/exit-codes.js";
9
+ import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
10
+ import { maskComments } from "../lib/doc-model.js";
10
11
 
11
12
  // Point reads. An agent that needs ONE requirement re-reads a whole spec —
12
13
  // hundreds of lines for a two-line fact. `show` resolves a compact reference
@@ -57,7 +58,7 @@ function showAdr(projectRoot, num) {
57
58
  const file = isDir(dir) ? readdirSync(dir).find((f) => f.startsWith(`${num}-`) && f.endsWith(".md")) : null;
58
59
  if (!file) {
59
60
  console.error(c.red("error:") + ` no ADR ${num} under .doctrina/decisions/`);
60
- return 1;
61
+ return EXIT.USAGE;
61
62
  }
62
63
  const full = path.join(dir, file);
63
64
  console.log(c.gray(`— ${relPath(projectRoot, full)} —`));
@@ -79,7 +80,7 @@ function loadSpec(projectRoot, cap) {
79
80
 
80
81
  function showSpecHead(projectRoot, cap) {
81
82
  const spec = loadSpec(projectRoot, cap);
82
- if (!spec) return 1;
83
+ if (!spec) return EXIT.USAGE;
83
84
  const lines = spec.text.split(/\r?\n/);
84
85
  const out = [];
85
86
  let mode = "head"; // header block -> seek Purpose -> purpose body -> stop
@@ -98,14 +99,14 @@ function showSpecHead(projectRoot, cap) {
98
99
 
99
100
  function showSpecItem(projectRoot, cap, kind, n) {
100
101
  const spec = loadSpec(projectRoot, cap);
101
- if (!spec) return 1;
102
+ if (!spec) return EXIT.USAGE;
102
103
 
103
104
  if (kind === "C") {
104
105
  const criteria = parseAcceptanceCriteria(spec.text);
105
106
  const crit = criteria.find((r) => r.n === n);
106
107
  if (!crit) {
107
108
  console.error(c.red("error:") + ` ${cap} has no acceptance criterion #${n} (it declares ${criteria.length})`);
108
- return 1;
109
+ return EXIT.USAGE;
109
110
  }
110
111
  console.log(c.gray(`— ${cap}-C${n} · ${relPath(projectRoot, spec.specPath)} · ## Acceptance criteria —`));
111
112
  console.log(`${n}. ${crit.body}`);
@@ -119,7 +120,7 @@ function showSpecItem(projectRoot, cap, kind, n) {
119
120
  const req = reqs[n - 1];
120
121
  if (!req) {
121
122
  console.error(c.red("error:") + ` ${cap} has no requirement R${n} (it declares ${reqs.length})`);
122
- return 1;
123
+ return EXIT.USAGE;
123
124
  }
124
125
  console.log(c.gray(`— ${cap}-R${n} · ${relPath(projectRoot, spec.specPath)} · ${req.section} —`));
125
126
  console.log(req.text);
@@ -129,7 +130,13 @@ function showSpecItem(projectRoot, cap, kind, n) {
129
130
  // Top-level bullets of the "## Requirements (EARS)" section, in file order,
130
131
  // each with its `### <grammar>` sub-section name and continuation lines.
131
132
  export function parseRequirements(text) {
132
- const lines = text.split(/\r?\n/);
133
+ // The scaffolded spec writes the five-line EARS legend as bullets inside
134
+ // an HTML comment under `## Requirements (EARS)`. Counted as content, the
135
+ // legend became R1..R5 and the capability's first real requirement was R6
136
+ // — on every spec `doctrina spec new` had ever created. Comments are
137
+ // blanked by position (same offsets, same line count), so the numbering
138
+ // below is over authored bullets only.
139
+ const lines = maskComments(text).split(/\r?\n/);
133
140
  const out = [];
134
141
  let inSection = false;
135
142
  let sub = "";
@@ -175,6 +182,14 @@ Point-read one artifact fragment instead of a whole file:
175
182
  doctrina show 0007 ADR 0007 (the whole decision)
176
183
  doctrina show cli the spec's header block + Purpose only
177
184
 
178
- R-refs are positional (they shift when a requirement is inserted above);
179
- C-refs use the criteria's own numbers. Read-only.
185
+ R-refs are positional across the WHOLE spec, in file order, and they shift
186
+ when a requirement is inserted above. C-refs use the criteria's own numbers.
187
+ Both count authored content only — a bullet inside an HTML comment (the
188
+ scaffold's EARS legend, for one) is annotation and is never numbered.
189
+
190
+ A spec delta's replace-requirement <section> <n> numbers differently, and
191
+ on purpose: it counts WITHIN one "### <section>", so the number stays put
192
+ when another section grows. doctrina show <cap>-RN prints the section it
193
+ landed in, which is what turns an R-ref into the delta's pair of coordinates.
194
+ Read-only.
180
195
  `;