doctrina-cli 0.15.1 → 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 (84) 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 -212
  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 +4 -82
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +60 -138
  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 +56 -4
  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 +181 -23
  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 +191 -5
  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/hooks/pre-commit.sample +6 -2
  82. package/templates/playbooks/bootstrap.md.template +43 -0
  83. package/templates/playbooks/chore.md.template +19 -0
  84. package/templates/playbooks/work.md.template +76 -0
package/src/index.js CHANGED
@@ -5,11 +5,11 @@ import { parseArgs } from "./lib/args.js";
5
5
  import { c } from "./lib/colors.js";
6
6
  import { suggest } from "./lib/suggest.js";
7
7
  import { cliVersion } from "./lib/version.js";
8
- import { surfaceHelp, OPERATIONS } from "./lib/commands.js";
8
+ import { surfaceHelp, OPERATIONS, deprecationFor } from "./lib/commands.js";
9
9
  import { GLOBAL_FLAGS } from "./lib/flag-catalog.js";
10
10
  import { EXIT, exitCodeHelp } from "./lib/exit-codes.js";
11
- import { recordUsage } from "./lib/usage.js";
12
- import { wantsJson, emitJson, captureOutput, stripAnsi } from "./lib/json-out.js";
11
+ import { recordUsage, operationOf } from "./lib/usage.js";
12
+ import { wantsJson, emitJson, captureOutput, stripAnsi, setDeprecation, deferJson, flushJson } from "./lib/json-out.js";
13
13
 
14
14
  import * as init from "./commands/init.js";
15
15
  import * as spec from "./commands/spec.js";
@@ -48,6 +48,7 @@ import * as intent from "./commands/intent.js";
48
48
  import * as upgrade from "./commands/upgrade.js";
49
49
  import * as adapter from "./commands/adapter.js";
50
50
  import * as triage from "./commands/triage.js";
51
+ import * as ci from "./commands/ci.js";
51
52
 
