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
package/src/lib/fs-ops.js CHANGED
@@ -2,8 +2,26 @@
2
2
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, renameSync, unlinkSync, rmdirSync } from "node:fs";
3
3
  import path from "node:path";
4
4
 
5
+ // Every module reads a file through here, so the byte-order mark is removed
6
+ // here — once, at the door (third audit, finding 5).
7
+ //
8
+ // A spec saved by a Windows editor opens `# Spec — …`, and `validate`
9
+ // reported "carries no title" while `show`, `spec list` and `coverage` read
10
+ // the same file without complaint: the surfaces disagreed about a file none
11
+ // of them had a problem with. The mark is an encoding artifact of how the
12
+ // bytes were stored, not something the Doctrina grammar has an opinion
13
+ // about, so no parser downstream should have to know it exists.
14
+ //
15
+ // It matters beyond Markdown: `JSON.parse` throws on a leading BOM, so an
16
+ // `index.json` written by the wrong editor was unreadable with an error
17
+ // that named neither the cause nor the file.
18
+ //
19
+ // A file that had one loses it when a command rewrites it. That is a
20
+ // normalisation, and the right one — nothing in this tree is served by
21
+ // keeping it.
5
22
  export function read(p) {
6
- return readFileSync(p, "utf8");
23
+ const text = readFileSync(p, "utf8");
24
+ return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
7
25
  }
8
26
 
9
27
  export function write(p, content, { force = false } = {}) {
@@ -53,6 +71,45 @@ export function remove(p) {
53
71
  }
54
72
  }
55
73
 
74
+ /**
75
+ * Remove `dir` and each ancestor of it, walking up, for as long as each one
76
+ * is EMPTY — stopping at the first directory that still holds something, and
77
+ * never touching `stopAt` itself or anything outside it.
78
+ *
79
+ * The asymmetry this closes: a command that creates a directory with
80
+ * `mkdirp` on the way in has to unmake it on the way out, or "remove" leaves
81
+ * a configuration root behind. An EMPTY directory is the safe case to
82
+ * delete — there is nothing of anyone else's in it — which is why this
83
+ * stops at the first non-empty one rather than recursing.
84
+ *
85
+ * @param {string} dir The innermost directory to consider.
86
+ * @param {string} stopAt Boundary, never removed and never escaped.
87
+ * @returns {string[]} The directories removed, innermost first.
88
+ */
89
+ export function pruneEmptyDirs(dir, stopAt) {
90
+ const removed = [];
91
+ const boundary = path.resolve(stopAt);
92
+ let cur = path.resolve(dir);
93
+ while (cur !== boundary && cur.startsWith(boundary + path.sep)) {
94
+ if (!isDir(cur)) break;
95
+ let entries;
96
+ try {
97
+ entries = readdirSync(cur);
98
+ } catch {
99
+ break;
100
+ }
101
+ if (entries.length > 0) break;
102
+ try {
103
+ rmdirSync(cur);
104
+ } catch {
105
+ break;
106
+ }
107
+ removed.push(cur);
108
+ cur = path.dirname(cur);
109
+ }
110
+ return removed;
111
+ }
112
+
56
113
  // Recursive file walk. Returns absolute paths of every regular file.
57
114
  export function walk(root) {
58
115
  const out = [];
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
  //
@@ -34,6 +35,28 @@ export const GATES = {
34
35
  },
35
36
  },
36
37
 
38
+ // Integrity: the subset of the structural questions that are true at
39
+ // EVERY point in a change's life — the proposal says why and what, the
40
+ // tasks are real, the deltas parse. Everything `structure` asks except
41
+ // the handful that only mean something before an apply.
42
+ //
43
+ // This exists because excluding `structure` wholesale from `archive` (to
44
+ // dodge the post-apply false positive documented below) also excluded the
45
+ // hollow-proposal check, which has no such problem. A change could then be
46
+ // archived — and stamped "applied" — with `## What` still holding nothing
47
+ // but its scaffold comment, after `apply` had refused it for exactly that.
48
+ // Observed on change 0030 of this repository, one release after the
49
+ // history recorded six hollow proposals reaching the archive.
50
+ integrity: {
51
+ label: "integrity",
52
+ rerun: (id) => `doctrina analyze ${id}`,
53
+ blockers(projectRoot, changeDir) {
54
+ return collectAnalysis(projectRoot, changeDir)
55
+ .filter((r) => r.kind === "fail" && r.scope !== "pre-apply")
56
+ .map((r) => stripAnsi(r.line).replace(/^✗\s*/, "").trim());
57
+ },
58
+ },
59
+
37
60
  // Verification: the work is claimed complete. Every checkbox in
