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
@@ -11,6 +11,28 @@ import { readFileSync } from "node:fs";
11
11
 
12
12
  const here = path.dirname(fileURLToPath(import.meta.url));
13
13
 
14
+ // Numeric semver comparison ("0.15.10" sorts above "0.15.9"); anything
15
+ // that is not X.Y.Z sorts lowest, so a garbage stamp is always migrated.
16
+ export function compareVersions(a, b) {
17
+ const parse = (v) => {
18
+ const m = String(v ?? "").match(/^(\d+)\.(\d+)\.(\d+)$/);
19
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
20
+ };
21
+ const pa = parse(a); const pb = parse(b);
22
+ if (!pa && !pb) return 0;
23
+ if (!pa) return -1;
24
+ if (!pb) return 1;
25
+ for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
26
+ return 0;
27
+ }
28
+
29
+ // The stamp to WRITE: the newer of what the index holds and what runs
30
+ // (change 0116). An older CLI rebuilding a newer tree keeps the newer
31
+ // stamp; a newer CLI migrates an older one forward. Never rewound.
32
+ export function newestVersion(current, running) {
33
+ return compareVersions(current, running) > 0 ? String(current) : String(running);
34
+ }
35
+
14
36
  export function cliVersion() {
15
37
  // src/lib/version.js -> packages/doctrina-cli/package.json
16
38
  const pkgPath = path.resolve(here, "..", "..", "package.json");
@@ -0,0 +1,410 @@
1
+ // @ts-check
2
+ import { c } from "./colors.js";
3
+ import { today } from "./dates.js";
4
+ import { churnByCapability } from "./ledger.js";
5
+
6
+ // Executed proof and signed proof are different claims (change 0039). One
7
+ // phrasing, used by every view, so none of them can quietly add the two
8
+ // together and call the total green.
9
+ function signoffNote(verify, { verbose = false } = {}) {
10
+ const s = verify.signoffs;
11
+ if (!s || s.manual === 0) return "";
12
+ const stale = s.expired + s.unverifiable + s.pending;
13
+ if (stale === 0) return verbose ? ` (${s.manual} signed, all current)` : ` · ${s.manual} signed`;
14
+ const parts = [];
15
+ if (s.expired) parts.push(`${s.expired} expired`);
16
+ if (s.unverifiable) parts.push(`${s.unverifiable} unverifiable`);
17
+ if (s.pending) parts.push(`${s.pending} unsigned`);
18
+ return ` (${s.manual} signed, ${parts.join(", ")})`;
19
+ }
20
+
21
+ // The four VIEWS of one snapshot (audit finding F7).
22
+ //
23
+ // `status`, `prime`, `handoff` and `report` differ only in shape: a
24
+ // dashboard, a session primer, a Markdown resume note, a period digest. They
25
+ // were four commands, each re-collecting the tree and reaching into the
26
+ // others' modules for the parts it did not collect itself.
27
+ //
28
+ // Each function below takes the snapshot lib/snapshot.js already produced and
29
+ // returns lines. They are pure: no reads, no writes, no process exits — which
30
+ // is what makes "the four views agree" a property of the code rather than a
31
+ // promise, and what lets `doctrina status --view <name>` render any of them.
32
+
33
+ export const VIEWS = ["dashboard", "prime", "handoff", "report", "rules"];
34
+
35
+ // Count the recorded lanes, keeping "unknown" as its own row rather than
36
+ // dropping it: a mix that silently omits the changes it could not classify
37
+ // reports a cleaner project than the one that exists.
38
+ function laneMix(records) {
39
+ const counts = new Map();
40
+ let overridden = 0;
41
+ let total = 0;
42
+ for (const raw of records) {
43
+ total += 1;
44
+ if (!raw) {
45
+ counts.set("unknown", (counts.get("unknown") ?? 0) + 1);
46
+ continue;
47
+ }
48
+ const lane = String(raw).split(/[\s(]/)[0] || "unknown";
49
+ counts.set(lane, (counts.get(lane) ?? 0) + 1);
50
+ if (/opened as|opened anyway/.test(raw)) overridden += 1;
51
+ }
52
+ return { total, overridden, rows: [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) };
53
+ }
54
+
55
+ /** Render one named view. Unknown names are a caller error, not a fallback. */
56
+ // One rendering of the coverage ratio, shared by every view (change 0037
57
+ // unified the collector; this is the same argument for the label). A pct of
58
+ // null means no criterion was declared — there is nothing to be a ratio OF,
59
+ // so the views say so instead of printing a score. `trace` has always said
60
+ // "no anchors declared" for its own empty case; coverage now matches it.
61
+ export function coverageLabel(cov) {
62
+ if (cov.pct === null) return `no criteria declared (0/0)`;
63
+ return `${cov.pct}% (${cov.totalCovered}/${cov.totalCriteria} criteria)`;
64
+ }
65
+
66
+ export function renderView(name, snapshot, options = {}) {
67
+ switch (name) {
68
+ case "dashboard": return dashboard(snapshot);
69
+ case "prime": return prime(snapshot);
70
+ case "handoff": return handoff(snapshot);
71
+ case "report": return report(snapshot, options);
72
+ case "rules": return rules(snapshot);
73
+ default: throw new Error(`unknown view "${name}" (declared: ${VIEWS.join(", ")})`);
74
+ }
75
+ }
76
+
77
+ // --------------------------------------------------------------- dashboard
78
+
79
+ export function dashboard(s) {
80
+ const out = [];
81
+ out.push(c.bold("Doctrina status") + c.gray(` — ${s.project} (framework ${s.stamp ?? "—"} / CLI ${s.cli})`));
82
+ out.push("");
83
+
84
+ out.push(c.bold(" Gates"));
85
+ let driftLine;
86
+ if (s.indexState === "missing") {
87
+ driftLine = c.red("missing/unreadable") + c.gray(" — run `doctrina index rebuild`");
88
+ } else if (s.indexState === "in-sync") {
89
+ driftLine = c.green("in sync");
90
+ } else {
91
+ driftLine = c.yellow("drifted") + c.gray(" — run `doctrina validate --fix`");
92
+ }
93
+ out.push(` ${"index".padEnd(11)} ${driftLine}`);
94
+
95
+ const stampLine = s.stamp === s.cli
96
+ ? c.green("current")
97
+ : c.yellow(`${s.stamp ?? "—"} (CLI ${s.cli})`) + c.gray(" — `doctrina index rebuild`");
98
+ out.push(` ${"stamp".padEnd(11)} ${stampLine}`);
99
+
100
+ const cov = s.coverage;
101
+ const covExtra = cov.totalDangling + cov.totalConditional > 0
102
+ ? c.yellow(` ${cov.totalDangling} dangling, ${cov.totalConditional} conditional`)
103
+ : "";
104
+ if (cov.pct === null) {
105
+ out.push(` ${"coverage".padEnd(11)} ${c.yellow("no criteria declared")} ${c.gray("(0/0)")}`);
106
+ } else {
107
+ const covColor = cov.pct === 100 ? c.green : cov.pct >= 50 ? c.yellow : c.red;
108
+ out.push(` ${"coverage".padEnd(11)} ${covColor(`${cov.pct}%`)} ${c.gray(`(${cov.totalCovered}/${cov.totalCriteria} criteria)`)}${covExtra}`);
109
+ }
110
+
111
+ const tr = s.trace;
112
+ const trExtra = tr.untraceable + tr.dropped + tr.dangling > 0
113
+ ? c.yellow(` ${tr.dropped} dropped, ${tr.untraceable} untraceable`)
114
+ : "";
115
+ const trColor = tr.anchors === 0 ? c.gray : (tr.realized === tr.anchors && tr.untraceable === 0 ? c.green : c.yellow);
116
+ const trText = tr.anchors === 0 ? "no anchors" : `${tr.realized}/${tr.anchors} anchors`;
117
+ out.push(` ${"trace".padEnd(11)} ${trColor(trText)}${trExtra}`);
118
+
119
+ let verifyLine = c.gray("not configured") + c.gray(" — `doctrina verify --init`");
120
+ if (s.verify.invalid) {
121
+ verifyLine = c.red("invalid JSON");
122
+ } else if (s.verify.configured) {
123
+ const n = s.verify.checks;
124
+ const stale = s.verify.signoffs
125
+ ? s.verify.signoffs.expired + s.verify.signoffs.unverifiable + s.verify.signoffs.pending
126
+ : 0;
127
+ verifyLine = c.cyan(`${n} check${n === 1 ? "" : "s"}`) +
128
+ (stale > 0 ? c.yellow(signoffNote(s.verify)) : c.gray(signoffNote(s.verify))) +
129
+ c.gray(" — run `doctrina verify`");
130
+ }
131
+ out.push(` ${"verify".padEnd(11)} ${verifyLine}`);
132
+
133
+ out.push("");
134
+ out.push(c.bold(" Work"));
135
+ const implBreak = Object.entries(s.specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
136
+ out.push(` ${"specs".padEnd(11)} ${s.specs.total}${implBreak ? c.gray(` (${implBreak})`) : ""}`);
137
+ out.push(` ${"changes".padEnd(11)} ${s.specs.openChanges} open`);
138
+ const adrNotes = [];
139
+ if (s.decisions.proposed > 0) adrNotes.push(c.yellow(`${s.decisions.proposed} proposed`));
140
+ if (s.decisions.bare > 0) adrNotes.push(c.yellow(`${s.decisions.bare} unproven`));
141
+ out.push(` ${"decisions".padEnd(11)} ${s.decisions.total}${adrNotes.length ? c.gray(" (") + adrNotes.join(c.gray(", ")) + c.gray(")") : ""}`);
142
+ out.push(` ${"skills".padEnd(11)} ${s.skills}`);
143
+
144
+ out.push("");
145
+ out.push(c.gray(" Full gates: `doctrina validate` · `doctrina verify`. Next step: `doctrina next`."));
146
+ return out;
147
+ }
148
+
149
+ // ------------------------------------------------------------------- prime
150
+
151
+ export function prime(s) {
152
+ const out = [];
153
+ out.push(c.bold("Doctrina prime") + c.gray(` — ${s.project} (framework ${s.stamp ?? "—"} / CLI ${s.cli})`));
154
+ out.push("");
155
+
156
+ const cov = s.coverage.pct === null
157
+ ? "no criteria declared"
158
+ : `${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria})`;
159
+ const tr = s.trace.anchors === 0 ? "no anchors" : `${s.trace.realized}/${s.trace.anchors}`;
160
+ const verify = s.verify.configured
161
+ ? `${s.verify.checks} verify checks${signoffNote(s.verify)}`
162
+ : "verify not configured";
163
+ out.push(c.bold("Gates ") + `index ${s.indexState} · coverage ${cov} · trace ${tr} · ${verify}`);
164
+ const implBreak = Object.entries(s.specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
165
+ out.push(
166
+ c.bold("Work ") +
167
+ `${s.specs.total} specs${implBreak ? ` (${implBreak})` : ""} · ${s.specs.openChanges} open change${s.specs.openChanges === 1 ? "" : "s"} · ` +
168
+ `${s.decisions.total} decisions · ${s.skills} skills`,
169
+ );
170
+
171
+ out.push("");
172
+ out.push(c.bold("Rules") + c.gray(` (${s.adrs.length} accepted ADRs — \`doctrina prime --rules\` for detail)`));
173
+ for (const a of s.adrs) out.push(` ${c.cyan(a.id)} ${a.title}`);
174
+ if (s.nonGoals.length > 0) {
175
+ out.push(c.gray(` + ${s.nonGoals.length} non-goal${s.nonGoals.length === 1 ? "" : "s"} declared in product.md`));
176
+ }
177
+
178
+ out.push("");
179
+ out.push(c.bold("Open work"));
180
+ if (s.openChanges.length === 0) {
181
+ out.push(c.gray(" none — `doctrina work \"<prompt>\"` opens the next change"));
182
+ } else {
183
+ for (const ch of s.openChanges) {
184
+ const tasks = ch.tasksTotal > 0 ? ` · tasks ${ch.tasksDone}/${ch.tasksTotal}` : "";
185
+ out.push(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.status}${tasks})`)}`);
186
+ }
187
+ }
188
+
189
+ out.push("");
190
+ out.push(c.bold("Next"));
191
+ if (s.actions.length === 0) {
192
+ out.push(c.gray(" nothing pending — pick up new work"));
193
+ } else {
194
+ s.actions.slice(0, 5).forEach((a, i) => out.push(` ${i + 1}. ${a.text}`));
195
+ if (s.actions.length > 5) out.push(c.gray(` … ${s.actions.length - 5} more — \`doctrina next\``));
196
+ }
197
+
198
+ out.push("");
199
+ out.push(c.gray("Read deeper: `doctrina context [<cap>] --concat` · `doctrina why <cap>` · `doctrina show <ref>`"));
200
+ return out;
201
+ }
202
+
203
+ // ----------------------------------------------------------------- handoff
204
+
205
+ export function handoff(s) {
206
+ const out = [];
207
+ out.push(`# Doctrina handoff — ${s.project} (${today()})`);
208
+ out.push("");
209
+
210
+ out.push("## Where things stand");
211
+ out.push("");
212
+ out.push(`- index: ${s.indexState} · framework stamp: ${s.stamp ?? "—"} (CLI ${s.cli})`);
213
+ out.push(`- coverage: ${coverageLabel(s.coverage)}` +
214
+ (s.coverage.totalDangling ? `, ${s.coverage.totalDangling} dangling` : "") +
215
+ (s.coverage.totalConditional ? `, ${s.coverage.totalConditional} conditional` : ""));
216
+ const tr = s.trace.anchors === 0 ? "no anchors declared" : `${s.trace.realized}/${s.trace.anchors} anchors realized`;
217
+ out.push(`- trace: ${tr}` + (s.trace.untraceable ? ` (${s.trace.untraceable} untraceable)` : ""));
218
+ out.push(`- verify: ${s.verify.configured ? `${s.verify.checks} checks declared${signoffNote(s.verify, { verbose: true })} — run \`doctrina verify\`` : "not configured"}`);
219
+
220
+ out.push("");
221
+ if (s.openChanges.length === 0) {
222
+ out.push("## Open work");
223
+ out.push("");
224
+ out.push("- none — the tree is at rest; start with `doctrina work \"<prompt>\"`");
225
+ } else {
226
+ for (const ch of s.openChanges) {
227
+ out.push(`## Open change \`${ch.id}\`${ch.title ? ` — ${ch.title}` : ""}`);
228
+ out.push("");
229
+ out.push(`- proposal status: ${ch.status}`);
230
+ if (ch.tasksTotal > 0) {
231
+ out.push(`- tasks: ${ch.tasksDone}/${ch.tasksTotal} checked`);
232
+ for (const t of ch.unchecked.slice(0, 8)) out.push(` - [ ] ${t}`);
233
+ if (ch.unchecked.length > 8) out.push(` - … ${ch.unchecked.length - 8} more in tasks.md`);
234
+ } else {
235
+ out.push("- tasks: no tasks.md checklist found");
236
+ }
237
+ out.push(`- resume with: \`doctrina work --resume ${ch.id}\` · close with: \`doctrina close ${ch.id}\``);
238
+ out.push("");
239
+ }
240
+ }
241
+
242
+ out.push("## Next actions");
243
+ out.push("");
244
+ if (s.actions.length === 0) {
245
+ out.push("1. nothing pending — `doctrina next` will confirm; pick up new work");
246
+ } else {
247
+ s.actions.forEach((a, i) => out.push(`${i + 1}. ${a.text}`));
248
+ }
249
+
250
+ out.push("");
251
+ out.push("*Generated read-only from the tree — regenerate anytime with `doctrina handoff`.*");
252
+ return out;
253
+ }
254
+
255
+ // ------------------------------------------------------------------- rules
256
+ //
257
+ // The project's standing rules, in full: the accepted ADRs and the product's
258
+ // declared non-goals. Assembled, never owned — to change a principle you
259
+ // supersede its ADR, to change a non-goal you edit product.md.
260
+ //
261
+ // One rendering, two callers (change 0049). `prime --rules` prints it, and
262
+ // `doctrina constitution` — deprecated, kept working — prints the same lines,
263
+ // so the merge is demonstrable rather than asserted. `prime` on its own still
264
+ // shows the ADR titles and a non-goal COUNT: the primer is a fixed-size read,
265
+ // and the full list is what `--rules` is for.
266
+ export function rules(s) {
267
+ const out = [];
268
+ out.push(c.bold("Standing rules") + c.gray(` — ${s.project} (accepted decisions + non-goals)`));
269
+ out.push("");
270
+ out.push(c.bold(" Principles") + c.gray(" (immutable — supersede an ADR to change one)"));
271
+ if (s.adrs.length === 0) {
272
+ out.push(` ${c.gray("no accepted ADRs yet — record decisions with `doctrina decision new`")}`);
273
+ } else {
274
+ for (const a of s.adrs) out.push(` ${c.cyan("ADR " + a.id)} ${a.title}`);
275
+ }
276
+ out.push("");
277
+ out.push(c.bold(" Non-goals") + c.gray(" (.doctrina/product.md)"));
278
+ if (s.nonGoals.length === 0) {
279
+ // Name the act that is actually missing (rule C2): telling an author to
280
+ // add a section they already wrote is a remedy that resolves nothing.
281
+ out.push(` ${c.gray(s.hasNonGoalsSection
282
+ ? "none declared — the `## Non-goals` section of product.md is empty"
283
+ : "none declared — add a `## Non-goals` section to product.md")}`);
284
+ } else {
285
+ for (const g of s.nonGoals) out.push(` ${c.gray("•")} ${g}`);
286
+ }
287
+ out.push("");
288
+ out.push(c.gray(` ${s.adrs.length} accepted decision${s.adrs.length === 1 ? "" : "s"} · ` +
289
+ `${s.nonGoals.length} non-goal${s.nonGoals.length === 1 ? "" : "s"} · read-only`));
290
+ return out;
291
+ }
292
+
293
+ // ------------------------------------------------------------------ report
294
+
295
+ function asPct(x) {
296
+ return typeof x === "number" ? `${(x * 100).toFixed(1)}%` : "—";
297
+ }
298
+
299
+ /**
300
+ * @param {object} s the snapshot
301
+ * @param {{ days?: number, cutoffIso?: string, git?: any, gitState?: any, metrics?: any }} [options]
302
+ */
303
+ export function report(s, { days = 7, cutoffIso = "", git = null, gitState = null, metrics = null } = {}) {
304
+ const out = [];
305
+ out.push(`# Doctrina report — ${s.project} (${cutoffIso} → ${today()})`);
306
+ out.push("");
307
+
308
+ out.push("## Gates");
309
+ out.push("");
310
+ out.push(`- index: ${s.indexState} · framework stamp: ${s.stamp ?? "—"} (CLI ${s.cli})`);
311
+ out.push(`- coverage: ${coverageLabel(s.coverage)}` +
312
+ (s.coverage.totalDangling ? ` — ${s.coverage.totalDangling} dangling` : "") +
313
+ (s.coverage.totalConditional ? ` — ${s.coverage.totalConditional} conditional` : ""));
314
+ out.push(`- trace: ${s.trace.anchors === 0 ? "no anchors declared" : `${s.trace.realized}/${s.trace.anchors} anchors realized`}`);
315
+ out.push(`- verify: ${s.verify.configured ? `${s.verify.checks} checks declared${signoffNote(s.verify, { verbose: true })}` : "not configured"}`);
316
+
317
+ out.push("");
318
+ out.push("## Changes");
319
+ out.push("");
320
+ const archived = s.archive
321
+ .filter((ch) => typeof ch.applied === "string" && ch.applied >= cutoffIso)
322
+ .sort((a, b) => String(a.applied).localeCompare(String(b.applied)));
323
+ if (archived.length === 0) {
324
+ out.push(`- archived in period: none`);
325
+ } else {
326
+ out.push(`- archived in period: ${archived.length}`);
327
+ for (const ch of archived) out.push(` - ${ch.applied} — \`${ch.id}\` ${ch.title}`);
328
+ }
329
+ if (s.openChanges.length === 0) {
330
+ out.push("- open now: none");
331
+ } else {
332
+ out.push(`- open now: ${s.openChanges.length}`);
333
+ for (const ch of s.openChanges) {
334
+ const tasks = ch.tasksTotal > 0 ? ` (tasks ${ch.tasksDone}/${ch.tasksTotal})` : "";
335
+ out.push(` - \`${ch.id}\`${ch.title ? ` ${ch.title}` : ""}${tasks}`);
336
+ }
337
+ }
338
+
339
+ // What KIND of work the period held (change 0042). The classifier's verdict
340
+ // used to be computed, printed and thrown away, so no report could answer
341
+ // this — and the classifier had no set of right and wrong answers to be
342
+ // calibrated against. A change with no recorded lane is counted as unknown,
343
+ // never folded into a lane it might not belong to.
344
+ const lanes = laneMix([...archived.map((ch) => ch.lane ?? null), ...s.openLanes]);
345
+ if (lanes.total > 0) {
346
+ out.push("");
347
+ out.push("## Lanes");
348
+ out.push("");
349
+ for (const [lane, n] of lanes.rows) out.push(`- ${lane}: ${n}`);
350
+ if (lanes.overridden > 0) {
351
+ out.push(`- of which the operator opened a different lane than read: ${lanes.overridden}`);
352
+ }
353
+ }
354
+
355
+ // WHERE the work landed, from the ledger rather than from git (change
356
+ // 0046). git counts files; the ledger counts capabilities, which is the
357
+ // unit the specs, the gates and this report are all written in. The number
358
+ // is reported, never judged: a capability that moves often may be badly
359
+ // drawn or may simply be where the work is, and nothing here can tell those
360
+ // apart (ADR 0005).
361
+ const churn = churnByCapability(s.ledger ?? [], { since: cutoffIso });
362
+ if (churn.length > 0) {
363
+ out.push("");
364
+ out.push("## Capability churn (in period)");
365
+ out.push("");
366
+ for (const row of churn) {
367
+ out.push(`- ${row.capability}: ${row.changes} change${row.changes === 1 ? "" : "s"} (last ${row.last})`);
368
+ }
369
+ }
370
+
371
+ out.push("");
372
+ out.push("## Artifacts");
373
+ out.push("");
374
+ out.push(`- specs: ${s.specs.total} · decisions: ${s.decisions.total}` +
375
+ (s.decisions.proposed ? ` (${s.decisions.proposed} proposed)` : "") +
376
+ ` · skills: ${s.skills}`);
377
+
378
+ out.push("");
379
+ out.push("## Git (local, last " + days + " days)");
380
+ out.push("");
381
+ if (!git) {
382
+ // Name the ACTUAL condition, from the one git door (change 0040), not a
383
+ // list of causes that might apply. In a freshly `git init`ed repository
384
+ // this said "not a repository, or git not installed" — two causes, both
385
+ // false, sending the reader to look for an installation problem that was
386
+ // not there, while `metrics` read the same state and got it right
387
+ // (change 0080).
388
+ const why = gitState?.reason;
389
+ out.push(`- no git history available${why ? ` — ${why}` : ""}`);
390
+ } else {
391
+ out.push(`- commits: ${git.commits} (${git.fixes} fix-shaped, ${git.commits ? Math.round((git.fixes / git.commits) * 100) : 0}%)`);
392
+ if (git.churn.length > 0) {
393
+ out.push(`- top-churn files:`);
394
+ for (const [file, n] of git.churn) out.push(` - ${file} (${n} touches)`);
395
+ }
396
+ // The rework proxies for the same window, from the same snapshot
397
+ // `doctrina metrics` renders (change 0050) — reported, never judged:
398
+ // both rates move with team size and release cadence, and iterative
399
+ // work trips the re-edit proxy exactly like rework does.
400
+ if (metrics) {
401
+ out.push(`- reverts: ${metrics.reverts} (${asPct(metrics.revert_rate)} of commits)`);
402
+ out.push(`- ${metrics.reedit_window_days}-day re-edit rate: ${asPct(metrics.reedit_rate)}` +
403
+ " (touches a file edited in the prior window — a proxy for rework, not a verdict)");
404
+ }
405
+ }
406
+
407
+ out.push("");
408
+ out.push(`*Generated read-only by \`doctrina report --since ${days}\`; deeper numbers: \`doctrina metrics\`.*`);
409
+ return out;
410
+ }
@@ -0,0 +1,77 @@
1
+ // @ts-check
2
+ // Ranking capabilities — by a set of changed files, or by a prompt.
3
+ //
4
+ // `review` needs the diff ranker to map a diff onto the specs it touches, and
5
+ // reached into `commands/work.js` for it (audit finding F7). Both rankers are
6
+ // pure functions over the spec tree, and `work` now ACTS on the prompt one
7
+ // (it scaffolds the winner's delta), so neither belongs in a command module:
8
+ // ADR 0025 — command modules render, shared logic lives in lib/.
9
+ import path from "node:path";
10
+ import { readdirSync } from "node:fs";
11
+ import { isDir, isFile, read } from "./fs-ops.js";
12
+ import { score, terms } from "./lexicon.js";
13
+ import { parseSourceGlobs } from "./scan.js";
14
+ import { globToRegExp } from "./runtime.js";
15
+ // Rank capabilities by the working tree, not the prompt (review F10): a changed
16
+ // file scores its capability when the file sits under a path segment named for
17
+ // it, or when the spec cites the file as evidence. A deterministic overlap
18
+ // hint for the agent — never a decision (ADR 0005).
19
+ export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3 } = {}) {
20
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
21
+ if (!files || files.length === 0 || !isDir(specsDir)) return [];
22
+ const norm = files.map((f) => f.replace(/\\/g, "/"));
23
+
24
+ const ranked = [];
25
+ for (const cap of readdirSync(specsDir).sort()) {
26
+ const specPath = path.join(specsDir, cap, "spec.md");
27
+ if (!isFile(specPath)) continue;
28
+ const specText = read(specPath);
29
+ // A DECLARED glob outranks every inference: the spec said this code is
30
+ // its own, and a declaration is not a guess (change 0077).
31
+ const declared = parseSourceGlobs(specText).map((g) => globToRegExp(g));
32
+ let score = 0;
33
+ for (const f of norm) {
34
+ if (declared.some((re) => re.test(f))) score += 10; // the spec claims it
35
+ if (f.split("/").includes(cap)) score += 3; // src/<cap>/... etc.
36
+ if (specText.includes(f)) score += 5; // file cited in the spec
37
+ else if (specText.includes(path.basename(f))) score += 2; // filename cited
38
+ }
39
+ if (score > 0) ranked.push({ id: cap, score, path: `.doctrina/specs/${cap}/spec.md` });
40
+ }
41
+ // `limit` keeps the work-playbook hint short (top 3); review passes
42
+ // Infinity — truncating there is how it missed 5 of 8 touched capabilities
43
+ // in the 0.11.0 field session.
44
+ return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, limit);
45
+ }
46
+
47
+ // Deterministic term overlap: fold prompt and spec text with the SHARED
48
+ // lexicon, then score. This is a hint for the agent, never a decision
49
+ // (ADR 0005).
50
+ //
51
+ // Before change 0040 this carried its own stop list and its own fold, while
52
+ // `context --for` carried different ones — and the work playbook tells the
53
+ // agent to run `context` immediately after `work`. Two rankers, in sequence,
54
+ // on the same prompt, free to disagree about which capability it is about.
55
+ // Both now read lib/lexicon.js, and the score below is that module's
56
+ // PROJECTION of the relevance tuple, not a second calculation.
57
+ export function rankCapabilities(projectRoot, prompt) {
58
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
59
+ const queryTerms = terms(prompt);
60
+ if (queryTerms.length === 0 || !isDir(specsDir)) return [];
61
+
62
+ const ranked = [];
63
+ for (const cap of readdirSync(specsDir).sort()) {
64
+ const specPath = path.join(specsDir, cap, "spec.md");
65
+ if (!isFile(specPath)) continue;
66
+ // The capability name is the emphasis: a term in the name says far more
67
+ // about what the prompt is about than the same term buried in the body.
68
+ const points = score(read(specPath), queryTerms, cap.split("-").join(" "));
69
+ if (points > 0) ranked.push({ id: cap, score: points, path: `.doctrina/specs/${cap}/spec.md` });
70
+ }
71
+ const sorted = ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
72
+ // The margin over the NEXT capability — computed before the top-3 cut, so
73
+ // the winner's margin is a real one — is what lets a caller tell a lead
74
+ // apart from a coin toss (change 0044). The last capability's runner-up is
75
+ // 0, the same convention `triage` uses for a lane nothing scored against.
76
+ return sorted.slice(0, 3).map((e, i) => ({ ...e, margin: e.score - (sorted[i + 1]?.score ?? 0) }));
77
+ }
@@ -1,34 +1,28 @@
1
1
  # AGENTS.md — {{PROJECT_NAME}}
