doctrina-cli 0.15.1 → 0.17.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 (93) hide show
  1. package/README.md +27 -72
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +27 -3
  4. package/src/commands/analyze.js +16 -212
  5. package/src/commands/change.js +144 -267
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +65 -21
  8. package/src/commands/close.js +349 -45
  9. package/src/commands/completion.js +8 -2
  10. package/src/commands/context.js +284 -111
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +66 -7
  14. package/src/commands/doctor.js +293 -130
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +17 -3
  17. package/src/commands/index-rebuild.js +55 -44
  18. package/src/commands/init.js +88 -16
  19. package/src/commands/intake.js +85 -99
  20. package/src/commands/intent.js +14 -1
  21. package/src/commands/metrics.js +65 -88
  22. package/src/commands/next.js +105 -147
  23. package/src/commands/prime.js +23 -91
  24. package/src/commands/report.js +40 -109
  25. package/src/commands/review.js +68 -36
  26. package/src/commands/search.js +18 -4
  27. package/src/commands/show.js +24 -9
  28. package/src/commands/skill.js +23 -39
  29. package/src/commands/spec.js +38 -11
  30. package/src/commands/status.js +69 -171
  31. package/src/commands/templates.js +35 -148
  32. package/src/commands/trace.js +36 -71
  33. package/src/commands/triage.js +4 -82
  34. package/src/commands/upgrade.js +31 -6
  35. package/src/commands/validate.js +44 -877
  36. package/src/commands/verify.js +60 -138
  37. package/src/commands/why.js +2 -2
  38. package/src/commands/work.js +243 -267
  39. package/src/index.js +139 -12
  40. package/src/lib/actions.js +490 -0
  41. package/src/lib/adapters.js +0 -11
  42. package/src/lib/adr-guard.js +19 -0
  43. package/src/lib/agent-changelog.js +133 -0
  44. package/src/lib/analysis.js +264 -0
  45. package/src/lib/args.js +14 -0
  46. package/src/lib/change-ops.js +133 -0
  47. package/src/lib/commands.js +135 -10
  48. package/src/lib/config.js +206 -0
  49. package/src/lib/constitution-model.js +102 -0
  50. package/src/lib/coverage-model.js +431 -0
  51. package/src/lib/criteria.js +23 -0
  52. package/src/lib/doc-model.js +370 -3
  53. package/src/lib/docs-impact.js +270 -27
  54. package/src/lib/ears.js +24 -1
  55. package/src/lib/flag-catalog.js +0 -16
  56. package/src/lib/fs-ops.js +58 -1
  57. package/src/lib/gates.js +231 -29
  58. package/src/lib/git.js +132 -0
  59. package/src/lib/index-json.js +42 -24
  60. package/src/lib/intake-model.js +155 -0
  61. package/src/lib/json-out.js +100 -2
  62. package/src/lib/ledger.js +192 -0
  63. package/src/lib/lexicon.js +228 -0
  64. package/src/lib/metrics-model.js +189 -0
  65. package/src/lib/names.js +42 -0
  66. package/src/lib/playbook.js +97 -0
  67. package/src/lib/project.js +105 -0
  68. package/src/lib/reproducibility.js +137 -0
  69. package/src/lib/runtime.js +191 -5
  70. package/src/lib/scan.js +328 -25
  71. package/src/lib/signoff.js +147 -0
  72. package/src/lib/snapshot.js +231 -0
  73. package/src/lib/spec-ops.js +72 -8
  74. package/src/lib/templates-model.js +289 -0
  75. package/src/lib/trace-model.js +95 -0
  76. package/src/lib/triage-model.js +121 -0
  77. package/src/lib/usage.js +6 -4
  78. package/src/lib/validation-model.js +1381 -0
  79. package/src/lib/version.js +22 -0
  80. package/src/lib/views.js +413 -0
  81. package/src/lib/work-model.js +102 -0
  82. package/templates/AGENTS.md.template +21 -28
  83. package/templates/README.md +35 -0
  84. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  85. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  86. package/templates/change/proposal.md.template +16 -0
  87. package/templates/change/tasks.md.template +3 -8
  88. package/templates/contract.md.template +2 -2
  89. package/templates/hooks/pre-commit.sample +16 -2
  90. package/templates/playbooks/bootstrap.md.template +47 -0
  91. package/templates/playbooks/chore.md.template +24 -0
  92. package/templates/playbooks/work.md.template +83 -0
  93. package/src/commands/constitution.js +0 -145
