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
@@ -0,0 +1,490 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { readdirSync } from "node:fs";
4
+ import { isDir, isFile, read, relPath, walk } from "./fs-ops.js";
5
+ import * as idx from "./index-json.js";
6
+ import { deriveIndex, indexesMatch, listHeader, specHeader } from "./scan.js";
7
+ import { collectRuntimeFindings } from "./runtime.js";
8
+ import { FIX_SHAPED } from "./lexicon.js";
9
+ import { summarize as coverageSummary } from "./coverage-model.js";
10
+ import { summarize as traceSummary } from "./trace-model.js";
11
+
12
+ // What comes next, as DATA (change 0032).
13
+ //
14
+ // This used to live in `commands/next.js` and return strings: the CLI
15
+ // assembled `"doctrina change archive 0031 — applied but not archived"`
16
+ // character by character, and then handed the agent a sentence to
17
+ // re-interpret in order to re-issue the command it had just built. Even
18
+ // `--json` returned that same prose inside quotes. Every other part of the
19
+ // framework exists to keep a machine from having to read English; this was
20
+ // the one place the deterministic chain leaked back into it.
21
+ //
22
+ // So an action is a record. `text` is the human rendering, built HERE from
23
+ // the same fields, so the sentence and the structure can never disagree —
24
+ // one builder owns both. A machine branches on `command`/`args`; a person
25
+ // reads `text`; `next --run` executes the first `runnable` one.
26
+
27
+ /**
28
+ * @typedef {object} Action
29
+ * @property {string} id Stable slug for the KIND of action, not the instance.
30
+ * @property {string|null} command The doctrina operation ("change archive"), or null
31
+ * when the next step is authorship, not a command.
32
+ * @property {string[]} args
33
+ * @property {string} why One line: why this is next.
34
+ * @property {string|null} gate The gate this action clears, when it clears one.
35
+ * @property {"blocking"|"advisory"} severity
36
+ * @property {boolean} runnable Whether `next --run` may execute it unattended.
37
+ * @property {string} text The rendered line. Derived from the fields above.
38
+ */
39
+
40
+ /**
41
+ * Build one action. `text` is derived unless the line needs a shape the
42
+ * default does not cover (a command embedded mid-sentence, a placeholder
43
+ * argument the caller must fill in).
44
+ *
45
+ * `runnable` defaults to FALSE and is opted into deliberately. An action is
46
+ * runnable only when running it unattended is both safe and the whole of
47
+ * what the action asks for. Anything that needs a human to DECIDE —
48
+ * accepting an ADR, writing a proposal, capturing a skill, ticking a box —
49
+ * is not runnable however mechanical the edit would be, because the edit is
50
+ * not the work.
51
+ *
52
+ * @param {Partial<Action> & { id: string, why: string }} spec
53
+ * @returns {Action}
54
+ */
55
+ export function action(spec) {
56
+ const command = spec.command ?? null;
57
+ const args = spec.args ?? [];
58
+ return {
59
+ id: spec.id,
60
+ command,
61
+ args,
62
+ why: spec.why,
63
+ gate: spec.gate ?? null,
64
+ severity: spec.severity ?? "blocking",
65
+ runnable: spec.runnable ?? false,
66
+ text: spec.text ?? defaultText(command, args, spec.why),
67
+ };
68
+ }
69
+
70
+ /** The invocation a person would type, or null when there is no command. */
71
+ export function invocation(a) {
72
+ if (!a.command) return null;
73
+ return `doctrina ${[a.command, ...a.args].join(" ")}`;
74
+ }
75
+
76
+ function defaultText(command, args, why) {
77
+ const cmd = command ? `doctrina ${[command, ...args].join(" ")}` : null;
78
+ return cmd ? `${cmd} — ${why}` : why;
79
+ }
80
+
81
+ /**
82
+ * The priority-ordered action list. Shared by `next`, `prime`, `handoff`
83
+ * and `watch`.
84
+ *
85
+ * `gates` is the already-collected gate state, passed in by `snapshot.js`
86
+ * so the whole read costs one collection (change 0037). Omitted — as `next`
87
+ * omits it — the collectors run here instead; the answer is the same either
88
+ * way, which is the point.
89
+ *
90
+ * @param {string} projectRoot
91
+ * @param {{ coverage?: any, trace?: any, verify?: any }} [gates]
92
+ * @returns {Action[]}
93
+ */
94
+ export function computeActions(projectRoot, gates = {}) {
95
+ /** @type {Action[]} */
96
+ const actions = [];
97
+
98
+ // RUNTIME FIRST (change 0029). A declared wiring that does not hold is
99
+ // not a queue item — it is the reason the last run lied. After a job goes
100
+ // green having executed nothing, "open a change on the observability
101
+ // capability" sends the agent to polish the Markdown of an empty-state
102
+ // message while the cause sits in a file no other action names. A broken
103
+ // declaration outranks every artifact chore below it.
104
+ try {
105
+ const runtime = collectRuntimeFindings(projectRoot);
106
+ const errs = runtime.findings.filter((f) => f.level === "error");
107
+ if (errs.length > 0) {
108
+ actions.push(action({
109
+ id: "runtime-declarations",
110
+ command: "triage",
111
+ gate: "runtime",
112
+ runnable: true,
113
+ why:
114
+ `${errs.length} runtime declaration${errs.length === 1 ? " does" : "s do"} not hold ` +
115
+ `(first: ${errs[0].code} ${errs[0].message.slice(0, 80)}${errs[0].message.length > 80 ? "…" : ""})`,
116
+ }));
117
+ }
118
+ } catch {
119
+ // A malformed contract is `validate`'s finding to make, not a reason
120
+ // for `next` to fall over.
121
+ }
122
+
123
+ // A pending intake is the very first thing to resolve: until it is
124
+ // converted, product.md and the specs are still empty scaffolding.
125
+ const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
126
+ const intakeExists = isFile(intakePath);
127
+ if (intakeExists) {
128
+ const status = (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase();
129
+ if (status !== "converted") {
130
+ actions.push(action({
131
+ id: "intake-pending",
132
+ command: "intake",
133
+ runnable: true,
134
+ why: "a pending intake awaits conversion into product.md and specs",
135
+ }));
136
+ }
137
+ } else if (specCapabilities(projectRoot).length === 0) {
138
+ // And the state BEFORE that one, which had no action at all (second
139
+ // audit). `init` does not write an intake.md — deliberately: an empty
140
+ // pending intake would be a mould passing for content, which change 0065
141
+ // just made a refusable thing. So the condition that means "the specs are
142
+ // not written yet" is read from what IS on disk: no capability spec.
143
+ //
144
+ // Without this the bootstrap door was invisible at exactly the moment it
145
+ // was needed. The hub tells the agent to check a file `init` never
146
+ // creates, and `prime`, `next` and `doctor` named `intake` nowhere.
147
+ actions.push(action({
148
+ id: "bootstrap-unspecced",
149
+ command: "intake",
150
+ args: ["--text", '"<what this project is>"'],
151
+ severity: "blocking",
152
+ why: "no capability is specced yet, so nothing states what this project must do",
153
+ text: 'doctrina intake --text "<what this project is>" — no capability is specced yet; ' +
154
+ "for an existing codebase, `doctrina work --from-diff` backfills from the code instead",
155
+ }));
156
+ }
157
+
158
+ // Index drift comes right after the intake (change 0193). It used to be
159
+ // surfaced LAST as "silent rot" — but `validate` reports it as an error,
160
+ // the close's index-drift step blocks on it, the fix is one runnable
161
+ // command, and the actions below read the very index that drifted. Ranked
162
+ // fifth, under advisory nudges, it told the agent to polish product.md
163
+ // before running the one command that unblocked everything.
164
+ try {
165
+ const current = idx.load(projectRoot);
166
+ if (!indexesMatch(deriveIndex(projectRoot, current), current)) {
167
+ actions.push(action({
168
+ id: "index-drift",
169
+ command: "index rebuild",
170
+ gate: "index",
171
+ runnable: true,
172
+ why: "index.json has drifted from the tree",
173
+ }));
174
+ }
175
+ } catch {
176
+ actions.push(action({
177
+ id: "index-unreadable",
178
+ command: "index rebuild",
179
+ gate: "index",
180
+ runnable: true,
181
+ why: "index.json is missing or unreadable",
182
+ }));
183
+ }
184
+
185
+
186
+ // Open changes drive the loop: finish what is started before opening more.
187
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
188
+ if (isDir(changesDir)) {
189
+ for (const id of readdirSync(changesDir).sort()) {
190
+ if (id === "archive" || id.startsWith(".")) continue;
191
+ if (!isDir(path.join(changesDir, id))) continue;
192
+ const proposalPath = path.join(changesDir, id, "proposal.md");
193
+ if (!isFile(proposalPath)) {
194
+ actions.push(action({
195
+ id: "change-missing-proposal",
196
+ why: `add a proposal.md to .doctrina/changes/${id}/ (open changes need one)`,
197
+ }));
198
+ continue;
199
+ }
200
+ const status = listHeader(read(proposalPath), "Status") ?? "proposed";
201
+ if (status === "applied") {
202
+ actions.push(action({
203
+ id: "change-archive-pending",
204
+ command: "change archive",
205
+ args: [id],
206
+ gate: "archive",
207
+ runnable: true,
208
+ why: "applied but not archived",
209
+ }));
210
+ continue;
211
+ }
212
+ const tasksPath = path.join(changesDir, id, "tasks.md");
213
+ const unchecked = isFile(tasksPath)
214
+ ? (read(tasksPath).match(/^-\s+\[ \]/gm) ?? []).length
215
+ : 0;
216
+ if (unchecked > 0) {
217
+ // Deliberately NOT runnable, and deliberately without a command:
218
+ // the only thing that clears this is doing the work. `change tick`
219
+ // would tick the box, which is the dishonesty every gate here
220
+ // exists to prevent.
221
+ actions.push(action({
222
+ id: "change-tasks-open",
223
+ why: `complete ${unchecked} open task${unchecked === 1 ? "" : "s"} in .doctrina/changes/${id}/tasks.md`,
224
+ }));
225
+ continue;
226
+ }
227
+ const deltas = walk(path.join(changesDir, id, "specs")).filter((p) => p.endsWith("delta.md"));
228
+ if (deltas.length > 0) {
229
+ actions.push(action({
230
+ id: "change-apply-ready",
231
+ command: "change check",
232
+ args: [id],
233
+ gate: "structure",
234
+ runnable: true,
235
+ why: `tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`,
236
+ text: `doctrina change check ${id}, then doctrina close ${id} — ` +
237
+ `tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`,
238
+ }));
239
+ } else {
240
+ actions.push(action({
241
+ id: "change-deltas-missing",
242
+ command: "change apply",
243
+ args: [id],
244
+ why: `add spec deltas under .doctrina/changes/${id}/specs/, or apply as metadata-only`,
245
+ text: `add spec deltas under .doctrina/changes/${id}/specs/, ` +
246
+ `or apply as metadata-only: doctrina change apply ${id}`,
247
+ }));
248
+ }
249
+ }
250
+ }
251
+
252
+ // ADRs stuck in proposed need a human decision; accepted-but-bare ADRs are
253
+ // the rot the review flagged — a decision with nothing behind it (no Evidence,
254
+ // no Landed) is drift waiting to be superseded. Surface the proposed ones
255
+ // first (a pending decision blocks more than a missing stamp).
256
+ const adrDir = path.join(projectRoot, ".doctrina", "decisions");
257
+ /** @type {Action[]} */
258
+ const landNudges = [];
259
+ const isBareValue = (v) => {
260
+ const t = (v ?? "").trim();
261
+ return t === "" || t === "—" || t === "-";
262
+ };
263
+ for (const f of walk(adrDir)) {
264
+ if (!f.endsWith(".md")) continue;
265
+ const m = path.basename(f).match(/^(\d{4})-/);
266
+ if (!m) continue;
267
+ const text = read(f);
268
+ const status = listHeader(text, "Status");
269
+ if (status && status.toLowerCase() === "proposed") {
270
+ // Accepting a decision is the human's call, not a step to automate:
271
+ // `runnable` stays false however mechanical the header edit is.
272
+ actions.push(action({
273
+ id: "adr-proposed",
274
+ command: "decision accept",
275
+ args: [m[1]],
276
+ why: `ADR ${m[1]} is still proposed`,
277
+ text: `review ADR ${m[1]} (${relPath(projectRoot, f)}): doctrina decision accept ${m[1]}, or supersede it`,
278
+ }));
279
+ } else if (status && status.toLowerCase() === "accepted") {
280
+ // Bare only when BOTH anchors are empty (Evidence header present-but-bare
281
+ // and no Landed stamp). An ADR that opts out of Evidence entirely
282
+ // (header absent) is not nagged.
283
+ const evidence = listHeader(text, "Evidence");
284
+ const landed = listHeader(text, "Landed");
285
+ if (evidence !== null && isBareValue(evidence) && isBareValue(landed)) {
286
+ landNudges.push(action({
287
+ id: "adr-unproven",
288
+ command: "decision land",
289
+ args: [m[1]],
290
+ severity: "advisory",
291
+ why: `nothing proves ADR ${m[1]}`,
292
+ text: `record what proves ADR ${m[1]}: cite its **Evidence:**, or once it ships, doctrina decision land ${m[1]}`,
293
+ }));
294
+ }
295
+ }
296
+ }
297
+ for (const n of landNudges) actions.push(n);
298
+
299
+ // No procedural memory captured yet, but the history shows a fix-shaped
300
+ // change — exactly the lesson a skill exists to keep from being relearned
301
+ // (review §5: the change-0003 "tolerate LLM code fences" case). One gentle
302
+ // nudge, only when skills are empty, so it never nags a project that opted in.
303
+ const skillNudge = suggestSkillCapture(projectRoot);
304
+ if (skillNudge) actions.push(skillNudge);
305
+
306
+ // GATE SIGNALS. `next` used to answer "no open work" over a tree where
307
+ // `doctor` reported five findings with a named remedy each: it knew the
308
+ // change/ADR/index/intake/runtime/skill lifecycle and nothing about whether
309
+ // the gates were satisfied. The command whose entire job is to answer "what
310
+ // now?" was the one that said "nothing" (second audit).
311
+ //
312
+ // Change 0037 unified the views so they could not disagree; this is that
313
+ // unification finally reaching `next`. Same collections, same numbers — the
314
+ // only difference is that a view REPORTS and an action RECOMMENDS.
315
+ // The gates measure capabilities, so they say nothing before any exists. A
316
+ // freshly initialised project must be told to run `intake`, not to write
317
+ // acceptance criteria for capabilities it has not named yet — three tidy
318
+ // recommendations ahead of the one that matters is the noise this whole
319
+ // change exists to remove.
320
+ const hasSpecs = specCapabilities(projectRoot).length > 0;
321
+
322
+ const cov = hasSpecs ? (gates.coverage ?? safely(() => coverageSummary(projectRoot))) : null;
323
+ if (cov) {
324
+ if (cov.totalDangling > 0) {
325
+ actions.push(action({
326
+ id: "coverage-dangling",
327
+ command: "coverage",
328
+ gate: "coverage",
329
+ why: `${cov.totalDangling} acceptance criterion(s) cite evidence missing on disk`,
330
+ }));
331
+ } else if (cov.totalCriteria === 0) {
332
+ actions.push(action({
333
+ id: "coverage-none",
334
+ command: null,
335
+ gate: "coverage",
336
+ severity: "advisory",
337
+ why: "no acceptance criteria declared yet — nothing proves any capability",
338
+ text: "write acceptance criteria with cited evidence — no capability is proven yet " +
339
+ "(`doctrina coverage` reports them once they exist)",
340
+ }));
341
+ }
342
+ }
343
+
344
+ const tr = hasSpecs ? (gates.trace ?? safely(() => traceSummary(projectRoot))) : null;
345
+ if (tr) {
346
+ if (tr.dropped > 0) {
347
+ actions.push(action({
348
+ id: "trace-dropped",
349
+ command: "trace",
350
+ gate: "trace",
351
+ why: `${tr.dropped} product intent anchor(s) are realized by no spec`,
352
+ }));
353
+ } else if (tr.anchors === 0) {
354
+ actions.push(action({
355
+ id: "trace-no-anchors",
356
+ command: null,
357
+ gate: "trace",
358
+ severity: "advisory",
359
+ why: "no intent anchors declared in product.md, so nothing traces to product intent",
360
+ text: 'tag product.md success criteria as "- [SC1] ..." and cite them with ' +
361
+ "**Realizes:** in each spec — nothing traces to product intent yet",
362
+ }));
363
+ }
364
+ }
365
+
366
+ // An active spec still `planned` with no note is an inventory claim: the
367
+ // document says the capability is current and the axis says nothing is built.
368
+ const inventoryClaims = activeButUnbuilt(projectRoot);
369
+ if (inventoryClaims.length > 0) {
370
+ actions.push(action({
371
+ id: "spec-inventory-claim",
372
+ command: "spec set",
373
+ args: [inventoryClaims[0], "--implementation", "auto"],
374
+ gate: "validate",
375
+ why: `${inventoryClaims.length} active spec(s) are still "planned" with no note ` +
376
+ `(${inventoryClaims.slice(0, 3).join(", ")})`,
377
+ }));
378
+ }
379
+
380
+ // The build gate undeclared is the quietest failure of all: `close` skips
381
+ // step 7 and every change ships without the project's own tests ever running.
382
+ const verify = hasSpecs ? (gates.verify ?? readVerifyState(projectRoot)) : { configured: true, invalid: false };
383
+ if (verify.invalid) {
384
+ actions.push(action({
385
+ id: "verify-invalid",
386
+ command: null,
387
+ gate: "verify",
388
+ why: ".doctrina/verify.json is not valid JSON, so the build gate cannot run",
389
+ text: "fix .doctrina/verify.json — it is not valid JSON, so `doctrina verify` " +
390
+ "and the close's build gate cannot run",
391
+ }));
392
+ } else if (!verify.configured) {
393
+ actions.push(action({
394
+ id: "verify-unconfigured",
395
+ command: "verify",
396
+ args: ["--init"],
397
+ gate: "verify",
398
+ // NOT runnable, though the command is one call: `verify --init` writes a
399
+ // fail-closed placeholder that a person still has to replace with the
400
+ // project's real commands, so running it unattended is not the whole of
401
+ // what the action asks for. Marking it runnable in change 0064 was a
402
+ // mistake — `next --run` picked it, found no runner registered, and
403
+ // errored where it should have said the next step needs a person.
404
+ why: "no .doctrina/verify.json — the close skips its build gate entirely",
405
+ }));
406
+ }
407
+
408
+ return actions;
409
+ }
410
+
411
+ // Return a single skill-capture nudge, or null. Fires only when no skill has
412
+ // been written yet (skills/ holds nothing but .gitkeep) AND the archive shows a
413
+ // fix-shaped change whose lesson is the textbook case for a skill. Deterministic
414
+ // pattern match on the archived folder name — a hint, never a decision (ADR 0005).
415
+
416
+ function suggestSkillCapture(projectRoot) {
417
+ const skillsDir = path.join(projectRoot, ".doctrina", "skills");
418
+ if (isDir(skillsDir)) {
419
+ const hasSkill = walk(skillsDir).some((f) => f.endsWith(".md"));
420
+ if (hasSkill) return null; // opted in already — never nag
421
+ }
422
+ const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
423
+ if (!isDir(archiveDir)) return null;
424
+ for (const name of readdirSync(archiveDir).sort()) {
425
+ if (!isDir(path.join(archiveDir, name))) continue;
426
+ const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
427
+ if (FIX_SHAPED.test(id)) {
428
+ return action({
429
+ id: "skill-capture",
430
+ command: "skill new",
431
+ args: ["<slug>"],
432
+ severity: "advisory",
433
+ why: "a past fix went uncaptured and no skill exists yet",
434
+ text: `capture a skill from past fixes (e.g. "${id}"): doctrina skill new <slug> — ` +
435
+ `on-demand procedural memory so the next agent does not relearn it (none exist yet)`,
436
+ });
437
+ }
438
+ }
439
+ return null;
440
+ }
441
+
442
+ // A collector that throws is a finding for `validate` to make, never a reason
443
+ // for `next` to fall over — the same posture the runtime block above takes.
444
+ function safely(fn) {
445
+ try {
446
+ return fn();
447
+ } catch {
448
+ return null;
449
+ }
450
+ }
451
+
452
+ // Whether the project declares its build gate. Deliberately the same three
453
+ // states `doctor` reports — configured, absent, invalid — read the same way.
454
+ function readVerifyState(projectRoot) {
455
+ const p = path.join(projectRoot, ".doctrina", "verify.json");
456
+ if (!isFile(p)) return { configured: false, invalid: false };
457
+ try {
458
+ const cfg = JSON.parse(read(p));
459
+ return { configured: Array.isArray(cfg?.checks) && cfg.checks.length > 0, invalid: false };
460
+ } catch {
461
+ return { configured: false, invalid: true };
462
+ }
463
+ }
464
+
465
+ // Active capability specs whose Implementation is still a bare "planned".
466
+ // A note after the value ("planned — backend deferred to Q3") is the declared
467
+ // gap and is left alone, exactly as `validate` leaves it alone.
468
+ function specCapabilities(projectRoot) {
469
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
470
+ if (!isDir(specsDir)) return [];
471
+ return readdirSync(specsDir, { withFileTypes: true })
472
+ .filter((e) => e.isDirectory() && isFile(path.join(specsDir, e.name, "spec.md")))
473
+ .map((e) => e.name)
474
+ .sort();
475
+ }
476
+
477
+ function activeButUnbuilt(projectRoot) {
478
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
479
+ const out = [];
480
+ for (const cap of specCapabilities(projectRoot)) {
481
+ const entry = { name: cap };
482
+ const file = path.join(specsDir, entry.name, "spec.md");
483
+ const text = read(file);
484
+ const status = (specHeader(text, "Status") ?? "").toLowerCase();
485
+ const impl = (specHeader(text, "Implementation") ?? "").trim();
486
+ if (status !== "active") continue;
487
+ if (impl.toLowerCase() === "planned") out.push(entry.name);
488
+ }
489
+ return out.sort();
490
+ }
@@ -126,15 +126,4 @@ export function renderAdapterFile(file, tokens) {
126
126
  });
