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
package/src/lib/gates.js CHANGED
@@ -1,10 +1,11 @@
1
1
  // @ts-check
2
- import { getSection } from "./doc-model.js";
2
+ import { checklistProgress, getSection } from "./doc-model.js";
3
3
  import path from "node:path";
4
4
  import { appendFileSync, writeFileSync } from "node:fs";
5
5
  import { exists, isFile, mkdirp, read } from "./fs-ops.js";
6
6
  import { today } from "./dates.js";
7
- import { collectAnalysis } from "../commands/analyze.js";
7
+ import { collectAnalysis } from "./analysis.js";
8
+ import { appendLedgerLine, forcedLine } from "./ledger.js";
8
9
 
9
10
  // The gate-to-transition map (audit item C6).
10
11
  //
@@ -23,10 +24,10 @@ import { collectAnalysis } from "../commands/analyze.js";
23
24
  // A gate is a named precondition returning human-readable blockers.
24
25
  export const GATES = {
25
26
  // Structural validity: the change's own files parse and point at real
26
- // targets. The same checks `doctrina analyze` reports.
27
+ // targets. The same checks `doctrina change check` reports first.
27
28
  structure: {
28
29
  label: "structure",
29
- rerun: (id) => `doctrina analyze ${id}`,
30
+ rerun: (id) => `doctrina change check ${id}`,
30
31
  blockers(projectRoot, changeDir) {
31
32
  return collectAnalysis(projectRoot, changeDir)
32
33
  .filter((r) => r.kind === "fail")
@@ -48,7 +49,7 @@ export const GATES = {
48
49
  // history recorded six hollow proposals reaching the archive.
49
50
  integrity: {
50
51
  label: "integrity",
51
- rerun: (id) => `doctrina analyze ${id}`,
52
+ rerun: (id) => `doctrina change check ${id}`,
52
53
  blockers(projectRoot, changeDir) {
53
54
  return collectAnalysis(projectRoot, changeDir)
54
55
  .filter((r) => r.kind === "fail" && r.scope !== "pre-apply")
@@ -57,23 +58,26 @@ export const GATES = {
57
58
  },
58
59
 
59
60
  // Verification: the work is claimed complete. Every checkbox in
60
- // tasks.md (closing steps included) and in the proposal's
61
- // "## Verification" section.
61
+ // tasks.md and in the proposal's "## Verification" section — except a
62
+ // legacy "## Closing steps" list, which names what the close itself does
63
+ // (change 0195).
62
64
  verification: {
63
65
  label: "verification",
64
66
  rerun: (id) => `doctrina change tick ${id}`,
65
67
  blockers(projectRoot, changeDir) {
66
68
  const out = [];
67
- const countUnchecked = (s) => (s.match(/^\s*-\s*\[ \]/gm) ?? []).length;
69
+ // Same counter as every other surface (change 0067).
70
+ const countUnchecked = (text, section = null) =>
71
+ checklistProgress(text, { section }).total - checklistProgress(text, { section }).done;
68
72
 
69
73
  const tasksPath = path.join(changeDir, "tasks.md");
70
74
  if (isFile(tasksPath)) {
71
75
  const n = countUnchecked(read(tasksPath));
72
- if (n > 0) out.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md (closing steps count)`);
76
+ if (n > 0) out.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md`);
73
77
  }
74
78
  const proposalPath = path.join(changeDir, "proposal.md");
75
79
  if (isFile(proposalPath)) {
76
- const n = countUnchecked(getSection(read(proposalPath), "Verification"));
80
+ const n = countUnchecked(read(proposalPath), "Verification");
77
81
  if (n > 0) out.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
78
82
  }
79
83
  return out;
@@ -105,6 +109,218 @@ export const TRANSITIONS = {
105
109
  archive: { label: "change archive", gates: ["integrity", "verification"] },
106
110
  };
107
111
 
112
+ // ---------------------------------------------------------------------------
113
+ // The gate SEQUENCES (audit finding F1)
114
+ // ---------------------------------------------------------------------------
115
+ //
116
+ // ADR 0017 promised ONE gate map. TRANSITIONS above delivered it for the two
117
+ // mutating transitions and stopped there: `close` carried a literal array of
118
+ // ten steps, `doctor` eight hand-written rows, and `action.yml` five YAML
119
+ // steps. Four lists, four owners, and nothing that noticed when they
120
+ // diverged — which is how the runtime gate could land in `close` and stay
121
+ // absent from CI (change 0033 had to add it to both by hand, and nothing
122
+ // would have caught it if the second edit had been forgotten).
123
+ //
124
+ // So a sequence is DATA. Each step declares what it is, how hard it bites,
125
+ // and the command that runs it standalone. The surfaces only RENDER it:
126
+ // `close` executes it in order, `doctor` reports each as a row, and
127
+ // `doctrina ci --emit github` writes it as CI YAML. None of them may invent
128
+ // a step, drop one, or reorder — a drift test holds each surface to this
129
+ // declaration, and the emitted YAML to the versioned `action.yml`.
130
+ //
131
+ // What this does NOT decide is which gates belong in a sequence; it declares
132
+ // the sequences that exist so that the answer lives in one file.
133
+
134
+ /**
135
+ * One step of a gate sequence.
136
+ *
137
+ * @typedef {object} GateStep
138
+ * @property {string} id Stable key. The surfaces bind their renderer/runner to it.
139
+ * @property {string} label What the surface calls the step.
140
+ * @property {"blocking"|"advisory"|"forceable"} level
141
+ * blocking — a non-zero result stops the sequence.
142
+ * advisory — reported, never stops anything.
143
+ * forceable — blocking, unless --force, which records the gap in the ledger.
144
+ * @property {string[]|null} argv
145
+ * The standalone CLI invocation this step is, or null when the step is not
146
+ * one command (the ADR checkpoint, the docs gate). The token "<id>" is
147
+ * replaced with the change id at render time.
148
+ * @property {string} [rerun] Literal rerun line, for a step argv cannot describe.
149
+ * @property {string} [why] One line of rationale. The CI emitter writes it as a comment.
150
+ * @property {boolean} [strict] CI only: the command takes --strict when the action runs strict.
151
+ * @property {string} [script] CI only: a shell body that replaces the derived one-liner.
152
+ * @property {string} [flag] Only run/report this step when that flag is set.
153
+ * @property {string} [short] CI only: the name used in the action's own description.
154
+ * @property {string} [summary] CI only: the name plus what it checks, for the file header.
155
+ */
156
+
157
+ /** @type {Record<string, GateStep[]>} */
158
+ export const SEQUENCES = {
159
+ // The closing sequence. `close` runs these in order, in-process where it
160
+ // can, stopping at the first blocking failure with the step's rerun line.
161
+ close: [
162
+ // The structural pre-flight. Its id stays "analyze" (the module it runs
163
+ // in-process), but a reader sees "structure", the gate it holds: change
164
+ // 0182 deprecated `doctrina analyze`, and the command a person reruns is
165
+ // `change check`, which reports the same checks first (change 0183).
166
+ { id: "analyze", label: "structure", level: "blocking", argv: ["analyze", "<id>"], rerun: "doctrina change check <id>" },
167
+ { id: "adr-checkpoint", label: "ADR checkpoint (advisory)", level: "advisory", argv: ["decision", "list"] },
168
+ // Conformance review (audit finding F3). It is the richest analysis the
169
+ // project has — capabilities whose code moved while their spec did not,
170
+ // affected dependants, dangling coverage — and no driver invoked it, so
171
+ // it only ever happened when somebody typed the command.
172
+ //
173
+ // BEFORE the apply, deliberately: that is the point where its findings can
174
+ // still change what gets written. Advisory for now — it raises a break for
175
+ // every capability with touched code and a still spec, and that noise has
176
+ // to be measured before it is allowed to refuse. `review --strict` remains
177
+ // the door for a project that wants CI to block on it.
178
+ { id: "review", label: "review (advisory)", level: "advisory", argv: ["review"] },
179
+ { id: "apply", label: "apply", level: "blocking", argv: ["change", "apply", "<id>"] },
180
+ { id: "runtime", label: "runtime", level: "blocking", argv: ["contract", "check"] },
181
+ { id: "implementation", label: "implementation (advisory)", level: "advisory", argv: null, rerun: "doctrina spec set <cap> --implementation auto" },
182
+ { id: "verify", label: "verify", level: "blocking", argv: ["verify"] },
183
+ { id: "coverage", label: "coverage", level: "blocking", argv: ["coverage", "--strict"] },
184
+ { id: "trace", label: "trace", level: "advisory", argv: ["trace"] },
185
+ { id: "docs", label: "docs", level: "forceable", argv: null, rerun: "document the change, then rerun" },
186
+ { id: "archive", label: "archive", level: "blocking", argv: ["change", "archive", "<id>"] },
187
+ // AFTER the archive, because the archive is the last step that WRITES the
188
+ // index, and a gate placed before the step it guards cannot guard it.
189
+ //
190
+ // The drift check already ran, inside `verify` — and that is exactly why
191
+ // it missed: `verify` is five steps earlier, so it certified an index the
192
+ // archive had not yet rewritten. Change 0138 closed green on a tree whose
193
+ // index had drifted, the commit shipped, and all six test legs of CI went
194
+ // red on the next push. The close was not wrong about what it checked; it
195
+ // checked before the damage.
196
+ //
197
+ // `validate` cannot stand in for it: drift of that kind is only visible by
198
+ // rebuilding the index and comparing, which validate deliberately does not
199
+ // do.
200
+ { id: "index-drift", label: "index drift", level: "blocking", argv: ["index", "rebuild", "--check"] },
201
+ { id: "validate", label: "validate", level: "blocking", argv: ["validate"] },
202
+ ],
203
+
204
+ // The diagnostic sequence. Every row is advisory in the sense that `doctor`
205
+ // never mutates anything; the level says whether the row counts as a
206
+ // failing area (exit 1) or an advisory one (exit 0).
207
+ doctor: [
208
+ { id: "validate", label: "validate", level: "blocking", argv: ["validate"] },
209
+ { id: "index", label: "index", level: "blocking", argv: ["index", "rebuild", "--check"] },
210
+ { id: "coverage", label: "coverage", level: "advisory", argv: ["coverage"] },
211
+ { id: "trace", label: "trace", level: "advisory", argv: ["trace"] },
212
+ { id: "clean-checkout", label: "clean-checkout", level: "blocking", argv: ["verify", "--clean"] },
213
+ { id: "templates", label: "templates", level: "advisory", argv: ["upgrade"] },
214
+ { id: "runtime", label: "runtime", level: "blocking", argv: ["contract", "check"] },
215
+ { id: "local-env", label: "local .env", level: "blocking", argv: null, rerun: "doctrina doctor --env", flag: "env" },
216
+ { id: "verify-config", label: "verify config", level: "blocking", argv: ["verify", "--init"] },
217
+ // The declared size budgets, and the headroom left. Reported BEFORE either
218
+ // is breached: `agents-md-lines` is OUTPUT, so `analyze` refuses the
219
+ // raise-the-ceiling fix, and the generated surface block lives inside
220
+ // AGENTS.md — one command added spends a line of both (change 0072).
221
+ { id: "budgets", label: "budgets", level: "advisory", argv: null, rerun: "doctrina validate" },
222
+ // Not a check — a READOUT. Every other row can fail; this one exists
223
+ // because a project could not see what it had configured without reading
224
+ // the CLI's source, which is how a pt-BR project sat red under `clarify`
225
+ // with no clue why (change 0047). Advisory: a default is not a fault.
226
+ { id: "config", label: "config", level: "advisory", argv: null, rerun: "edit .doctrina/config.json" },
227
+ // Also a readout, and also advisory. It appears only when the operator
228
+ // has switched the usage log on: an instrument that is off has nothing
229
+ // to say, and a row saying so every run would train the reader to skip
230
+ // the section (change 0050).
231
+ { id: "usage", label: "usage", level: "advisory", argv: null, rerun: "doctrina metrics --commands" },
232
+ ],
233
+
234
+ // The CI sequence, emitted as the composite action. Deliberately WITHOUT
235
+ // `verify`: the build gate is the adopting project's own to run, and the
236
+ // action exists to check the artifact tree.
237
+ ci: [
238
+ { id: "validate", label: "doctrina validate", level: "blocking", argv: ["validate"], short: "validate", summary: "validate (schema/structure)" },
239
+ { id: "index-drift", label: "doctrina index rebuild --check", level: "blocking", argv: ["index", "rebuild", "--check"], short: "index drift", summary: "index rebuild --check (index ↔ tree drift)" },
240
+ {
241
+ id: "contract-check",
242
+ label: "doctrina contract check",
243
+ level: "blocking",
244
+ argv: ["contract", "check"],
245
+ short: "contract check",
246
+ summary: "contract check (the declared runtime surface, RT01-RT05)",
247
+ why:
248
+ "The runtime gate (RT01-RT05). Every other step reads Markdown; this one\n" +
249
+ "reads what the Markdown CLAIMS about the running system and holds the\n" +
250
+ "implementation to it — the variable no workflow exports, the default an\n" +
251
+ "empty CI value never triggers, the unvalidated enum, the selector that\n" +
252
+ "matches nothing and still exits 0. Not gated on `strict`: a declaration\n" +
253
+ "that does not hold is an error at any adoption stage. A project with no\n" +
254
+ "contracts prints one line and exits 0.",
255
+ },
256
+ { id: "coverage", label: "doctrina coverage", level: "blocking", argv: ["coverage"], strict: true, short: "coverage", summary: "coverage (acceptance-criteria evidence)" },
257
+ { id: "trace", label: "doctrina trace", level: "blocking", argv: ["trace"], strict: true, short: "trace", summary: "trace (intent provenance)" },
258
+ {
259
+ id: "context-budget",
260
+ label: "doctrina context (budget)",
261
+ level: "blocking",
262
+ argv: ["context"],
263
+ short: "context budget",
264
+ summary: "context (the pack budget, ADR 0022)",
265
+ why:
266
+ "The context budget (ADR 0022). A pack that degrades to fit is fine; this\n" +
267
+ "fails only when a pack's irreducible core no longer fits at all, which\n" +
268
+ "means a spec has outgrown itself or a change has gone stale. Configure\n" +
269
+ "the ceiling per project with \"context_budget\": n in\n" +
270
+ ".doctrina/config.json.",
271
+ script:
272
+ "{{PREFIX}} context >/dev/null\n" +
273
+ "for cap in $(ls .doctrina/specs 2>/dev/null); do\n" +
274
+ " {{PREFIX}} context \"$cap\" >/dev/null\n" +
275
+ "done",
276
+ },
277
+ ],
278
+ };
279
+
280
+ /** The declared steps of a sequence, or an error naming the ones that exist. */
281
+ export function sequence(name) {
282
+ const steps = SEQUENCES[name];
283
+ if (!steps) throw new Error(`unknown gate sequence "${name}" (declared: ${Object.keys(SEQUENCES).join(", ")})`);
284
+ return steps;
285
+ }
286
+
287
+ /**
288
+ * The sequence as a reader sees it: each step's label, in order, with the
289
+ * forceable ones marked the way the advisory ones already are in their own
290
+ * labels.
291
+ *
292
+ * Every surface that SHOWS the sequence renders it from here. Four copies of
293
+ * the close sequence were written out by hand — `close --help`, two adapter
294
+ * command files, and the flow page in both languages — and all four had gone
295
+ * stale, each to a different list: one named four steps of thirteen, another
296
+ * eleven. A sequence a reader can count is a sequence a reader will trust, so
297
+ * it has exactly one author.
298
+ */
299
+ export function sequenceLabels(name) {
300
+ // The marker comes from `level`, never from the label text: labels had
301
+ // already drifted among themselves — `trace` is advisory and did not say so
302
+ // while `ADR checkpoint` did — so any marker already written in is stripped
303
+ // and re-derived. One rule, applied to every step.
304
+ return sequence(name).map((s) => {
305
+ const base = s.label.replace(/\s*\((?:advisory|forceable)\)\s*$/, "");
306
+ if (s.level === "advisory") return `${base} (advisory)`;
307
+ if (s.level === "forceable") return `${base} (forceable)`;
308
+ return base;
309
+ });
310
+ }
311
+
312
+ /**
313
+ * The command that runs one step on its own — what a surface prints when it
314
+ * tells the operator how to clear the gate. Derived from `argv` so the
315
+ * declaration cannot disagree with itself; `rerun` covers the steps that are
316
+ * not a single command.
317
+ */
318
+ export function stepRerun(step, changeId = "<id>") {
319
+ if (step.rerun) return step.rerun.replace(/<id>/g, changeId);
320
+ if (!step.argv) return `doctrina ${step.id}`;
321
+ return `doctrina ${step.argv.map((a) => (a === "<id>" ? changeId : a)).join(" ")}`;
322
+ }
323
+
108
324
  // Evaluate a transition's gates. Returns { ok, blockers, gates } where
109
325
  // blockers is a flat list of human-readable reasons.
110
326
  export function checkTransition(projectRoot, changeDir, transition) {
@@ -123,25 +339,11 @@ export function checkTransition(projectRoot, changeDir, transition) {
123
339
  // waved through is part of the visible history rather than nothing at all
124
340
  // — the same posture `change archive --force` and `change abandon` take.
125
341
  export function recordForcedGap(projectRoot, id, transition, blockers) {
126
- const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
127
- const ledgerPath = path.join(archiveDir, "LEDGER.md");
128
- // Create the ledger if this is the first entry. A forced transition can
129
- // happen before any change has ever been archived, and a gap that goes
130
- // unrecorded because the file did not exist yet is exactly the silence
131
- // this is meant to break.
132
- if (!exists(ledgerPath)) {
133
- mkdirp(archiveDir);
134
- writeFileSync(ledgerPath,
135
- "# Change ledger\n\n" +
136
- "One line per archived change, newest last. Appended by\n" +
137
- "`doctrina change archive`; edit freely, the CLI only appends.\n\n");
138
- }
139
- const summary = blockers.map((b) => `${b.gate}: ${b.message}`).join("; ");
140
- appendFileSync(
141
- ledgerPath,
142
- ` - ${today()} — ${id} — forced ${transition} past ${blockers.length} ` +
143
- `blocker${blockers.length === 1 ? "" : "s"} (${summary})\n`,
144
- );
342
+ // The ledger owns its own format and its own creation (lib/ledger.js): a
343
+ // forced transition can happen before any change has ever been archived,
344
+ // and a gap that goes unrecorded because the file did not exist yet is
345
+ // exactly the silence this is meant to break.
346
+ appendLedgerLine(projectRoot, forcedLine(id, transition, blockers));
145
347
  return true;
146
348
  }
147
349
 
package/src/lib/git.js CHANGED
@@ -69,6 +69,23 @@ export function hasCommits(cwd) {
69
69
  return git(cwd, ["rev-parse", "--verify", "HEAD"]).state === GIT_STATE.OK;
70
70
  }
71
71
 
72
+ /**
73
+ * Does this ref resolve to a commit in this repository?
74
+ *
75
+ * Asked directly, because the message heuristic above cannot answer it: git
76
+ * says "unknown revision or path not in the working tree" both for a
77
+ * repository with no commits and for a ref that does not exist, so a diff
78
+ * against a missing ref came back as GIT_STATE.EMPTY and every caller read
79
+ * it as "nothing changed". `doctrina review --diff main --strict` therefore
80
+ * passed on a shallow clone with no local `main` (change 0092).
81
+ *
82
+ * False when git is absent, this is not a repository, or the ref is unknown —
83
+ * callers that need to tell those apart ask `historyState` as well.
84
+ */
85
+ export function refExists(cwd, ref) {
86
+ return git(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).state === GIT_STATE.OK;
87
+ }
88
+
72
89
  // The state a command should report before trying to read history:
73
90
  // { usable, state, reason } where `reason` is a sentence fit to print.
74
91
  export function historyState(cwd) {
@@ -95,3 +112,118 @@ export function gitLines(cwd, args) {
95
112
  function splitLines(s) {
96
113
  return s.split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
97
114
  }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // The period window the report view describes (change 0037)
118
+ // ---------------------------------------------------------------------------
119
+ //
120
+ // `report` shelled out to git twice with its own spawnSync, which is the one
121
+ // pattern this module exists to end: a caller that reads a non-zero exit as
122
+ // "no commits" rather than "the command refused". Both probes go through
123
+ // `git()` here, so an absent binary, a non-repository and a real failure stay
124
+ // distinguishable — and the view above stays a pure formatter.
125
+
126
+ /** The ISO date `days` ago — the left edge of a report window. */
127
+ export function windowCutoff(days) {
128
+ return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
129
+ }
130
+
131
+ /**
132
+ * Commits, fix-shaped share and top-churn files in the window.
133
+ * Returns null when there is no history to read — outside a repository, with
134
+ * git absent, or before the first commit — so the caller can say so rather
135
+ * than print a confident zero.
136
+ */
137
+ export function gitWindow(cwd, days) {
138
+ const since = `--since=${days} days ago`;
139
+ const log = git(cwd, ["log", since, "--pretty=%s"]);
140
+ if (log.state !== GIT_STATE.OK) return null;
141
+ const subjects = log.lines.filter((l) => l.trim().length > 0);
142
+ const fixes = subjects.filter((sub) => /^(fix|bug|hotfix|patch)(\(|:|!)/i.test(sub)).length;
143
+
144
+ const files = git(cwd, ["log", since, "--name-only", "--pretty=format:"]);
145
+ const counts = new Map();
146
+ if (files.state === GIT_STATE.OK) {
147
+ for (const line of files.lines) {
148
+ const f = line.trim();
149
+ if (!f) continue;
150
+ counts.set(f, (counts.get(f) ?? 0) + 1);
151
+ }
152
+ }
153
+ const churn = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
154
+ return { commits: subjects.length, fixes, churn };
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Changed files: the one answer, four callers (audit finding F5)
159
+ // ---------------------------------------------------------------------------
160
+ //
161
+ // `work`, `review`, `context` and `docs-impact` each carried their own
162
+ // "which files changed" — four spawnSync helpers, four slightly different
163
+ // answers, and each one reading a non-zero exit as an empty list rather than
164
+ // a refusal. The semantics that genuinely differ between them are OPTIONS
165
+ // here, not separate implementations: `docs-impact` needs the merge-base so a
166
+ // branch's earlier commits count, `context --diff` compares against a named
167
+ // ref, and a review against a ref does not want untracked files mixed in.
168
+
169
+ /**
170
+ * The files that changed, as the caller defines "changed".
171
+ *
172
+ * @param {string} cwd
173
+ * @param {object} [opts]
174
+ * @param {string|null} [opts.since] Compare against this ref instead of HEAD.
175
+ * @param {boolean} [opts.untracked] Include untracked, non-ignored files (default true).
176
+ * @param {boolean} [opts.mergeBase] Also include everything since the merge-base with the
177
+ * default branch, so a branch's earlier commits count.
178
+ * @param {string[]} [opts.bases] Which branch names to try as the merge-base target.
179
+ * @returns {{ ok: boolean, files: string[], state: string }}
180
+ * `ok` is false when git could not answer — outside a repository, with git
181
+ * absent, or on a real failure. A caller that treats "not ok" as "nothing
182
+ * changed" is making the mistake this door exists to prevent, so the state
183
+ * is returned rather than folded into an empty list.
184
+ */
185
+ export function changedFiles(cwd, { since = null, untracked = true, mergeBase = false, bases = ["main", "master"] } = {}) {
186
+ const out = new Set();
187
+ const add = (r) => {
188
+ for (const line of r.lines) {
189
+ const p = line.trim();
190
+ if (p) out.add(p.replace(/\\/g, "/"));
191
+ }
192
+ };
193
+
194
+ const diff = git(cwd, since ? ["diff", "--name-only", since, "--"] : ["diff", "--name-only", "HEAD"]);
195
+ if (diff.state !== GIT_STATE.OK) return { ok: false, files: [], state: diff.state };
196
+ add(diff);
197
+
198
+ if (untracked) {
199
+ const others = git(cwd, ["ls-files", "--others", "--exclude-standard"]);
200
+ if (others.state === GIT_STATE.OK) add(others);
201
+ }
202
+
203
+ if (mergeBase) {
204
+ for (const base of bases) {
205
+ const mb = git(cwd, ["merge-base", "HEAD", base]);
206
+ if (mb.state === GIT_STATE.OK && mb.lines[0]) {
207
+ const since = git(cwd, ["diff", "--name-only", `${mb.lines[0].trim()}..HEAD`]);
208
+ if (since.state === GIT_STATE.OK) add(since);
209
+ break;
210
+ }
211
+ }
212
+ }
213
+
214
+ return { ok: true, files: [...out].sort(), state: GIT_STATE.OK };
215
+ }
216
+
217
+ /**
218
+ * The date of the most recent tag, as YYYY-MM-DD, or null.
219
+ *
220
+ * The left edge of "what has landed since the last release". Null when there
221
+ * is no tag, no history, or no repository — the caller then has to say which
222
+ * window it used rather than pretend there was one.
223
+ */
224
+ export function lastTagDate(cwd) {
225
+ const tags = gitLines(cwd, ["for-each-ref", "--sort=-creatordate", "--count=1",
226
+ "--format=%(creatordate:short)", "refs/tags"]);
227
+ const date = tags[0] ?? "";
228
+ return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
229
+ }
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import { read, write, exists } from "./fs-ops.js";
4
- import { cliVersion } from "./version.js";
4
+ import { cliVersion, newestVersion } from "./version.js";
5
5
 
6
6
  export const SCHEMA_VERSION = "0.1.0";
7
7
 
@@ -39,7 +39,7 @@ export function save(projectRoot, index) {
39
39
  // records which CLI last wrote it, so a stale stamp (an index written by an
40
40
  // older CLI) is detectable by `doctrina validate` and migrated by
41
41
  // `doctrina index rebuild`.
42
- index.framework_version = cliVersion();
42
+ index.framework_version = newestVersion(index.framework_version, cliVersion());
43
43
  const p = indexPath(projectRoot);
44
44
  const text = JSON.stringify(index, null, 2) + "\n";
45
45
  write(p, text, { force: true });
@@ -64,10 +64,45 @@ export function blank(projectName, date) {
64
64
  };
65
65
  }
66
66
 
67
+ // INSERT WHERE A REBUILD WOULD HAVE PUT IT.
68
+ //
69
+ // Every category here is written twice: incrementally, by the command that
70
+ // creates the artifact, and wholesale by `index rebuild`, which walks the
71
+ // directory — `readdirSync(...).sort()` — and writes what it finds, in that
72
+ // order. The two agreed only by luck: appending matches a sorted walk exactly
73
+ // when the new entry happens to sort last.
74
+ //
75
+ // It usually does, which is why this survived. `doctrina spec new alpha` after
76
+ // `spec new zebra` does not, and neither does closing change 0138 after 0139 —
77
+ // that one shipped, and turned all six test legs of CI red on a tree whose own
78
+ // `close` had just reported green.
79
+ //
80
+ // `validate` cannot see it: drift of this kind is only visible by rebuilding
81
+ // and comparing, which is `index rebuild --check`, a different gate. So the
82
+ // tree looked healthy from inside and failed from outside.
83
+ //
84
+ // Each entry's `path` embeds the on-disk name the rebuild sorts by, and the
85
+ // parent directory is constant within a category — so ordering by `path`
86
+ // reproduces the walk exactly, without this module having to know how any
87
+ // category is laid out.
88
+ // The comparison has to be the SAME one: `readdirSync(dir).sort()` takes the
89
+ // default comparator, which orders by UTF-16 code unit, and `localeCompare`
90
+ // does not — it ignores or reweights punctuation, so `0100-a` and `0100_a`
91
+ // can come out in the opposite order. Matching the walk means comparing the
92
+ // way the walk compares.
93
+ function insertInRebuildOrder(list, entry) {
94
+ list.push(entry);
95
+ list.sort((a, b) => {
96
+ const x = String(a.path ?? ""), y = String(b.path ?? "");
97
+ return x < y ? -1 : x > y ? 1 : 0;
98
+ });
99
+ return list;
100
+ }
101
+
67
102
  export function addSkill(index, entry) {
68
103
  if (!index.artifacts.skills) index.artifacts.skills = [];
69
104
  if (!index.artifacts.skills.some((s) => s.id === entry.id)) {
70
- index.artifacts.skills.push(entry);
105
+ insertInRebuildOrder(index.artifacts.skills, entry);
71
106
  }
72
107
  return index;
73
108
  }
@@ -75,17 +110,11 @@ export function addSkill(index, entry) {
75
110
  export function addContract(index, entry) {
76
111
  if (!index.artifacts.contracts) index.artifacts.contracts = [];
77
112
  if (!index.artifacts.contracts.some((s) => s.id === entry.id)) {
78
- index.artifacts.contracts.push(entry);
113
+ insertInRebuildOrder(index.artifacts.contracts, entry);
79
114
  }
80
115
  return index;
81
116
  }
82
117
 
83
- export function removeSkill(index, id) {
84
- if (!index.artifacts.skills) return index;
85
- index.artifacts.skills = index.artifacts.skills.filter((s) => s.id !== id);
86
- return index;
87
- }
88
-
89
118
  export function touch(index, date) {
90
119
  index.last_updated = date;
91
120
  return index;
@@ -93,19 +122,14 @@ export function touch(index, date) {
93
122
 
94
123
  export function addSpec(index, entry) {
95
124
  if (!index.artifacts.specs.some((s) => s.id === entry.id)) {
96
- index.artifacts.specs.push(entry);
125
+ insertInRebuildOrder(index.artifacts.specs, entry);
97
126
  }
98
127
  return index;
99
128
  }
100
129
 
101
- export function removeSpec(index, id) {
102
- index.artifacts.specs = index.artifacts.specs.filter((s) => s.id !== id);
103
- return index;
104
- }
105
-
106
130
  export function addDecision(index, entry) {
107
131
  if (!index.artifacts.decisions.some((d) => d.id === entry.id)) {
108
- index.artifacts.decisions.push(entry);
132
+ insertInRebuildOrder(index.artifacts.decisions, entry);
109
133
  }
110
134
  return index;
111
135
  }
@@ -117,17 +141,11 @@ export function updateDecision(index, id, mutator) {
117
141
  return index;
118
142
  }
119
143
 
120
- export function addChange(index, entry) {
121
- if (!index.artifacts.changes.some((c) => c.id === entry.id)) {
122
- index.artifacts.changes.push(entry);
123
- }
124
- return index;
125
- }
126
144
 
127
145
  export function moveChangeToArchive(index, id, archiveEntry) {
128
146
  index.artifacts.changes = index.artifacts.changes.filter((c) => c.id !== id);
129
147
  if (!index.artifacts.changes_archive.some((c) => c.id === id)) {
130
- index.artifacts.changes_archive.push(archiveEntry);
148
+ insertInRebuildOrder(index.artifacts.changes_archive, archiveEntry);
131
149
  }
132
150
  return index;
133
151
  }