@@ -2,12 +2,19 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
5
+ import { ensureDoctrinaProject, projectName } from "../lib/project.js";
5
6
  import { listHeader } from "../lib/scan.js";
6
7
  import { assessBrief } from "../lib/clarity.js";
7
8
  import * as idx from "../lib/index-json.js";
8
9
  import { today } from "../lib/dates.js";
9
10
  import { flagBool, flagString } from "../lib/args.js";
10
11
  import { c } from "../lib/colors.js";
12
+ import { looksLikePath, markIntakeConverted, printBootstrapPlaybook, warnIfThinIntake, writeIntakeFile } from "../lib/intake-model.js";
13
+
14
+ // `init --intake` performs the same operations; they live in
15
+ // lib/intake-model.js so neither command imports out of the other (F7).
16
+ export { warnIfThinIntake, writeIntakeFile, printBootstrapPlaybook } from "../lib/intake-model.js";
17
+ import { collectValidation } from "../lib/validation-model.js";
11
18
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
12
19
 
13
20
  // `intake` is the first half of the no-ceremony path (ADR 0005): store the
@@ -19,10 +26,47 @@ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
19
26
  // Flags this command accepts. Declared HERE, with the command, so
20
27
  // adding a command never requires editing the entrypoint — the gap that
21
28
  // let six flags ship undeclared and silently swallow a positional (C3).
22
- export const flags = { boolean: ["json", "force"], string: ["text"] };
29
+ export const flags = { boolean: ["json", "force", "converted"], string: ["text"] };
23
30
 