2
2
 
3
3
  Operational source of truth for AI coding agents working in this
4
- repository. Follows the open AGENTS.md standard.
4
+ repository. Follows the open AGENTS.md standard; the Doctrina framework
5
+ artifacts live under `.doctrina/`.
5
6
 
6
7
  ## What this repo is
7
8
 
8
9
  {{PROJECT_DESCRIPTION}}
9
10
 
10
- This repository uses the Doctrina framework for spec-driven, AGENTS.md
11
- native multi-agent development. The full framework artifacts live under
12
- `.doctrina/`.
13
-
14
11
  ## Working from intent (you drive; the human stays passive)
15
12
 
16
13
  You — the AI agent — run the Doctrina commands. The human gives a brief
17
14
  prompt and approves; do not make them run the CLI or hand-author artifacts.
18
15
 
19
- - **Bootstrap.** If `.doctrina/intake.md` is `Status: pending`, the specs
20
- are not written yet: run `doctrina intake` and execute the printed
21
- playbook in one pass (fill `product.md`, derive capabilities, one EARS
22
- spec each, `doctrina clarify --all` + `doctrina validate`, then flip the
23
- intake to `Status: converted`). After conversion the specs are the only
24
- source of truth — never edit `intake.md` to change requirements.
16
+ - **Bootstrap.** No capability in `.doctrina/specs/`, or an intake still
17
+ `Status: pending`? The specs are not written: run `doctrina intake --text
18
+ "<description>"` and execute the printed playbook in one pass (existing
19
+ codebase: `doctrina work --from-diff`). After conversion the specs are the
20
+ only source of truth — never edit `intake.md` to change requirements.
25
21
  - **Day-to-day.** Start every session with `doctrina prime` (gates, rules,
26
- open work in one read). Turn any request into a change with `doctrina
27
- work "<prompt>"` and follow the printed playbook (spec delta → tasks →
28
- implement → close). Preview a close with `doctrina change check <id>`;
29
- close with `doctrina close <id>`. Unsure what's next? `doctrina next`.
30
- Before compaction or handover: `doctrina handoff`. Ask the human only on
31
- genuine ambiguity.
22
+ open work in one read). Turn any request into a change with `doctrina work
23
+ "<prompt>"` and follow the printed playbook. Preview a close with `doctrina
24
+ change check <id>`; close with `doctrina close <id>`. Unsure what's next?
25
+ `doctrina next`. Before compaction or handover: `doctrina handoff`.
32
26
 