127
127
  }
128
128
 
129
- // Is this project-relative path one that some adapter owns? Used by
130
- // `adapter remove` so it deletes only what an adapter created, and by
131
- // `init` so it never counts adapter files as user content.
132
- export function isAdapterOwnedPath(projectRoot, relPathPosix) {
133
- for (const name of listAdapterNames(projectRoot)) {
134
- const spec = adapterFiles(projectRoot, name);
135
- if (spec?.files.some((f) => f.relativePath === relPathPosix)) return true;
136
- }
137
- return false;
138
- }
139
-
140
129
  export { exists };
@@ -12,6 +12,25 @@ import { listHeader } from "./scan.js";
12
12
  // (`decision supersede` / `decision new`) instead of drifting past it.
13
13
  // A word match on capability names — a hint, never a decision (ADR 0005),
14
14
  // and never a blocker (an ADR merely MENTIONING a capability is normal).
15
+ /**
16
+ * How many ADRs on disk are accepted.
17
+ *
18
+ * The checkpoint prints "no accepted ADR cites the touched capabilities",
19
+ * which is conformance only when there ARE accepted ADRs to cite them. With
20
+ * none, the sentence is vacuously true and reads as a decision checked
21
+ * (change 0089), so the caller needs the count to tell the two apart.
22
+ */
23
+ export function acceptedDecisionCount(projectRoot) {
24
+ const adrDir = path.join(projectRoot, ".doctrina", "decisions");
25
+ if (!isDir(adrDir)) return 0;
26
+ let n = 0;
27
+ for (const f of walk(adrDir)) {
28
+ if (!/^\d{4}-.*\.md$/.test(path.basename(f))) continue;
29
+ if ((listHeader(read(f), "Status") ?? "").toLowerCase() === "accepted") n += 1;
30
+ }
31
+ return n;
32
+ }
33
+
15
34
  export function adrAdvisories(projectRoot, capabilities) {
16
35
  const adrDir = path.join(projectRoot, ".doctrina", "decisions");
17
36
  if (!isDir(adrDir) || capabilities.length === 0) return [];
@@ -0,0 +1,133 @@
1
+ // @ts-check
2
+ // Drafting the agent-facing changelog from what actually shipped.
3
+ //
4
+ // `AGENT_CHANGELOG` in lib/commands.js is the block `upgrade --write` writes
5
+ // into AGENTS.md: at most five bullets telling an arriving agent what it must
6
+ // now DO differently. It is an object literal somebody edits by hand at every
7
+ // release, in parallel with a 57 KB prose CHANGELOG describing the same
8
+ // changes for people — and the comment above it already admits the approach
9
+ // "worked once and does not scale" (audit finding F22).
10
+ //
11
+ // This module does not write it. It DRAFTS candidates, from the signals the
12
+ // docs gate already extracts per archived change — the commands, flags and
13
+ // exit codes that change said it touched. Authorship stays human, because
14
+ // what an agent must do differently is a judgement about behaviour, and the
15
+ // CLI does not make those (ADR 0005). What it removes is the remembering.
16
+ import path from "node:path";
17
+ import { isDir } from "./fs-ops.js";
18
+ import { readLedger } from "./ledger.js";
19
+ import { documentedSurfaceSignals } from "./docs-impact.js";
20
+ import { AGENT_CHANGELOG_MAX_BULLETS } from "./commands.js";
21
+ import { lastTagDate, windowCutoff } from "./git.js";
22
+
23
+ /**
24
+ * Which window a draft covers, and why that window.
25
+ *
26
+ * "Since the last tag" is the honest default: the block describes the
27
+ * release being cut, and the last tag is where the previous one ended. With
28
+ * no tags (a young project, a shallow clone) it falls back to a day window
29
+ * and SAYS which it used — a draft that quietly covered the wrong period
30
+ * would be worse than one that names its edges.
31
+ *
32
+ * @param {string} projectRoot
33
+ * @param {{days?: number|null}} [options]
34
+ * @returns {{since: string, basis: string}}
35
+ */
36
+ export function draftWindow(projectRoot, { days = null } = {}) {
37
+ if (days !== null) return { since: windowCutoff(days), basis: `the last ${days} days` };
38
+ const tag = lastTagDate(projectRoot);
39
+ if (tag) return { since: tag, basis: "changes archived since the last tag" };
40
+ return { since: windowCutoff(30), basis: "the last 30 days (no tag to measure from)" };
41
+ }
42
+
43
+ /**
44
+ * Candidate bullets for the agent changelog, newest first.
45
+ *
46
+ * One candidate per archived change in the window that touched a documented
47
+ * surface. A change that touched none produces nothing — the block is for
48
+ * what an agent must do differently, and a change no agent can observe is
49
+ * not that.
50
+ *
51
+ * @param {string} projectRoot
52
+ * @param {{days?: number|null, max?: number}} [options]
53
+ * @returns {{
54
+ * since: string, basis: string, max: number,
55
+ * candidates: {id: string, date: string, title: string, signals: string[], bullet: string}[],
56
+ * silent: {id: string, date: string, title: string}[],
57
+ * truncated: number
58
+ * }}
59
+ */
60
+ export function draftAgentChangelog(projectRoot, { days = null, max = AGENT_CHANGELOG_MAX_BULLETS } = {}) {
61
+ const { since, basis } = draftWindow(projectRoot, { days });
62
+ const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
63
+
64
+ const candidates = [];
65
+ const silent = [];
66
+ // The ledger is the record of what landed and when (change 0046); the
67
+ // archived folder is where the change's own artifacts still are.
68
+ const landed = readLedger(projectRoot).entries
69
+ .filter((e) => e.kind === "archived" && e.date >= since)
70
+ .sort((a, b) => b.date.localeCompare(a.date) || b.id.localeCompare(a.id));
71
+
72
+ for (const entry of landed) {
73
+ const dir = path.join(archiveDir, `${entry.date}-${entry.id}`);
74
+ const signals = isDir(dir) ? documentedSurfaceSignals(dir, projectRoot) : [];
75
+ if (signals.length === 0) {
76
+ silent.push({ id: entry.id, date: entry.date, title: entry.title });
77
+ continue;
78
+ }
79
+ candidates.push({
80
+ id: entry.id,
81
+ date: entry.date,
82
+ title: entry.title,
83
+ signals,
84
+ bullet: `${entry.title} (${signals.join("; ")}).`,
85
+ });
86
+ }
87
+
88
+ const truncated = Math.max(0, candidates.length - max);
89
+ return { since, basis, max, candidates: candidates.slice(0, max), silent, truncated };
90
+ }
91
+
92
+ /**
93
+ * The draft, rendered as the block a human edits into lib/commands.js.
94
+ *
95
+ * Deliberately shaped as the literal it will become, so the step is "cut and
96
+ * reword", not "translate". The bullets are drafts of the SHAPE — a title and
97
+ * the surface it touched — and every one of them is expected to be rewritten
98
+ * into what an agent must now do.
99
+ *
100
+ * @param {ReturnType<typeof draftAgentChangelog>} draft
101
+ * @param {string} version
102
+ * @returns {string[]} lines
103
+ */
104
+ export function renderDraft(draft, version) {
105
+ const out = [];
106
+ out.push(`# Agent changelog draft for ${version} — ${draft.basis} (since ${draft.since})`);
107
+ out.push("");
108
+ if (draft.candidates.length === 0) {
109
+ out.push("No archived change in this window touched a documented surface.");
110
+ out.push("Nothing to tell an arriving agent — which is a valid answer, not an empty one.");
111
+ return out;
112
+ }
113
+ out.push("Proposed, newest first. REWRITE each into what an agent must now DO;");
114
+ out.push("this draft only knows what surface the change touched.");
115
+ out.push("");
116
+ out.push(` "${version}": [`);
117
+ for (const cand of draft.candidates) {
118
+ out.push(` // ${cand.date} ${cand.id}`);
119
+ out.push(` ${JSON.stringify(cand.bullet)},`);
120
+ }
121
+ out.push(" ],");
122
+ if (draft.truncated > 0) {
123
+ out.push("");
124
+ out.push(`${draft.truncated} further candidate${draft.truncated === 1 ? "" : "s"} did not fit the ${draft.max}-bullet cap.`);
125
+ out.push("The cap is AGENTS.md's line budget, not a preference: choose, do not raise it.");
126
+ }
127
+ if (draft.silent.length > 0) {
128
+ out.push("");
129
+ out.push(`${draft.silent.length} change${draft.silent.length === 1 ? "" : "s"} touched no documented surface and are not proposed:`);
130
+ for (const s of draft.silent) out.push(` ${s.date} ${s.id}`);
131
+ }
132
+ return out;
133
+ }