24
31
  export async function run(positional, flags) {
25
32
  const projectRoot = process.cwd();
33
+
34
+ // Close the bootstrap through the CLI, not by hand.
35
+ //
36
+ // Step 7 of the playbook used to read "flip Status to converted in
37
+ // .doctrina/intake.md" — the one place this framework told an agent to
38
+ // hand-author a metadata header, against its own standing rule, and the one
39
+ // header nothing read back. A mistyped word left `next` asking forever for
40
+ // a bootstrap that was already done.
41
+ if (flagBool(flags, "converted", false)) {
42
+ ensureDoctrinaProject(projectRoot);
43
+ // Converting is the claim that the bootstrap is done — step 6 of the
44
+ // playbook, "fix everything the gates report", made checkable (change
45
+ // 0194). It used to convert over a tree `validate` failed, so the specs
46
+ // became "the source of truth" while an error still said they were not
47
+ // well-formed. --force converts anyway, for a tree whose errors are not
48
+ // the bootstrap's to fix.
49
+ if (isFile(path.join(projectRoot, ".doctrina", "intake.md")) && !flagBool(flags, "force", false)) {
50
+ const { errors } = collectValidation(projectRoot);
51
+ if (errors.length > 0) {
52
+ console.error(c.red("error:") + ` the bootstrap is not done — \`doctrina validate\` reports ${errors.length} error${errors.length === 1 ? "" : "s"}:`);
53
+ for (const e of errors.slice(0, 5)) console.error(" - " + e);
54
+ if (errors.length > 5) console.error(` … and ${errors.length - 5} more`);
55
+ console.error(c.gray("hint: ") + "fix them (`doctrina validate --fix` heals index drift), then rerun; `--force` converts anyway");
56
+ return EXIT.GATE;
57
+ }
58
+ }
59
+ const written = markIntakeConverted(projectRoot);
60
+ if (!written) {
61
+ console.error(c.red("error:") + " no intake to mark converted at .doctrina/intake.md");
62
+ console.error(c.gray("hint: ") + 'store one first: `doctrina intake --text "<description>"`');
63
+ return EXIT.PRECONDITION;
64
+ }
65
+ console.log(c.green("converted") + ` ${relPath(projectRoot, written)} — specs are the source of truth now`);
66
+ console.log(c.gray("next: ") + 'doctrina next');
67
+ return EXIT.OK;
68
+ }
69
+
26
70
  if (!exists(path.join(projectRoot, ".doctrina"))) {
27
71
  throw notADoctrinaProject();
28
72
  }
@@ -58,12 +102,24 @@ export async function run(positional, flags) {
58
102
  source = "inline (--text)";
59
103
  } else {
60
104
  const abs = path.resolve(projectRoot, sourceFile);
61
- if (!isFile(abs)) {
105
+ if (isFile(abs)) {
106
+ body = read(abs);
107
+ source = relPath(projectRoot, abs);
108
+ } else if (looksLikePath(sourceFile)) {
62
109
  console.error(c.red("error:") + ` description file not found: ${sourceFile}`);
110
+ console.error(c.gray("hint: ") +
111
+ 'to pass the description itself, use `doctrina intake --text "<description>"`');
63
112
  return 1;
113
+ } else {
114
+ // Prose, not a path (change 0071). The surface block says "store the
115
+ // intent", so passing the intent is what its wording invites; answering
116
+ // "description file not found:" and echoing the author's whole sentence
117
+ // back as a filename was the CLI blaming the reader for reading it.
118
+ body = sourceFile;
119
+ source = "inline";
120
+ console.error(c.gray("note: ") +
121
+ 'read as the description itself, not as a file — `--text "<description>"` says so explicitly');
64
122
  }
65
- body = read(abs);
66
- source = relPath(projectRoot, abs);
67
123
  }
68
124
  if (body.trim().length === 0) {
69
125
  console.error(c.red("error:") + " the description is empty");
@@ -74,9 +130,19 @@ export async function run(positional, flags) {
74
130
  console.error(c.red("error:") + ` ${relPath(projectRoot, intakePath)} already exists (pass --force to overwrite)`);
75
131
  return 1;
76
132
  }
133
+ // After conversion the specs are the only source of truth, and the intake
134
+ // is never edited to change requirements (change 0112). `--force` was the
135
+ // CLI-sanctioned way to do exactly that: it rewrote the file to `pending`
136
+ // and printed the bootstrap playbook as if the tree were empty. The door
137
+ // for new intent is `intent add`; for a change of behaviour, `work`.
138
+ if (exists(intakePath) && (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase() === "converted") {
139
+ console.error(c.red("error:") + ` ${relPath(projectRoot, intakePath)} is already converted — the specs are the source of truth now, and --force does not reopen it`);
140
+ console.error(c.gray("hint: ") + "new product intent: `doctrina intent add \"<text>\"` · a change of behaviour: `doctrina work \"<prompt>\"`");
141
+ return EXIT.PRECONDITION;
142
+ }
77
143
 
78
- const projectName = idx.load(projectRoot).project ?? path.basename(projectRoot);
79
- writeIntakeFile(projectRoot, { body, source, projectName, date: today(), force });
144
+ const name = projectName(projectRoot);
145
+ writeIntakeFile(projectRoot, { body, source, projectName: name, date: today(), force });
80
146
  console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)}`);
81
147
  console.log("");
82
148
  warnIfThinIntake(body);
@@ -84,95 +150,6 @@ export async function run(positional, flags) {
84
150
  return 0;
85
151
  }
86
152
 
87
- // Clarification gate (review Topic A). A description too thin to spec from is
88
- // the moment to ask the user, not to let the agent invent requirements. Prints
89
- // the specific gaps before the bootstrap playbook; advisory, never blocking —
90
- // the intake is still captured verbatim (the playbook step 6 resolves it).
91
- export function warnIfThinIntake(body) {
92
- const assessment = assessBrief(body, { kind: "intake" });
93
- if (!assessment.thin) return;
94
- console.log(c.yellow("⚠ thin intake — clarify with the user before converting to specs:"));
95
- for (const reason of assessment.reasons) console.log(` - ${reason}`);
96
- console.log("");
97
- }
98
-
99
- // Shared with `init --intake`. Writes the intake verbatim under a small
100
- // status header; the agent flips Status to converted at the end of the
101
- // playbook, which is how `next` knows the bootstrap is done.
102
- export function writeIntakeFile(projectRoot, { body, source, projectName, date, force = false }) {
103
- const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
104
- const content = [
105
- `# Intake — ${projectName}`,
106
- "",
107
- "- **Status:** pending",
108
- `- **Date:** ${date}`,
109
- `- **Source:** ${source}`,
110
- "",
111
- "<!-- Raw project intent, stored verbatim. The bootstrap playbook",
112
- " (doctrina intake) converts it into product.md and capability",
113
- " specs, then flips Status to converted. After conversion the",
114
- " specs are the only source of truth; never edit this file to",
115
- " change requirements. -->",
116
- "",
117
- "---",
118
- "",
119
- body.replace(/\r\n/g, "\n").replace(/\n*$/, "\n"),
120
- ].join("\n");
121
- write(intakePath, content, { force });
122
- return intakePath;
123
- }
124
-
125
- // Exported so `init --intake` can print the same playbook inline instead
126
- // of bouncing the user to a second command.
127
- export function printBootstrapPlaybook(projectRoot) {
128
- const hasSpecs = exists(path.join(projectRoot, ".doctrina", "specs"));
129
- console.log(c.bold("Bootstrap playbook") + c.gray(" — agent-executed (ADR 0005); the CLI does no interpretation."));
130
- console.log("");
131
- console.log("Execute in order, in a single linear pass:");
132
- console.log("");
133
- console.log(`1. Read ${c.cyan(".doctrina/intake.md")} (raw intent) and ${c.cyan(".doctrina/product.md")}.`);
134
- console.log("");
135
- console.log("2. Fill every product.md section from the intake: Vision, Problem,");
136
- console.log(" Target users, Scope (in/out), Non-goals, Success criteria, and the");
137
- console.log(" Delivery order — name the ONE end-to-end walking skeleton to build");
138
- console.log(" and verify before fanning out (depth before breadth).");
139
- console.log(" One fact, one home — product.md holds vision, never requirements.");
140
- console.log("");
141
- console.log("3. Derive the capability list: kebab-case, one per area of behaviour");
142
- console.log(" (e.g. auth, billing, reports). For each capability:");
143
- console.log(` ${c.cyan("doctrina spec new <capability>")}`);
144
- console.log(" then replace the template placeholders in");
145
- console.log(" .doctrina/specs/<capability>/spec.md with EARS requirements derived");
146
- console.log(" from the intake (Ubiquitous / Event-driven / State-driven /");
147
- console.log(" Unwanted-behavior / Optional). Keep the two axes honest — leave");
148
- console.log(" Implementation: planned for what is not built yet; keep aspiration");
149
- console.log(" under ## Maturity → Future, not in EARS; write concrete Acceptance");
150
- console.log(" criteria, each [unverified] until a cited test proves it.");
151
- console.log("");
152
- console.log("4. If the project spans services/front-ends, own the seams between");
153
- console.log(" them (port map, env, API/WS/event shapes) as a contract:");
154
- console.log(` ${c.cyan("doctrina contract new <name>")} → fill it → ${c.cyan("doctrina contract check")}`);
155
- console.log("");
156
- console.log("5. Record any architectural decision the intake forces (cite Evidence):");
157
- console.log(` ${c.cyan("doctrina decision new \"<title>\"")} → edit → ${c.cyan("doctrina decision accept <num>")}`);
158
- console.log("");
159
- console.log("6. Quality gates — fix everything they report before moving on:");
160
- console.log(` ${c.cyan("doctrina clarify --all")}`);
161
- console.log(` ${c.cyan("doctrina validate")}`);
162
- console.log(` ${c.cyan("doctrina coverage")} (and ${c.cyan("doctrina verify --init")} to declare the build gate)`);
163
- console.log(" If something in the intake is genuinely ambiguous, ask the user");
164
- console.log(" before assuming.");
165
- console.log("");
166
- console.log("7. Mark the intake consumed: in .doctrina/intake.md flip");
167
- console.log(" \"- **Status:** pending\" to \"- **Status:** converted\".");
168
- console.log("");
169
- console.log(`8. ${c.cyan("doctrina next")} — follow the recommendation. Implementation then`);
170
- console.log(` flows through ${c.cyan("doctrina work \"<brief prompt>\"")}, one change at a time.`);
171
- if (!hasSpecs) {
172
- console.log("");
173
- console.log(c.yellow("warn:") + " .doctrina/specs/ is missing — re-run `doctrina init` first.");
174
- }
175
- }
176
153
 