52
53
  const COMMANDS = {
53
54
  init, spec, change, decision, validate, hooks, analyze, clarify,
@@ -55,7 +56,7 @@ const COMMANDS = {
55
56
  intake, work, coverage, verify, contract, trace,
56
57
  status, close, review, watch, why, constitution,
57
58
  prime, handoff, show, doctor, report, completion,
58
- intent, upgrade, adapter, triage,
59
+ intent, upgrade, adapter, triage, ci,
59
60
  };
60
61
 
61
62
  const TOP_HELP = `
@@ -127,18 +128,98 @@ async function main(argv) {
127
128
  return 0;
128
129
  }
129
130
 
131
+ // A flag the command does not declare is REFUSED, never ignored.
132
+ //
133
+ // Declaring the flags (C3) fixed the parser swallowing a positional; it
134
+ // did not make anything CHECK the declaration, so an unrecognised flag was
135
+ // simply dropped. On the same tree, `doctrina coverage --strict` exited 1
136
+ // and `doctrina coverage --stricts` exited 0: the gate the operator asked
137
+ // for never ran, and nothing said so. That is the most expensive failure a
138
+ // gate can have, because it is indistinguishable from success — a CI job
139
+ // with a typo in `--strict` stays green forever over a tree the gate would
140
+ // reject (change 0082).
141
+ //
142
+ // Usage error, not gate failure: exit 2 (ADR 0018). Checked after --help,
143
+ // so `doctrina <cmd> --typo --help` still explains the command instead of
144
+ // refusing to.
145
+ if (spec) {
146
+ const declared = new Set([
147
+ ...GLOBAL_FLAGS.boolean, ...GLOBAL_FLAGS.string,
148
+ ...(spec.boolean ?? []), ...(spec.string ?? []),
149
+ ]);
150
+ const undeclared = [...flags.keys()].filter((f) => !declared.has(f));
151
+ if (undeclared.length > 0) {
152
+ const lines = [];
153
+ for (const f of undeclared) {
154
+ lines.push(`error: unknown flag "--${f}" for \`doctrina ${commandName}\``);
155
+ console.error(c.red("error:") + ` unknown flag "--${f}" for \`doctrina ${commandName}\``);
156
+ const guess = suggest(f, [...declared]);
157
+ if (guess) {
158
+ lines.push(`hint: did you mean \`--${guess}\`?`);
159
+ console.error(c.gray("hint: ") + `did you mean \`--${guess}\`?`);
160
+ }
161
+ }
162
+ lines.push(`hint: \`doctrina ${commandName} --help\` lists the flags it accepts`);
163
+ console.error(c.gray("hint: ") + `\`doctrina ${commandName} --help\` lists the flags it accepts`);
164
+ // A consumer that asked for JSON gets JSON, including when the answer
165
+ // is "I refused" (third audit, finding 7). Change 0086 made the
166
+ // envelope tell the truth about the exit code; the flag check runs
167
+ // BEFORE the envelope exists, so a rejected invocation returned exit 2
168
+ // with an empty stdout and the consumer got a parse error instead of
169
+ // `{ok: false, exit_code: 2}`.
170
+ if (wantsJson(flags)) {
171
+ emitJson(operationName(positional), { stderr: lines },
172
+ { ok: false, exitCode: EXIT.USAGE, args: positional.slice(1) });
173
+ }
174
+ return EXIT.USAGE;
175
+ }
176
+ }
177
+
178
+ // A deprecated name keeps working and says so, once, before it runs
179
+ // (change 0049). On stderr, so a piped stdout stays exactly what it was —
180
+ // a warning that corrupts the output it warns about is a breaking change
181
+ // wearing a deprecation's clothes.
182
+ const deprecated = deprecationFor(positional);
183
+ if (deprecated) {
184
+ console.error(c.yellow("deprecated:") + ` this command is superseded — use ${c.cyan(deprecated.use)}`);
185
+ console.error(c.gray(` ${deprecated.why}; the old name still works and will be removed in a later minor.`));
186
+ // And into the envelope, for the consumer that never sees a terminal
187
+ // (change 0061). The prose line above stays for the one that does.
188
+ setDeprecation({ use: deprecated.use, since: deprecated.since, why: deprecated.why });
189
+ }
190
+
130
191
  try {
131
192
  // --json on a command that builds no payload of its own still answers in
132
193
  // JSON: its output is captured into a versioned envelope beside `ok` and
133
194
  // `exit_code`. Branch on those; the lines are for completeness (M7).
134
- if (wantsJson(flags) && !command.jsonNative) {
195
+ // A module with several subcommands is native for some and not others —
196
+ // `contract check` builds a real payload, `contract new` has nothing but
197
+ // its prose — so `jsonNative` may be a predicate over the subcommand's
198
+ // own arguments (change 0068).
199
+ const jsonNative = typeof command.jsonNative === "function"
200
+ ? command.jsonNative(positional.slice(1))
201
+ : command.jsonNative === true;
202
+ if (wantsJson(flags) && !jsonNative) {
135
203
  const { code, stdout, stderr } = await captureOutput(
136
204
  () => command.run(positional.slice(1), flags),
137
205
  );
138
- emitJson([commandName, ...positional.slice(1)].join(" "), {
206
+ emitJson(operationName(positional), {
139
207
  stdout: stdout.map(stripAnsi),
140
208
  stderr: stderr.map(stripAnsi),
141
- }, { ok: code === EXIT.OK, exitCode: code });
209
+ }, { ok: code === EXIT.OK, exitCode: code, args: operationArgs(positional) });
210
+ return code;
211
+ }
212
+ // The native path: hold the payload the command builds, run it, then
213
+ // write the envelope with the code it returned — so `ok` and `exit_code`
214
+ // say what the process says (change 0086).
215
+ if (wantsJson(flags)) {
216
+ deferJson();
217
+ let code = EXIT.OK;
218
+ try {
219
+ code = (await command.run(positional.slice(1), flags)) ?? EXIT.OK;
220
+ } finally {
221
+ flushJson(code);
222
+ }
142
223
  return code;
143
224
  }
144
225
  return await command.run(positional.slice(1), flags);
@@ -157,6 +238,24 @@ async function main(argv) {
157
238
  }
158
239
  }
159
240
 
241
+ // The OPERATION an invocation names, and the arguments it carries — the two
242
+ // halves the envelope's `command` field used to run together (third audit,
243
+ // finding 6). `doctrina why carteira --json` answered `"command": "why
244
+ // carteira"`, so a consumer reading that field got a different shape for
245
+ // every capability, while `next --json` documents `command`/`args` as the
246
+ // contract to branch on. The catalog is what tells a sub-operation
247
+ // (`spec list`) from an argument (`why carteira`) — shape alone cannot,
248
+ // which is the same reason `lib/usage.js` consults it.
249
+ const KNOWN_OPERATIONS = new Set(OPERATIONS.map((o) => o[0]));
250
+
251
+ function operationName(positional) {
252
+ return operationOf(positional, KNOWN_OPERATIONS) ?? positional[0] ?? "";
253
+ }
254
+
255
+ function operationArgs(positional) {
256
+ return positional.slice(operationName(positional).split(" ").length);
257
+ }
258
+
160
259
  main(process.argv.slice(2)).then((code) => {
161
260
  // Record which operation ran, if and only if the operator asked for it
162
261
  // by setting DOCTRINA_USAGE_LOG (M8). Off by default, local file only,
@@ -0,0 +1,484 @@
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
+ // Open changes drive the loop: finish what is started before opening more.
159
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
160
+ if (isDir(changesDir)) {
161
+ for (const id of readdirSync(changesDir).sort()) {
162
+ if (id === "archive" || id.startsWith(".")) continue;
163
+ if (!isDir(path.join(changesDir, id))) continue;
164
+ const proposalPath = path.join(changesDir, id, "proposal.md");
165
+ if (!isFile(proposalPath)) {
166
+ actions.push(action({
167
+ id: "change-missing-proposal",
168
+ why: `add a proposal.md to .doctrina/changes/${id}/ (open changes need one)`,
169
+ }));
170
+ continue;
171
+ }
172
+ const status = listHeader(read(proposalPath), "Status") ?? "proposed";
173
+ if (status === "applied") {
174
+ actions.push(action({
175
+ id: "change-archive-pending",
176
+ command: "change archive",
177
+ args: [id],
178
+ gate: "archive",
179
+ runnable: true,
180
+ why: "applied but not archived",
181
+ }));
182
+ continue;
183
+ }
184
+ const tasksPath = path.join(changesDir, id, "tasks.md");
185
+ const unchecked = isFile(tasksPath)
186
+ ? (read(tasksPath).match(/^-\s+\[ \]/gm) ?? []).length
187
+ : 0;
188
+ if (unchecked > 0) {
189
+ // Deliberately NOT runnable, and deliberately without a command:
190
+ // the only thing that clears this is doing the work. `change tick`
191
+ // would tick the box, which is the dishonesty every gate here
192
+ // exists to prevent.
193
+ actions.push(action({
194
+ id: "change-tasks-open",
195
+ why: `complete ${unchecked} open task${unchecked === 1 ? "" : "s"} in .doctrina/changes/${id}/tasks.md`,
196
+ }));
197
+ continue;
198
+ }
199
+ const deltas = walk(path.join(changesDir, id, "specs")).filter((p) => p.endsWith("delta.md"));
200
+ if (deltas.length > 0) {
201
+ actions.push(action({
202
+ id: "change-apply-ready",
203
+ command: "analyze",
204
+ args: [id],
205
+ gate: "structure",
206
+ runnable: true,
207
+ why: `tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`,
208
+ text: `doctrina analyze ${id}, then doctrina change apply ${id} — ` +
209
+ `tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`,
210
+ }));
211
+ } else {
212
+ actions.push(action({
213
+ id: "change-deltas-missing",
214
+ command: "change apply",
215
+ args: [id],
216
+ why: `add spec deltas under .doctrina/changes/${id}/specs/, or apply as metadata-only`,
217
+ text: `add spec deltas under .doctrina/changes/${id}/specs/, ` +
218
+ `or apply as metadata-only: doctrina change apply ${id}`,
219
+ }));
220
+ }
221
+ }
222
+ }
223
+
224
+ // ADRs stuck in proposed need a human decision; accepted-but-bare ADRs are
225
+ // the rot the review flagged — a decision with nothing behind it (no Evidence,
226
+ // no Landed) is drift waiting to be superseded. Surface the proposed ones
227
+ // first (a pending decision blocks more than a missing stamp).
228
+ const adrDir = path.join(projectRoot, ".doctrina", "decisions");
229
+ /** @type {Action[]} */
230
+ const landNudges = [];
231
+ const isBareValue = (v) => {
232
+ const t = (v ?? "").trim();
233
+ return t === "" || t === "—" || t === "-";
234
+ };
235
+ for (const f of walk(adrDir)) {
236
+ if (!f.endsWith(".md")) continue;
237
+ const m = path.basename(f).match(/^(\d{4})-/);
238
+ if (!m) continue;
239
+ const text = read(f);
240
+ const status = listHeader(text, "Status");
241
+ if (status && status.toLowerCase() === "proposed") {
242
+ // Accepting a decision is the human's call, not a step to automate:
243
+ // `runnable` stays false however mechanical the header edit is.
244
+ actions.push(action({
245
+ id: "adr-proposed",
246
+ command: "decision accept",
247
+ args: [m[1]],
248
+ why: `ADR ${m[1]} is still proposed`,
249
+ text: `review ADR ${m[1]} (${relPath(projectRoot, f)}): doctrina decision accept ${m[1]}, or supersede it`,
250
+ }));
251
+ } else if (status && status.toLowerCase() === "accepted") {
252
+ // Bare only when BOTH anchors are empty (Evidence header present-but-bare
253
+ // and no Landed stamp). An ADR that opts out of Evidence entirely
254
+ // (header absent) is not nagged.
255
+ const evidence = listHeader(text, "Evidence");
256
+ const landed = listHeader(text, "Landed");
257
+ if (evidence !== null && isBareValue(evidence) && isBareValue(landed)) {
258
+ landNudges.push(action({
259
+ id: "adr-unproven",
260
+ command: "decision land",
261
+ args: [m[1]],
262
+ severity: "advisory",
263
+ why: `nothing proves ADR ${m[1]}`,
264
+ text: `record what proves ADR ${m[1]}: cite its **Evidence:**, or once it ships, doctrina decision land ${m[1]}`,
265
+ }));
266
+ }
267
+ }
268
+ }
269
+ for (const n of landNudges) actions.push(n);
270
+
271
+ // No procedural memory captured yet, but the history shows a fix-shaped
272
+ // change — exactly the lesson a skill exists to keep from being relearned
273
+ // (review §5: the change-0003 "tolerate LLM code fences" case). One gentle
274
+ // nudge, only when skills are empty, so it never nags a project that opted in.
275
+ const skillNudge = suggestSkillCapture(projectRoot);
276
+ if (skillNudge) actions.push(skillNudge);
277
+
278
+ // GATE SIGNALS. `next` used to answer "no open work" over a tree where
279
+ // `doctor` reported five findings with a named remedy each: it knew the
280
+ // change/ADR/index/intake/runtime/skill lifecycle and nothing about whether
281
+ // the gates were satisfied. The command whose entire job is to answer "what
282
+ // now?" was the one that said "nothing" (second audit).
283
+ //
284
+ // Change 0037 unified the views so they could not disagree; this is that
285
+ // unification finally reaching `next`. Same collections, same numbers — the
286
+ // only difference is that a view REPORTS and an action RECOMMENDS.
287
+ // The gates measure capabilities, so they say nothing before any exists. A
288
+ // freshly initialised project must be told to run `intake`, not to write
289
+ // acceptance criteria for capabilities it has not named yet — three tidy
290
+ // recommendations ahead of the one that matters is the noise this whole
291
+ // change exists to remove.
292
+ const hasSpecs = specCapabilities(projectRoot).length > 0;
293
+
294
+ const cov = hasSpecs ? (gates.coverage ?? safely(() => coverageSummary(projectRoot))) : null;
295
+ if (cov) {
296
+ if (cov.totalDangling > 0) {
297
+ actions.push(action({
298
+ id: "coverage-dangling",
299
+ command: "coverage",
300
+ gate: "coverage",
301
+ why: `${cov.totalDangling} acceptance criterion(s) cite evidence missing on disk`,
302
+ }));
303
+ } else if (cov.totalCriteria === 0) {
304
+ actions.push(action({
305
+ id: "coverage-none",
306
+ command: null,
307
+ gate: "coverage",
308
+ severity: "advisory",
309
+ why: "no acceptance criteria declared yet — nothing proves any capability",
310
+ text: "write acceptance criteria with cited evidence — no capability is proven yet " +
311
+ "(`doctrina coverage` reports them once they exist)",
312
+ }));
313
+ }
314
+ }
315
+
316
+ const tr = hasSpecs ? (gates.trace ?? safely(() => traceSummary(projectRoot))) : null;
317
+ if (tr) {
318
+ if (tr.dropped > 0) {
319
+ actions.push(action({
320
+ id: "trace-dropped",
321
+ command: "trace",
322
+ gate: "trace",
323
+ why: `${tr.dropped} product intent anchor(s) are realized by no spec`,
324
+ }));
325
+ } else if (tr.anchors === 0) {
326
+ actions.push(action({
327
+ id: "trace-no-anchors",
328
+ command: null,
329
+ gate: "trace",
330
+ severity: "advisory",
331
+ why: "no intent anchors declared in product.md, so nothing traces to product intent",
332
+ text: 'tag product.md success criteria as "- [SC1] ..." and cite them with ' +
333
+ "**Realizes:** in each spec — nothing traces to product intent yet",
334
+ }));
335
+ }
336
+ }
337
+
338
+ // An active spec still `planned` with no note is an inventory claim: the
339
+ // document says the capability is current and the axis says nothing is built.
340
+ const inventoryClaims = activeButUnbuilt(projectRoot);
341
+ if (inventoryClaims.length > 0) {
342
+ actions.push(action({
343
+ id: "spec-inventory-claim",
344
+ command: "spec set",
345
+ args: [inventoryClaims[0], "--implementation", "auto"],
346
+ gate: "validate",
347
+ why: `${inventoryClaims.length} active spec(s) are still "planned" with no note ` +
348
+ `(${inventoryClaims.slice(0, 3).join(", ")})`,
349
+ }));
350
+ }
351
+
352
+ // The build gate undeclared is the quietest failure of all: `close` skips
353
+ // step 7 and every change ships without the project's own tests ever running.
354
+ const verify = hasSpecs ? (gates.verify ?? readVerifyState(projectRoot)) : { configured: true, invalid: false };
355
+ if (verify.invalid) {
356
+ actions.push(action({
357
+ id: "verify-invalid",
358
+ command: null,
359
+ gate: "verify",
360
+ why: ".doctrina/verify.json is not valid JSON, so the build gate cannot run",
361
+ text: "fix .doctrina/verify.json — it is not valid JSON, so `doctrina verify` " +
362
+ "and the close's build gate cannot run",
363
+ }));
364
+ } else if (!verify.configured) {
365
+ actions.push(action({
366
+ id: "verify-unconfigured",
367
+ command: "verify",
368
+ args: ["--init"],
369
+ gate: "verify",
370
+ // NOT runnable, though the command is one call: `verify --init` writes a
371
+ // fail-closed placeholder that a person still has to replace with the
372
+ // project's real commands, so running it unattended is not the whole of
373
+ // what the action asks for. Marking it runnable in change 0064 was a
374
+ // mistake — `next --run` picked it, found no runner registered, and
375
+ // errored where it should have said the next step needs a person.
376
+ why: "no .doctrina/verify.json — the close skips its build gate entirely",
377
+ }));
378
+ }
379
+
380
+ // Index drift is silent rot; surface it last.
381
+ try {
382
+ const current = idx.load(projectRoot);
383
+ if (!indexesMatch(deriveIndex(projectRoot, current), current)) {
384
+ actions.push(action({
385
+ id: "index-drift",
386
+ command: "index rebuild",
387
+ gate: "index",
388
+ runnable: true,
389
+ why: "index.json has drifted from the tree",
390
+ }));
391
+ }
392
+ } catch {
393
+ actions.push(action({
394
+ id: "index-unreadable",
395
+ command: "index rebuild",
396
+ gate: "index",
397
+ runnable: true,
398
+ why: "index.json is missing or unreadable",
399
+ }));
400
+ }
401
+
402
+ return actions;
403
+ }
404
+
405
+ // Return a single skill-capture nudge, or null. Fires only when no skill has
406
+ // been written yet (skills/ holds nothing but .gitkeep) AND the archive shows a
407
+ // fix-shaped change whose lesson is the textbook case for a skill. Deterministic
408
+ // pattern match on the archived folder name — a hint, never a decision (ADR 0005).
409
+
410
+ function suggestSkillCapture(projectRoot) {
411
+ const skillsDir = path.join(projectRoot, ".doctrina", "skills");
412
+ if (isDir(skillsDir)) {
413
+ const hasSkill = walk(skillsDir).some((f) => f.endsWith(".md"));
414
+ if (hasSkill) return null; // opted in already — never nag
415
+ }
416
+ const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
417
+ if (!isDir(archiveDir)) return null;
418
+ for (const name of readdirSync(archiveDir).sort()) {
419
+ if (!isDir(path.join(archiveDir, name))) continue;
420
+ const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
421
+ if (FIX_SHAPED.test(id)) {
422
+ return action({
423
+ id: "skill-capture",
424
+ command: "skill new",
425
+ args: ["<slug>"],
426
+ severity: "advisory",
427
+ why: "a past fix went uncaptured and no skill exists yet",
428
+ text: `capture a skill from past fixes (e.g. "${id}"): doctrina skill new <slug> — ` +
429
+ `on-demand procedural memory so the next agent does not relearn it (none exist yet)`,
430
+ });
431
+ }
432
+ }
433
+ return null;
434
+ }
435
+
436
+ // A collector that throws is a finding for `validate` to make, never a reason
437
+ // for `next` to fall over — the same posture the runtime block above takes.
438
+ function safely(fn) {
439
+ try {
440
+ return fn();
441
+ } catch {
442
+ return null;
443
+ }
444
+ }
445
+
446
+ // Whether the project declares its build gate. Deliberately the same three
447
+ // states `doctor` reports — configured, absent, invalid — read the same way.
448
+ function readVerifyState(projectRoot) {
449
+ const p = path.join(projectRoot, ".doctrina", "verify.json");
450
+ if (!isFile(p)) return { configured: false, invalid: false };
451
+ try {
452
+ const cfg = JSON.parse(read(p));
453
+ return { configured: Array.isArray(cfg?.checks) && cfg.checks.length > 0, invalid: false };
454
+ } catch {
455
+ return { configured: false, invalid: true };
456
+ }
457
+ }
458
+
459
+ // Active capability specs whose Implementation is still a bare "planned".
460
+ // A note after the value ("planned — backend deferred to Q3") is the declared
461
+ // gap and is left alone, exactly as `validate` leaves it alone.
462
+ function specCapabilities(projectRoot) {
463
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
464
+ if (!isDir(specsDir)) return [];
465
+ return readdirSync(specsDir, { withFileTypes: true })
466
+ .filter((e) => e.isDirectory() && isFile(path.join(specsDir, e.name, "spec.md")))
467
+ .map((e) => e.name)
468
+ .sort();
469
+ }
470
+
471
+ function activeButUnbuilt(projectRoot) {
472
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
473
+ const out = [];
474
+ for (const cap of specCapabilities(projectRoot)) {
475
+ const entry = { name: cap };
476
+ const file = path.join(specsDir, entry.name, "spec.md");
477
+ const text = read(file);
478
+ const status = (specHeader(text, "Status") ?? "").toLowerCase();
479
+ const impl = (specHeader(text, "Implementation") ?? "").trim();
480
+ if (status !== "active") continue;
481
+ if (impl.toLowerCase() === "planned") out.push(entry.name);
482
+ }
483
+ return out.sort();
484
+ }
@@ -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 [];