33
27
  <!-- doctrina:surface:begin — CLI-owned block, generated from the installed command catalog. Refreshed by `doctrina upgrade --write`; edits inside are overwritten. -->
34
28
  ## Doctrina command surface (generated — reach for these, don't hand-author)
@@ -40,7 +34,7 @@ canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.
40
34
  - `doctrina intake` — store the intent and print the bootstrap playbook. *When:* you have a full project description and no specs yet.
41
35
  - `doctrina adapter list|add|remove` — install/remove agent adapters (additive). *When:* adding or removing an agent's pointer files.
42
36
  **Orient**
43
- - `doctrina prime (session start)` — gates, standing rules, and open work in one read. *When:* at the START of every session.
37
+ - `doctrina prime (session start)` — gates, open work and next steps in one read (--rules for the full standing rules). *When:* at the START of every session.
44
38
  - `doctrina context [<cap>] --for "<task>" --concat` — the read pack, assembled to fit a token budget. *When:* before working on any task, to load the right files.
45
39
  - `doctrina show` — point-read a single artifact by reference. *When:* you need one requirement, criterion, or ADR, not a file.
46
40
  - `doctrina search` — search the artifact tree, grouped by category. *When:* you do not know which artifact mentions a term.
@@ -48,12 +42,11 @@ canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.
48
42
  - `doctrina next` — the recommended next workflow action. *When:* you finished something and do not know what follows.