177
154
  export const help = `
178
155
  Usage: doctrina intake [<file>] [--text "<description>"] [--force]
@@ -189,11 +166,20 @@ Forms:
189
166
  doctrina intake --text "<text>" Ingest an inline description
190
167
  doctrina intake Reprint the playbook for the
191
168
  existing pending intake
169
+ doctrina intake --converted Close the bootstrap: flip the stored
170
+ intake's Status to converted
192
171
 
193
172
  Options:
194
173
  --text "<description>" Inline description instead of a file
195
- --force Overwrite an existing .doctrina/intake.md
196
-
197
- After conversion the agent flips the intake header to
198
- "- **Status:** converted"; specs become the only source of truth.
174
+ --force Overwrite an existing .doctrina/intake.md; with
175
+ --converted, convert although validate fails
176
+ --converted Mark the stored intake converted (ends the
177
+ bootstrap; nothing else writes that header).
178
+ Refuses (exit 1) while validate reports an error
179
+
180
+ Close the bootstrap with \`doctrina intake --converted\` rather than editing
181
+ the header by hand: \`next\` branches on that value, and \`validate\` reports a
182
+ Status that is neither pending nor converted, because every other word read
183
+ as pending in silence. After conversion the specs are the only source of
184
+ truth.
199
185
  `;