38
61
  // tasks.md (closing steps included) and in the proposal's
39
62
  // "## Verification" section.
@@ -42,7 +65,9 @@ export const GATES = {
42
65
  rerun: (id) => `doctrina change tick ${id}`,
43
66
  blockers(projectRoot, changeDir) {
44
67
  const out = [];
45
- const countUnchecked = (s) => (s.match(/^\s*-\s*\[ \]/gm) ?? []).length;
68
+ // Same counter as every other surface (change 0067).
69
+ const countUnchecked = (text, section = null) =>
70
+ checklistProgress(text, { section }).total - checklistProgress(text, { section }).done;
46
71
 
47
72
  const tasksPath = path.join(changeDir, "tasks.md");
48
73
  if (isFile(tasksPath)) {
@@ -51,7 +76,7 @@ export const GATES = {
51
76
  }
52
77
  const proposalPath = path.join(changeDir, "proposal.md");
53
78
  if (isFile(proposalPath)) {
54
- const n = countUnchecked(getSection(read(proposalPath), "Verification"));
79
+ const n = countUnchecked(read(proposalPath), "Verification");
55
80
  if (n > 0) out.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
56
81
  }
57
82
  return out;
@@ -72,11 +97,186 @@ export const GATES = {
72
97
  // content the delta just wrote, so re-asking at archive time reports a
73
98
  // conflict that is the proof the apply worked. A gate must be asked at the
74
99
  // point its question is meaningful.
100
+ //
101
+ // `archive` therefore requires INTEGRITY — that same structural question
102
+ // set minus the pre-apply-only checks — rather than nothing structural at
103
+ // all. Excluding the whole gate to dodge three checks also excluded the
104
+ // hollow-proposal check, and a change refused by `apply` for an unwritten
105
+ // `## What` could still be archived and stamped "applied".
75
106
  export const TRANSITIONS = {
76
107
  apply: { label: "change apply", gates: ["structure"] },
77
- archive: { label: "change archive", gates: ["verification"] },
108
+ archive: { label: "change archive", gates: ["integrity", "verification"] },
109
+ };
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // The gate SEQUENCES (audit finding F1)
113
+ // ---------------------------------------------------------------------------
114
+ //
115
+ // ADR 0017 promised ONE gate map. TRANSITIONS above delivered it for the two
116
+ // mutating transitions and stopped there: `close` carried a literal array of
117
+ // ten steps, `doctor` eight hand-written rows, and `action.yml` five YAML
118
+ // steps. Four lists, four owners, and nothing that noticed when they
119
+ // diverged — which is how the runtime gate could land in `close` and stay
120
+ // absent from CI (change 0033 had to add it to both by hand, and nothing
121
+ // would have caught it if the second edit had been forgotten).
122
+ //
123
+ // So a sequence is DATA. Each step declares what it is, how hard it bites,
124
+ // and the command that runs it standalone. The surfaces only RENDER it:
125
+ // `close` executes it in order, `doctor` reports each as a row, and
126
+ // `doctrina ci --emit github` writes it as CI YAML. None of them may invent
127
+ // a step, drop one, or reorder — a drift test holds each surface to this
128
+ // declaration, and the emitted YAML to the versioned `action.yml`.
129
+ //
130
+ // What this does NOT decide is which gates belong in a sequence; it declares
131
+ // the sequences that exist so that the answer lives in one file.
132
+
133
+ /**
134
+ * One step of a gate sequence.
135
+ *
136
+ * @typedef {object} GateStep
137
+ * @property {string} id Stable key. The surfaces bind their renderer/runner to it.
138
+ * @property {string} label What the surface calls the step.
139
+ * @property {"blocking"|"advisory"|"forceable"} level
140
+ * blocking — a non-zero result stops the sequence.
141
+ * advisory — reported, never stops anything.
142
+ * forceable — blocking, unless --force, which records the gap in the ledger.
143
+ * @property {string[]|null} argv
144
+ * The standalone CLI invocation this step is, or null when the step is not
145
+ * one command (the ADR checkpoint, the docs gate). The token "<id>" is
146
+ * replaced with the change id at render time.
147
+ * @property {string} [rerun] Literal rerun line, for a step argv cannot describe.
148
+ * @property {string} [why] One line of rationale. The CI emitter writes it as a comment.
149
+ * @property {boolean} [strict] CI only: the command takes --strict when the action runs strict.
150
+ * @property {string} [script] CI only: a shell body that replaces the derived one-liner.
151
+ * @property {string} [flag] Only run/report this step when that flag is set.
152
+ * @property {string} [short] CI only: the name used in the action's own description.
153
+ * @property {string} [summary] CI only: the name plus what it checks, for the file header.
154
+ */
155
+
156
+ /** @type {Record<string, GateStep[]>} */
157
+ export const SEQUENCES = {
158
+ // The closing sequence. `close` runs these in order, in-process where it
159
+ // can, stopping at the first blocking failure with the step's rerun line.
160
+ close: [
161
+ { id: "analyze", label: "analyze", level: "blocking", argv: ["analyze", "<id>"] },
162
+ { id: "adr-checkpoint", label: "ADR checkpoint (advisory)", level: "advisory", argv: ["decision", "list"] },
163
+ // Conformance review (audit finding F3). It is the richest analysis the
164
+ // project has — capabilities whose code moved while their spec did not,
165
+ // affected dependants, dangling coverage — and no driver invoked it, so
166
+ // it only ever happened when somebody typed the command.
167
+ //
168
+ // BEFORE the apply, deliberately: that is the point where its findings can
169
+ // still change what gets written. Advisory for now — it raises a break for
170
+ // every capability with touched code and a still spec, and that noise has
171
+ // to be measured before it is allowed to refuse. `review --strict` remains
172
+ // the door for a project that wants CI to block on it.
173
+ { id: "review", label: "review (advisory)", level: "advisory", argv: ["review"] },
174
+ { id: "apply", label: "apply", level: "blocking", argv: ["change", "apply", "<id>"] },
175
+ { id: "runtime", label: "runtime", level: "blocking", argv: ["contract", "check"] },
176
+ { id: "implementation", label: "implementation (advisory)", level: "advisory", argv: null, rerun: "doctrina spec set <cap> --implementation auto" },
177
+ { id: "verify", label: "verify", level: "blocking", argv: ["verify"] },
178
+ { id: "coverage", label: "coverage", level: "blocking", argv: ["coverage", "--strict"] },
179
+ { id: "trace", label: "trace", level: "advisory", argv: ["trace"] },
180
+ { id: "docs", label: "docs", level: "forceable", argv: null, rerun: "document the change, then rerun" },
181
+ { id: "archive", label: "archive", level: "blocking", argv: ["change", "archive", "<id>"] },
182
+ { id: "validate", label: "validate", level: "blocking", argv: ["validate"] },
183
+ ],
184
+
185
+ // The diagnostic sequence. Every row is advisory in the sense that `doctor`
186
+ // never mutates anything; the level says whether the row counts as a
187
+ // failing area (exit 1) or an advisory one (exit 0).
188
+ doctor: [
189
+ { id: "validate", label: "validate", level: "blocking", argv: ["validate"] },
190
+ { id: "index", label: "index", level: "blocking", argv: ["index", "rebuild", "--check"] },
191
+ { id: "coverage", label: "coverage", level: "advisory", argv: ["coverage"] },
192
+ { id: "trace", label: "trace", level: "advisory", argv: ["trace"] },
193
+ { id: "clean-checkout", label: "clean-checkout", level: "blocking", argv: ["verify", "--clean"] },
194
+ { id: "templates", label: "templates", level: "advisory", argv: ["templates", "check"] },
195
+ { id: "runtime", label: "runtime", level: "blocking", argv: ["contract", "check"] },
196
+ { id: "local-env", label: "local .env", level: "blocking", argv: null, rerun: "doctrina doctor --env", flag: "env" },
197
+ { id: "verify-config", label: "verify config", level: "blocking", argv: ["verify", "--init"] },
198
+ // The declared size budgets, and the headroom left. Reported BEFORE either
199
+ // is breached: `agents-md-lines` is OUTPUT, so `analyze` refuses the
200
+ // raise-the-ceiling fix, and the generated surface block lives inside
201
+ // AGENTS.md — one command added spends a line of both (change 0072).
202
+ { id: "budgets", label: "budgets", level: "advisory", argv: null, rerun: "doctrina validate" },
203
+ // Not a check — a READOUT. Every other row can fail; this one exists
204
+ // because a project could not see what it had configured without reading
205
+ // the CLI's source, which is how a pt-BR project sat red under `clarify`
206
+ // with no clue why (change 0047). Advisory: a default is not a fault.
207
+ { id: "config", label: "config", level: "advisory", argv: null, rerun: "edit .doctrina/config.json" },
208
+ // Also a readout, and also advisory. It appears only when the operator
209
+ // has switched the usage log on: an instrument that is off has nothing
210
+ // to say, and a row saying so every run would train the reader to skip
211
+ // the section (change 0050).
212
+ { id: "usage", label: "usage", level: "advisory", argv: null, rerun: "doctrina metrics --commands" },
213
+ ],
214
+
215
+ // The CI sequence, emitted as the composite action. Deliberately WITHOUT
216
+ // `verify`: the build gate is the adopting project's own to run, and the
217
+ // action exists to check the artifact tree.
218
+ ci: [
219
+ { id: "validate", label: "doctrina validate", level: "blocking", argv: ["validate"], short: "validate", summary: "validate (schema/structure)" },
220
+ { id: "index-drift", label: "doctrina index rebuild --check", level: "blocking", argv: ["index", "rebuild", "--check"], short: "index drift", summary: "index rebuild --check (index ↔ tree drift)" },
221
+ {
222
+ id: "contract-check",
223
+ label: "doctrina contract check",
224
+ level: "blocking",
225
+ argv: ["contract", "check"],
226
+ short: "contract check",
227
+ summary: "contract check (the declared runtime surface, RT01-RT05)",
228
+ why:
229
+ "The runtime gate (RT01-RT05). Every other step reads Markdown; this one\n" +
230
+ "reads what the Markdown CLAIMS about the running system and holds the\n" +
231
+ "implementation to it — the variable no workflow exports, the default an\n" +
232
+ "empty CI value never triggers, the unvalidated enum, the selector that\n" +
233
+ "matches nothing and still exits 0. Not gated on `strict`: a declaration\n" +
234
+ "that does not hold is an error at any adoption stage. A project with no\n" +
235
+ "contracts prints one line and exits 0.",
236
+ },
237
+ { id: "coverage", label: "doctrina coverage", level: "blocking", argv: ["coverage"], strict: true, short: "coverage", summary: "coverage (acceptance-criteria evidence)" },
238
+ { id: "trace", label: "doctrina trace", level: "blocking", argv: ["trace"], strict: true, short: "trace", summary: "trace (intent provenance)" },
239
+ {
240
+ id: "context-budget",
241
+ label: "doctrina context (budget)",
242
+ level: "blocking",
243
+ argv: ["context"],
244
+ short: "context budget",
245
+ summary: "context (the pack budget, ADR 0022)",
246
+ why:
247
+ "The context budget (ADR 0022). A pack that degrades to fit is fine; this\n" +
248
+ "fails only when a pack's irreducible core no longer fits at all, which\n" +
249
+ "means a spec has outgrown itself or a change has gone stale. Configure\n" +
250
+ "the ceiling per project with \"context_budget\": n in\n" +
251
+ ".doctrina/config.json.",
252
+ script:
253
+ "{{PREFIX}} context >/dev/null\n" +
254
+ "for cap in $(ls .doctrina/specs 2>/dev/null); do\n" +
255
+ " {{PREFIX}} context \"$cap\" >/dev/null\n" +
256
+ "done",
257
+ },
258
+ ],
78
259
  };
79
260
 
261
+ /** The declared steps of a sequence, or an error naming the ones that exist. */
262
+ export function sequence(name) {
263
+ const steps = SEQUENCES[name];
264
+ if (!steps) throw new Error(`unknown gate sequence "${name}" (declared: ${Object.keys(SEQUENCES).join(", ")})`);
265
+ return steps;
266
+ }
267
+
268
+ /**
269
+ * The command that runs one step on its own — what a surface prints when it
270
+ * tells the operator how to clear the gate. Derived from `argv` so the
271
+ * declaration cannot disagree with itself; `rerun` covers the steps that are
272
+ * not a single command.
273
+ */
274
+ export function stepRerun(step, changeId = "<id>") {
275
+ if (step.rerun) return step.rerun;
276
+ if (!step.argv) return `doctrina ${step.id}`;
277
+ return `doctrina ${step.argv.map((a) => (a === "<id>" ? changeId : a)).join(" ")}`;
278
+ }
279
+
80
280
  // Evaluate a transition's gates. Returns { ok, blockers, gates } where
81
281
  // blockers is a flat list of human-readable reasons.
82
282
  export function checkTransition(projectRoot, changeDir, transition) {
@@ -95,25 +295,11 @@ export function checkTransition(projectRoot, changeDir, transition) {
95
295
  // waved through is part of the visible history rather than nothing at all
96
296
  // — the same posture `change archive --force` and `change abandon` take.
97
297
  export function recordForcedGap(projectRoot, id, transition, blockers) {
98
- const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
99
- const ledgerPath = path.join(archiveDir, "LEDGER.md");
100
- // Create the ledger if this is the first entry. A forced transition can
101
- // happen before any change has ever been archived, and a gap that goes
102
- // unrecorded because the file did not exist yet is exactly the silence
103
- // this is meant to break.
104
- if (!exists(ledgerPath)) {
105
- mkdirp(archiveDir);
106
- writeFileSync(ledgerPath,
107
- "# Change ledger\n\n" +
108
- "One line per archived change, newest last. Appended by\n" +
109
- "`doctrina change archive`; edit freely, the CLI only appends.\n\n");
110
- }
111
- const summary = blockers.map((b) => `${b.gate}: ${b.message}`).join("; ");
112
- appendFileSync(
113
- ledgerPath,
114
- ` - ${today()} — ${id} — forced ${transition} past ${blockers.length} ` +
115
- `blocker${blockers.length === 1 ? "" : "s"} (${summary})\n`,
116
- );
298
+ // The ledger owns its own format and its own creation (lib/ledger.js): a
299
+ // forced transition can happen before any change has ever been archived,
300
+ // and a gap that goes unrecorded because the file did not exist yet is
301
+ // exactly the silence this is meant to break.
302
+ appendLedgerLine(projectRoot, forcedLine(id, transition, blockers));
117
303
  return true;
118
304
  }
119
305
 
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 });
@@ -80,12 +80,6 @@ export function addContract(index, entry) {
80
80
  return index;
81
81
  }
82
82
 
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
83
  export function touch(index, date) {
90
84
  index.last_updated = date;
91
85
  return index;
@@ -98,11 +92,6 @@ export function addSpec(index, entry) {
98
92
  return index;
99
93
  }
100
94
 
101
- export function removeSpec(index, id) {
102
- index.artifacts.specs = index.artifacts.specs.filter((s) => s.id !== id);
103
- return index;
104
- }
105
-
106
95
  export function addDecision(index, entry) {
107
96
  if (!index.artifacts.decisions.some((d) => d.id === entry.id)) {
108
97
  index.artifacts.decisions.push(entry);
@@ -117,12 +106,6 @@ export function updateDecision(index, id, mutator) {
117
106
  return index;
118
107
  }
119
108
 
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
109
 
127
110
  export function moveChangeToArchive(index, id, archiveEntry) {
128
111
  index.artifacts.changes = index.artifacts.changes.filter((c) => c.id !== id);
@@ -0,0 +1,86 @@
1
+ // @ts-check
2
+ // Writing the intake and printing the bootstrap playbook.
3
+ //
4
+ // `init --intake` performs the same three operations `intake` does, and
5
+ // reached into `commands/intake.js` for them (audit finding F7). They are
6
+ // operations over the tree, not renderings of one command, so both
7
+ // entry points read them from here.
8
+ import path from "node:path";
9
+ import { exists, isFile, read, relPath, write } from "./fs-ops.js";
10
+ import { assessBrief } from "./clarity.js";
11
+ import { c } from "./colors.js";
12
+ import { printPlaybookTemplate } from "./playbook.js";
13
+ // Clarification gate (review Topic A). A description too thin to spec from is
14
+ // the moment to ask the user, not to let the agent invent requirements. Prints
15
+ // the specific gaps before the bootstrap playbook; advisory, never blocking —
16
+ // the intake is still captured verbatim (the playbook step 6 resolves it).
17
+ /**
18
+ * Does this argument read as a FILE PATH, or as the description itself?
19
+ *
20
+ * `doctrina intake "<description>"` is the form the surface block's wording
21
+ * invites — "store the intent" — and it answered `description file not found:`
22
+ * followed by the author's whole sentence, echoed back as if it were a
23
+ * filename, with no mention of `--text` (change 0071). Prose and a path are
24
+ * distinguishable without guessing: a path has no sentence in it.
25
+ *
26
+ * Deliberately conservative — anything that could be a path IS treated as one,
27
+ * so a real file is never mistaken for prose and read as a description.
28
+ *
29
+ * @param {string} value
30
+ * @returns {boolean}
31
+ */
32
+ export function looksLikePath(value) {
33
+ const v = String(value).trim();
34
+ if (v === "") return false;
35
+ if (/[/\\]/.test(v)) return true; // a separator settles it
36
+ if (/^[.~]/.test(v)) return true; // ./x, ../x, ~/x
37
+ if (!/\s/.test(v)) return true; // one token: assume a filename
38
+ if (/\.(md|txt|markdown|rst|adoc|org)$/i.test(v)) return true; // "my notes.txt"
39
+ return false;
40
+ }
41
+
42
+ export function warnIfThinIntake(body) {
43
+ const assessment = assessBrief(body, { kind: "intake" });
44
+ if (!assessment.thin) return;
45
+ console.log(c.yellow("⚠ thin intake — clarify with the user before converting to specs:"));
46
+ for (const reason of assessment.reasons) console.log(` - ${reason}`);
47
+ console.log("");
48
+ }
49
+
50
+ // Shared with `init --intake`. Writes the intake verbatim under a small
51
+ // status header; the agent flips Status to converted at the end of the
52
+ // playbook, which is how `next` knows the bootstrap is done.
53
+ export function writeIntakeFile(projectRoot, { body, source, projectName, date, force = false }) {
54
+ const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
55
+ const content = [
56
+ `# Intake — ${projectName}`,
57
+ "",
58
+ "- **Status:** pending",
59
+ `- **Date:** ${date}`,
60
+ `- **Source:** ${source}`,
61
+ "",
62
+ "<!-- Raw project intent, stored verbatim. The bootstrap playbook",
63
+ " (doctrina intake) converts it into product.md and capability",
64
+ " specs, then flips Status to converted. After conversion the",
65
+ " specs are the only source of truth; never edit this file to",
66
+ " change requirements. -->",
67
+ "",
68
+ "---",
69
+ "",
70
+ body.replace(/\r\n/g, "\n").replace(/\n*$/, "\n"),
71
+ ].join("\n");
72
+ write(intakePath, content, { force });
73
+ return intakePath;
74
+ }
75
+
76
+ // The bootstrap playbook, like the work and chore playbooks, is a TEMPLATE
77
+ // resolved project-over-bundled (audit finding F17). What stays here is the
78
+ // one conditional: a tree that never got its specs/ directory.
79
+ export function printBootstrapPlaybook(projectRoot) {
80
+ const hasSpecs = exists(path.join(projectRoot, ".doctrina", "specs"));
81
+ printPlaybookTemplate(projectRoot, "bootstrap", {
82
+ MISSING_SPECS_WARNING: hasSpecs
83
+ ? ""
84
+ : "\n" + c.yellow("warn:") + " .doctrina/specs/ is missing — re-run `doctrina init` first.",
85
+ });
86
+ }