49
43
  - `doctrina why` — provenance: intent, proof, ADRs, and history. *When:* you need to justify or trace a capability's existence.
50
44
  - `doctrina handoff (before compaction/handover)` — a resume note: open work, task state, next command. *When:* BEFORE compaction or handing over to another session.
51
- - `doctrina constitution` — accepted ADRs and product non-goals. *When:* you need the standing rules before deciding something.
52
45
  **Change**
53
46
  - `doctrina triage` — classify the lane (product/runtime/chore) and check the declared runtime surface. *When:* a request arrives — BEFORE scaffolding, especially if it smells like an incident.
54
47
  - `doctrina work "<prompt>" (--capability · --chore · --from-diff · --quiet)` — scaffold a change and print the playbook to execute. *When:* a request arrives that changes behaviour (triage says PRODUCT).
55
48
  - `doctrina spec new|list|set` — create, list, and edit capability specs. *When:* a capability needs creating or its headers advancing.
56
- - `doctrina change new|apply|archive|check|tick|diff|abandon` — new / apply / archive / check / tick / diff / abandon. *When:* driving a change through its lifecycle by hand.
49
+ - `doctrina change new|apply|archive|check|tick|abandon` — new / apply / archive / check (--verbose) / tick / abandon. *When:* driving a change through its lifecycle by hand.
57
50
  - `doctrina contract new|list|check` — own and verify the integration surface. *When:* the change touches ports, env vars, or public endpoints.