@@ -2,6 +2,7 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
5
+ import { intentKey } from "../lib/validation-model.js";
5
6
  import { c } from "../lib/colors.js";
6
7
  import { suggest } from "../lib/suggest.js";
7
8
  import { notADoctrinaProject } from "../lib/exit-codes.js";
@@ -102,9 +103,21 @@ function intentAdd(projectRoot, textArg) {
102
103
  let id = null;
103
104
  let text = textArg;
104
105
  const pinned = textArg.match(/^([A-Z]+)(\d+)\s*:\s*(.+)$/);
106
+ if (pinned) text = pinned[3].trim();
107
+
108
+ // The same intent twice is two anchors for one goal: realize either and the
109
+ // other stays "dropped", so `trace --strict` fails on a gap nobody can
110
+ // close. An agent re-running a playbook step is enough to do it. Compared
111
+ // folded, so case, accents and spacing do not slip a twin past.
112
+ const twin = anchors.find((a) => intentKey(a.text) === intentKey(text));
113
+ if (twin) {
114
+ console.error(c.red("error:") + ` [${twin.id}] already states this intent in product.md`);
115
+ console.error(c.gray("hint: ") + `realize [${twin.id}] with a spec's \`Realizes:\` header, or reword this one if it is a different goal`);
116
+ return 1;
117
+ }
118
+
105
119
  if (pinned) {
106
120
  id = pinned[1] + pinned[2];
107
- text = pinned[3].trim();
108
121
  if (anchors.some((a) => a.id === id)) {
109
122
  console.error(c.red("error:") + ` anchor [${id}] already exists in product.md`);
110
123
  return 1;
@@ -1,28 +1,26 @@
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, relPath, write } from "../lib/fs-ops.js";
7
6
  import { today } from "../lib/dates.js";
8
7
  import { flagBool, flagString } from "../lib/args.js";
9
8
  import { c } from "../lib/colors.js";
10
9
  import { EXIT } from "../lib/exit-codes.js";
11
- import { GIT_STATE, historyState } from "../lib/git.js";
10
+ import { GIT_STATE, historyState, git } from "../lib/git.js";
12
11
  import { notADoctrinaProject } from "../lib/exit-codes.js";
13
12
  import { USAGE_ENV, summarise } from "../lib/usage.js";
14
13
  import { OPERATIONS } from "../lib/commands.js";
14
+ import { REEDIT_WINDOW_DAYS, sinceWindow, computeSnapshot, parseLog, readSeries, latestSnapshot, trend, round } from "../lib/metrics-model.js";
15
15
 
16
16
  // Local-only adoption metrics derived from git history. No network calls,
17
17
  // no telemetry: the numbers stay in the repository, versioned like any
18
18
  // other artifact. This is the tooling half of docs/en/validation.md.
19
19
 
20
- const REEDIT_WINDOW_DAYS = 21;
21
-
22
20
  // Flags this command accepts. Declared HERE, with the command, so
23
21
  // adding a command never requires editing the entrypoint — the gap that
24
22
  // let six flags ship undeclared and silently swallow a positional (C3).
25
- export const flags = { boolean: ["json", "save", "commands"], string: ["since"] };
23
+ export const flags = { boolean: ["json", "save", "commands", "trend"], string: ["since"] };
26
24
 
27
25
  export async function run(_positional, flags) {
28
26
  const projectRoot = process.cwd();
@@ -33,6 +31,20 @@ export async function run(_positional, flags) {
33
31
  // the command surface are actually reached for (M8). Separate report,
34
32
  // separate source, so it never dilutes the adoption metrics.
35
33
  if (flagBool(flags, "commands", false)) return commandUsage();
34
+ // --trend reads the SERIES of saved snapshots rather than git. The
35
+ // snapshots are a time series versioned in the repository, and for two
36
+ // releases the only thing that read them was a delta against the most
37
+ // recent one (audit finding F15) — a year of measurement answering one
38
+ // question. Reading the series is what makes the saving worth doing.
39
+ if (flagBool(flags, "trend", false)) return printTrend(projectRoot);
40
+ // The window is the invocation's: refused the same with or without history.
41
+ const sinceRaw = flagString(flags, "since", "90");
42
+ const since = sinceWindow(sinceRaw);
43
+ if (since === null) {
44
+ console.error(c.red("error:") + ` --since expects a day count, a date (YYYY-MM-DD) or "<n> <unit>s ago", got "${sinceRaw}"`);
45
+ console.error(c.gray("hint: ") + "examples: --since 30 · --since 2026-01-01 · --since \"3 months ago\"");
46
+ return EXIT.USAGE;
47
+ }
36
48
  const probe = git(projectRoot, ["rev-parse", "--is-inside-work-tree"]);
37
49
  // "No history yet" is a VALID state, not a failure — a brand-new project
38
50
  // is the most common state in which someone explores this command, and it
@@ -51,15 +63,13 @@ export async function run(_positional, flags) {
51
63
  return EXIT.OK;
52
64
  }
53
65
 
54
- const sinceRaw = flagString(flags, "since", "90");
55
- const since = /^\d+$/.test(sinceRaw) ? `${sinceRaw} days ago` : sinceRaw;
56
66
  const save = flagBool(flags, "save", false);
57
67
 
58
68
  const log = git(projectRoot, [
59
69
  "log", `--since=${since}`, "--date=unix",
60
70
  "--pretty=format:@@%H|%ct|%s", "--name-only",
61
71
  ]);
62
- if (log.status !== 0) {
72
+ if (log.state !== GIT_STATE.OK) {
63
73
  console.error(c.red("error:") + ` git log failed: ${log.stderr.trim()}`);
64
74
  return EXIT.ENVIRONMENT;
65
75
  }
@@ -78,7 +88,7 @@ export async function run(_positional, flags) {
78
88
  if (save) {
79
89
  const dir = path.join(projectRoot, ".doctrina", "metrics");
80
90
  const file = path.join(dir, `${today()}.json`);
81
- const previous = latestSnapshot(dir, path.basename(file));
91
+ const previous = latestSnapshot(projectRoot, path.basename(file));
82
92
  write(file, JSON.stringify(snapshot, null, 2) + "\n", { force: true });
83
93
  console.log("");
84
94
  console.log(c.green("saved") + ` ${relPath(projectRoot, file)}`);
@@ -93,49 +103,45 @@ export async function run(_positional, flags) {
93
103
  return 0;
94
104
  }
95
105
 
96
- function computeSnapshot(commits, since) {
97
- const total = commits.length;
98
- const reverts = commits.filter((cm) => /^Revert\b/.test(cm.subject)).length;
99
- const fixes = commits.filter((cm) => /^fix[(:!]/.test(cm.subject)).length;
100
-
101
- // File churn: commits per file, top offenders first.
102
- const churn = new Map();
103
- for (const cm of commits) {
104
- for (const f of cm.files) churn.set(f, (churn.get(f) ?? 0) + 1);
106
+ // The saved series, oldest first, with the direction each rate moved across
107
+ // the whole span. No verdict attached: a revert rate moves with team size,
108
+ // release cadence and how the window was drawn, and the CLI cannot tell an
109
+ // improvement from a quieter quarter.
110
+ function printTrend(projectRoot) {
111
+ const series = readSeries(projectRoot);
112
+ console.log(c.bold("Doctrina metrics") + c.gray(" — saved snapshots (.doctrina/metrics/)"));
113
+ console.log("");
114
+ if (series.length === 0) {
115
+ console.log(c.gray("no saved snapshots yet — `doctrina metrics --save` writes the first."));
116
+ return 0;
105
117
  }
106
- const topChurn = [...churn.entries()]
107
- .sort((a, b) => b[1] - a[1])
108
- .slice(0, 5)
109
- .map(([file, count]) => ({ file, commits: count }));
110
-
111
- // Re-edit proxy: a commit counts when it touches a file another commit
112
- // already touched in the previous REEDIT_WINDOW_DAYS. This is a proxy
113
- // for rework, not a verdict — iterative work also trips it.
114
- const windowSec = REEDIT_WINDOW_DAYS * 86400;
115
- const lastTouch = new Map();
116
- let reedits = 0;
117
- for (const cm of [...commits].sort((a, b) => a.ts - b.ts)) {
118
- const isReedit = cm.files.some((f) => {
119
- const prev = lastTouch.get(f);
120
- return prev !== undefined && cm.ts - prev <= windowSec;
121
- });
122
- if (isReedit) reedits += 1;
123
- for (const f of cm.files) lastTouch.set(f, cm.ts);
118
+ console.log(c.gray("date commits reverts fix share re-edit"));
119
+ for (const snap of series) {
120
+ console.log(
121
+ `${String(snap.generated).padEnd(12)} ${String(snap.commits ?? "—").padStart(7)} ` +
122
+ `${pct(snap.revert_rate).padStart(7)} ${pct(snap.fix_share).padStart(9)} ${pct(snap.reedit_rate).padStart(7)}`,
123
+ );
124
124
  }
125
-
126
- return {
127
- generated: today(),
128
- window: since,
129
- commits: total,
130
- reverts,
131
- revert_rate: round(reverts / total),
132
- fix_share: round(fixes / total),
133
- reedit_window_days: REEDIT_WINDOW_DAYS,
134
- reedit_rate: round(reedits / total),
135
- top_churn: topChurn,
136
- };
125
+ const t = trend(series);
126
+ if (t.rows.length === 0) {
127
+ console.log("");
128
+ console.log(c.gray(`one snapshot — a trend needs two. Save another with \`doctrina metrics --save\`.`));
129
+ return 0;
130
+ }
131
+ console.log("");
132
+ console.log(c.bold(`Across ${t.span} snapshots (${t.from} → ${t.to}):`));
133
+ for (const row of t.rows) printDelta(LABELS[row.rate] ?? row.rate, row.first, row.last);
134
+ console.log("");
135
+ console.log(c.gray("A direction, not a verdict: these rates move with team size and cadence."));
136
+ return 0;
137
137
  }
138
138
 
139
+ const LABELS = {
140
+ revert_rate: "revert rate",
141
+ fix_share: "fix share",
142
+ reedit_rate: `${REEDIT_WINDOW_DAYS}-day re-edit rate`,
143
+ };
144
+
139
145
  function printReport(s) {
140
146
  console.log(`commits ${s.commits}`);
141
147
  console.log(`reverts ${s.reverts} (${pct(s.revert_rate)})`);
@@ -158,50 +164,16 @@ function printDelta(label, before, after) {
158
164
  console.log(` ${label}: ${pct(before)} → ${pct(after)} (${arrow})`);
159
165
  }
160
166
 
161
- function latestSnapshot(dir, excludeBase) {
162
- if (!isDir(dir)) return null;
163
- const files = readdirSync(dir)
164
- .filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f) && f !== excludeBase)
165
- .sort();
166
- if (files.length === 0) return null;
167
- try {
168
- return JSON.parse(read(path.join(dir, files[files.length - 1])));
169
- } catch {
170
- return null;
171
- }
172
- }
173
-
174
- function parseLog(stdout) {
175
- const commits = [];
176
- let current = null;
177
- for (const line of stdout.split("\n")) {
178
- if (line.startsWith("@@")) {
179
- const [hash, ts, ...rest] = line.slice(2).split("|");
180
- current = { hash, ts: Number(ts), subject: rest.join("|"), files: [] };
181
- commits.push(current);
182
- } else if (line.trim() !== "" && current) {
183
- current.files.push(line.trim());
184
- }
185
- }
186
- return commits;
187
- }
188
-
189
- function git(cwd, args) {
190
- return spawnSync("git", args, { cwd, encoding: "utf8" });
191
- }
192
-
193
- function round(x) {
194
- return Math.round(x * 1000) / 1000;
195
- }
196
167
 
197
168
  function pct(x) {
169
+ if (typeof x !== "number") return "—";
198
170
  return `${(x * 100).toFixed(1)}%`;
199
171
  }
200
172
 
201
173
  // Which operations were actually invoked, from the opt-in usage log. The
202
- // surface is 36 commands and 59 operations; the audit's charge is that some
203
- // exist because they were easy to add rather than because anyone reaches for
204
- // them. This is the evidence for that argument — or against it. Zero samples
174
+ // audit's charge is that some of the surface exists because it was easy to
175
+ // add rather than because anyone reaches for it (how big that surface is,
176
+ // lib/commands.js owns — a count copied into a comment only rots). This is the evidence for that argument — or against it. Zero samples
205
177
  // is a CANDIDATE for cutting, never a verdict: a command used once a quarter
206
178
  // and a command nobody wants look identical over one week.
207
179
  function commandUsage() {
@@ -252,10 +224,15 @@ Flags:
252
224
  opt-in usage log. Recording is OFF unless DOCTRINA_USAGE_LOG
253
225
  names a file; only the operation is recorded, never
254
226
  arguments, and nothing leaves the machine.
255
- --since <n|date> Window: a day count (default 90) or any git-parseable
256
- date ("2026-01-01", "3 months ago").
227
+ --since <n|date> Window: a day count (default 90), a calendar date
228
+ ("2026-01-01") or "<n> <unit>s ago" ("3 months ago").
229
+ Anything else is refused: git reads any text as a
230
+ date, so a typo would measure the wrong window.
257
231
  --save Write .doctrina/metrics/YYYY-MM-DD.json and print the
258
232
  deltas against the most recent prior snapshot.
233
+ --trend Read the saved snapshots instead of git: every snapshot
234
+ in date order, and the direction each rate moved across
235
+ the whole span (first to last, not last to previous).
259
236
 
260
237
  This is the tooling half of the empirical A/B protocol in
261
238
  docs/en/validation.md: snapshot before adopting Doctrina, snapshot each