58
51
  - `doctrina decision new|accept|land|supersede|list|scope` — record, accept, land, scope, and supersede ADRs. *When:* the change decides something a later session must not relitigate.
59
52
  - `doctrina skill new|list|sync|suggest` — capture on-demand procedural memory. *When:* a lesson is worth not relearning.
@@ -68,7 +61,7 @@ canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.
68
61
  - `doctrina verify` — the project's declared typecheck/test/build checks. *When:* the real build gate must run.
69
62
  - `doctrina close <id...>` — the whole closing sequence in one attested pass. *When:* a change is implemented and ready to finish.
70
63
  - `doctrina doctor` — aggregate diagnostic with per-finding remedies. *When:* something looks wrong and you do not know which gate to ask.
71
- **Maintain** (triggers: `doctrina <command> --help`) — `doctrina templates list|check|update` · `doctrina hooks install` · `doctrina index rebuild` · `doctrina watch` · `doctrina metrics` · `doctrina report` · `doctrina completion` · `doctrina upgrade --write`
64
+ **Maintain** (triggers: `doctrina <command> --help`) — `doctrina templates list|check|update` · `doctrina hooks install` · `doctrina index rebuild` · `doctrina watch` · `doctrina metrics` · `doctrina report` · `doctrina completion` · `doctrina upgrade --write` · `doctrina ci --emit github`
72
65
  <!-- doctrina:surface:end -->
73
66
 
74
67
  ## Stack and tooling
@@ -121,12 +114,13 @@ Finish by running `doctrina validate` and resolving every `error:`.
121
114
 
122
115
  `doctrina context [<capability>] --concat` assembles the read pack in one
123
116
  call: this `AGENTS.md` → `product.md` → the capability spec → open changes →
124
- accepted ADRs. Run it for ANY task. It skips `changes/archive/` (history).
125
-
126
- Skills are on demand: read the `description:` / `when:` frontmatter in
127
- `.doctrina/skills/`; load a body only when its trigger fires.
117
+ accepted ADRs. Run it for ANY task; it skips `changes/archive/`. Skills are
118
+ on demand: read the `description:` / `when:` frontmatter in
119
+ `.doctrina/skills/` and load a body only when its trigger fires.
128
120
 
129
- Keep this file under 150 lines. Density beats prose.
121
+ Keep this file under 150 lines. The scaffold already spends most of them
122
+ and the generated block above grows with the CLI — `doctrina doctor`
123
+ reports what is left. Density beats prose.
130
124
 
131
125
  ## Definition of done
132
126
 
@@ -29,10 +29,45 @@ replace. No conditionals, loops, or includes.
29
29
  CLI authors and template authors share this vocabulary. Adding a token
30
30
  requires extending the templates spec at `.doctrina/specs/templates/spec.md`.
31
31
 
32
+ ### Playbook tokens
33
+
34
+ The playbooks under `playbooks/` carry the variable parts of the procedure
35
+ the agent executes. Each is PRE-RENDERED by the CLI into a plain string, so
36
+ the template stays a document rather than a language with conditionals and
37
+ loops in it.
38
+
39
+ | Token | Meaning | Default if not supplied |
40
+ |-------|---------|-------------------------|
41
+ | `{{TITLE}}` | "Work playbook" or "Backfill playbook" | required in `work` |
42
+ | `{{PROMPT}}` | The prompt the change was opened from, verbatim | required |
43
+ | `{{FROM_DIFF_NOTE}}` | The code-first note, on `--from-diff` | empty |
44
+ | `{{THIN_WARNING}}` | The thin-prompt clarification block | empty |
45
+ | `{{CAPABILITY_BLOCK}}` | The pinned capability, the ranked hints, or "no match" | required in `work` |
46
+ | `{{DIFF_MATCHES}}` | Capabilities the working tree touched | empty |
47
+ | `{{STEP3_INTRO}}` | The delta step's opening, pinned or not | required in `work` |
48
+ | `{{FROM_DIFF_DELTA_NOTE}}` | The backfill caveat on writing the delta | empty |
49
+ | `{{MISSING_SPECS_WARNING}}` | Warning that `.doctrina/specs/` is absent | empty |
50
+
51
+ A line holding NOTHING but one token whose value is empty is removed rather
52
+ than left blank, which is what lets the optional blocks above be plain tokens.
53
+
54
+ Colour is marked inline and expanded before substitution, so a token's value
55
+ can never inject it:
56
+
57
+ | Markup | Renders as |
58
+ |--------|-----------|
59
+ | `[[c]]...[[/c]]` | cyan — a command to run |
60
+ | `[[g]]...[[/g]]` | gray — commentary |
61
+ | `[[b]]...[[/b]]` | bold — a heading |
62
+ | `[[y]]...[[/y]]` | yellow — a warning |
63
+
32
64
  ## Inventory (v0)
33
65
 
34
66
  ```
35
67
  README.md this file
68
+ playbooks/work.md.template the work / backfill playbook
69
+ playbooks/chore.md.template the spec-less chore playbook
70
+ playbooks/bootstrap.md.template the intake -> specs playbook
36
71
  AGENTS.md.template root rules for the target repo
37
72
  doctrina/ the .doctrina/ skeleton
38
73
  product.md.template