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
@@ -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,413 @@
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
+ // The note is pasted into another session, so the section has to END like
226
+ // the loop below ends: a heading glued to a list item is not a heading.
227
+ out.push("");
228
+ } else {
229
+ for (const ch of s.openChanges) {
230
+ out.push(`## Open change \`${ch.id}\`${ch.title ? ` — ${ch.title}` : ""}`);
231
+ out.push("");
232
+ out.push(`- proposal status: ${ch.status}`);
233
+ if (ch.tasksTotal > 0) {
234
+ out.push(`- tasks: ${ch.tasksDone}/${ch.tasksTotal} checked`);
235
+ for (const t of ch.unchecked.slice(0, 8)) out.push(` - [ ] ${t}`);
236
+ if (ch.unchecked.length > 8) out.push(` - … ${ch.unchecked.length - 8} more in tasks.md`);
237
+ } else {
238
+ out.push("- tasks: no tasks.md checklist found");
239
+ }
240
+ out.push(`- resume with: \`doctrina work --resume ${ch.id}\` · close with: \`doctrina close ${ch.id}\``);
241
+ out.push("");
242
+ }
243
+ }
244
+
245
+ out.push("## Next actions");
246
+ out.push("");
247
+ if (s.actions.length === 0) {
248
+ out.push("1. nothing pending — `doctrina next` will confirm; pick up new work");
249
+ } else {
250
+ s.actions.forEach((a, i) => out.push(`${i + 1}. ${a.text}`));
251
+ }
252
+
253
+ out.push("");
254
+ out.push("*Generated read-only from the tree — regenerate anytime with `doctrina handoff`.*");
255
+ return out;
256
+ }
257
+
258
+ // ------------------------------------------------------------------- rules
259
+ //
260
+ // The project's standing rules, in full: the accepted ADRs and the product's
261
+ // declared non-goals. Assembled, never owned — to change a principle you
262
+ // supersede its ADR, to change a non-goal you edit product.md.
263
+ //
264
+ // One rendering, two callers (change 0049). `prime --rules` prints it, and
265
+ // `doctrina constitution` — deprecated, kept working — prints the same lines,
266
+ // so the merge is demonstrable rather than asserted. `prime` on its own still
267
+ // shows the ADR titles and a non-goal COUNT: the primer is a fixed-size read,
268
+ // and the full list is what `--rules` is for.
269
+ export function rules(s) {
270
+ const out = [];
271
+ out.push(c.bold("Standing rules") + c.gray(` — ${s.project} (accepted decisions + non-goals)`));
272
+ out.push("");
273
+ out.push(c.bold(" Principles") + c.gray(" (immutable — supersede an ADR to change one)"));
274
+ if (s.adrs.length === 0) {
275
+ out.push(` ${c.gray("no accepted ADRs yet — record decisions with `doctrina decision new`")}`);
276
+ } else {
277
+ for (const a of s.adrs) out.push(` ${c.cyan("ADR " + a.id)} ${a.title}`);
278
+ }
279
+ out.push("");
280
+ out.push(c.bold(" Non-goals") + c.gray(" (.doctrina/product.md)"));
281
+ if (s.nonGoals.length === 0) {
282
+ // Name the act that is actually missing (rule C2): telling an author to
283
+ // add a section they already wrote is a remedy that resolves nothing.
284
+ out.push(` ${c.gray(s.hasNonGoalsSection
285
+ ? "none declared — the `## Non-goals` section of product.md is empty"
286
+ : "none declared — add a `## Non-goals` section to product.md")}`);
287
+ } else {
288
+ for (const g of s.nonGoals) out.push(` ${c.gray("•")} ${g}`);
289
+ }
290
+ out.push("");
291
+ out.push(c.gray(` ${s.adrs.length} accepted decision${s.adrs.length === 1 ? "" : "s"} · ` +
292
+ `${s.nonGoals.length} non-goal${s.nonGoals.length === 1 ? "" : "s"} · read-only`));
293
+ return out;
294
+ }
295
+
296
+ // ------------------------------------------------------------------ report
297
+
298
+ function asPct(x) {
299
+ return typeof x === "number" ? `${(x * 100).toFixed(1)}%` : "—";
300
+ }
301
+
302
+ /**
303
+ * @param {object} s the snapshot
304
+ * @param {{ days?: number, cutoffIso?: string, git?: any, gitState?: any, metrics?: any }} [options]
305
+ */
306
+ export function report(s, { days = 7, cutoffIso = "", git = null, gitState = null, metrics = null } = {}) {
307
+ const out = [];
308
+ out.push(`# Doctrina report — ${s.project} (${cutoffIso} → ${today()})`);
309
+ out.push("");
310
+
311
+ out.push("## Gates");
312
+ out.push("");
313
+ out.push(`- index: ${s.indexState} · framework stamp: ${s.stamp ?? "—"} (CLI ${s.cli})`);
314
+ out.push(`- coverage: ${coverageLabel(s.coverage)}` +
315
+ (s.coverage.totalDangling ? ` — ${s.coverage.totalDangling} dangling` : "") +
316
+ (s.coverage.totalConditional ? ` — ${s.coverage.totalConditional} conditional` : ""));
317
+ out.push(`- trace: ${s.trace.anchors === 0 ? "no anchors declared" : `${s.trace.realized}/${s.trace.anchors} anchors realized`}`);
318
+ out.push(`- verify: ${s.verify.configured ? `${s.verify.checks} checks declared${signoffNote(s.verify, { verbose: true })}` : "not configured"}`);
319
+
320
+ out.push("");
321
+ out.push("## Changes");
322
+ out.push("");
323
+ const archived = s.archive
324
+ .filter((ch) => typeof ch.applied === "string" && ch.applied >= cutoffIso)
325
+ .sort((a, b) => String(a.applied).localeCompare(String(b.applied)));
326
+ if (archived.length === 0) {
327
+ out.push(`- archived in period: none`);
328
+ } else {
329
+ out.push(`- archived in period: ${archived.length}`);
330
+ for (const ch of archived) out.push(` - ${ch.applied} — \`${ch.id}\` ${ch.title}`);
331
+ }
332
+ if (s.openChanges.length === 0) {
333
+ out.push("- open now: none");
334
+ } else {
335
+ out.push(`- open now: ${s.openChanges.length}`);
336
+ for (const ch of s.openChanges) {
337
+ const tasks = ch.tasksTotal > 0 ? ` (tasks ${ch.tasksDone}/${ch.tasksTotal})` : "";
338
+ out.push(` - \`${ch.id}\`${ch.title ? ` ${ch.title}` : ""}${tasks}`);
339
+ }
340
+ }
341
+
342
+ // What KIND of work the period held (change 0042). The classifier's verdict
343
+ // used to be computed, printed and thrown away, so no report could answer
344
+ // this — and the classifier had no set of right and wrong answers to be
345
+ // calibrated against. A change with no recorded lane is counted as unknown,
346
+ // never folded into a lane it might not belong to.
347
+ const lanes = laneMix([...archived.map((ch) => ch.lane ?? null), ...s.openLanes]);
348
+ if (lanes.total > 0) {
349
+ out.push("");
350
+ out.push("## Lanes");
351
+ out.push("");
352
+ for (const [lane, n] of lanes.rows) out.push(`- ${lane}: ${n}`);
353
+ if (lanes.overridden > 0) {
354
+ out.push(`- of which the operator opened a different lane than read: ${lanes.overridden}`);
355
+ }
356
+ }
357
+
358
+ // WHERE the work landed, from the ledger rather than from git (change
359
+ // 0046). git counts files; the ledger counts capabilities, which is the
360
+ // unit the specs, the gates and this report are all written in. The number
361
+ // is reported, never judged: a capability that moves often may be badly
362
+ // drawn or may simply be where the work is, and nothing here can tell those
363
+ // apart (ADR 0005).
364
+ const churn = churnByCapability(s.ledger ?? [], { since: cutoffIso });
365
+ if (churn.length > 0) {
366
+ out.push("");
367
+ out.push("## Capability churn (in period)");
368
+ out.push("");
369
+ for (const row of churn) {
370
+ out.push(`- ${row.capability}: ${row.changes} change${row.changes === 1 ? "" : "s"} (last ${row.last})`);
371
+ }
372
+ }
373
+
374
+ out.push("");
375
+ out.push("## Artifacts");
376
+ out.push("");
377
+ out.push(`- specs: ${s.specs.total} · decisions: ${s.decisions.total}` +
378
+ (s.decisions.proposed ? ` (${s.decisions.proposed} proposed)` : "") +
379
+ ` · skills: ${s.skills}`);
380
+
381
+ out.push("");
382
+ out.push("## Git (local, last " + days + " days)");
383
+ out.push("");
384
+ if (!git) {
385
+ // Name the ACTUAL condition, from the one git door (change 0040), not a
386
+ // list of causes that might apply. In a freshly `git init`ed repository
387
+ // this said "not a repository, or git not installed" — two causes, both
388
+ // false, sending the reader to look for an installation problem that was
389
+ // not there, while `metrics` read the same state and got it right
390
+ // (change 0080).
391
+ const why = gitState?.reason;
392
+ out.push(`- no git history available${why ? ` — ${why}` : ""}`);
393
+ } else {
394
+ out.push(`- commits: ${git.commits} (${git.fixes} fix-shaped, ${git.commits ? Math.round((git.fixes / git.commits) * 100) : 0}%)`);
395
+ if (git.churn.length > 0) {
396
+ out.push(`- top-churn files:`);
397
+ for (const [file, n] of git.churn) out.push(` - ${file} (${n} touches)`);
398
+ }
399
+ // The rework proxies for the same window, from the same snapshot
400
+ // `doctrina metrics` renders (change 0050) — reported, never judged:
401
+ // both rates move with team size and release cadence, and iterative
402
+ // work trips the re-edit proxy exactly like rework does.
403
+ if (metrics) {
404
+ out.push(`- reverts: ${metrics.reverts} (${asPct(metrics.revert_rate)} of commits)`);
405
+ out.push(`- ${metrics.reedit_window_days}-day re-edit rate: ${asPct(metrics.reedit_rate)}` +
406
+ " (touches a file edited in the prior window — a proxy for rework, not a verdict)");
407
+ }
408
+ }
409
+
410
+ out.push("");
411
+ out.push(`*Generated read-only by \`doctrina status --view report --since ${days}\`; deeper numbers: \`doctrina metrics\`.*`);
412
+ return out;
413
+ }
@@ -0,0 +1,102 @@
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
+ /**
48
+ * The capabilities whose `Source:` header CLAIMS a file — ADR 0027's question
49
+ * asked directly, with no inference in the answer.
50
+ *
51
+ * `rankCapabilitiesByDiff` deliberately falls back to inferences so a project
52
+ * that has not declared anything still gets a hint. That makes it the wrong
53
+ * function to enforce the declaration with: a file no spec names still comes
54
+ * back with an owner, scored 5 instead of 10, and a test asking "does this
55
+ * have an owner" cannot tell the two apart. `lib/names.js` sat undeclared
56
+ * behind exactly that.
57
+ */
58
+ export function declaredOwners(projectRoot, file) {
59
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
60
+ if (!isDir(specsDir)) return [];
61
+ const norm = String(file).replace(/\\/g, "/");
62
+ const owners = [];
63
+ for (const cap of readdirSync(specsDir).sort()) {
64
+ const specPath = path.join(specsDir, cap, "spec.md");
65
+ if (!isFile(specPath)) continue;
66
+ const globs = parseSourceGlobs(read(specPath)).map((g) => globToRegExp(g));
67
+ if (globs.some((re) => re.test(norm))) owners.push(cap);
68
+ }
69
+ return owners;
70
+ }
71
+
72
+ // Deterministic term overlap: fold prompt and spec text with the SHARED
73
+ // lexicon, then score. This is a hint for the agent, never a decision
74
+ // (ADR 0005).
75
+ //
76
+ // Before change 0040 this carried its own stop list and its own fold, while
77
+ // `context --for` carried different ones — and the work playbook tells the
78
+ // agent to run `context` immediately after `work`. Two rankers, in sequence,
79
+ // on the same prompt, free to disagree about which capability it is about.
80
+ // Both now read lib/lexicon.js, and the score below is that module's
81
+ // PROJECTION of the relevance tuple, not a second calculation.
82
+ export function rankCapabilities(projectRoot, prompt) {
83
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
84
+ const queryTerms = terms(prompt);
85
+ if (queryTerms.length === 0 || !isDir(specsDir)) return [];
86
+
87
+ const ranked = [];
88
+ for (const cap of readdirSync(specsDir).sort()) {
89
+ const specPath = path.join(specsDir, cap, "spec.md");
90
+ if (!isFile(specPath)) continue;
91
+ // The capability name is the emphasis: a term in the name says far more
92
+ // about what the prompt is about than the same term buried in the body.
93
+ const points = score(read(specPath), queryTerms, cap.split("-").join(" "));
94
+ if (points > 0) ranked.push({ id: cap, score: points, path: `.doctrina/specs/${cap}/spec.md` });
95
+ }
96
+ const sorted = ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
97
+ // The margin over the NEXT capability — computed before the top-3 cut, so
98
+ // the winner's margin is a real one — is what lets a caller tell a lead
99
+ // apart from a coin toss (change 0044). The last capability's runner-up is
100
+ // 0, the same convention `triage` uses for a lane nothing scored against.
101
+ return sorted.slice(0, 3).map((e, i) => ({ ...e, margin: e.score - (sorted[i + 1]?.score ?? 0) }));
102
+ }
@@ -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,18 +42,16 @@ 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` — drive a change by hand — check --verbose previews every delta. *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
- - `doctrina skill new|list|sync|suggest` — capture on-demand procedural memory. *When:* a lesson is worth not relearning.
52
+ - `doctrina skill new|list|suggest` — capture on-demand procedural memory. *When:* a lesson is worth not relearning.
60
53
  - `doctrina intent add|list` — append and list product intent anchors. *When:* new product intent appears after the intake.
61
54
  **Gate**
62
- - `doctrina analyze` — structural pre-flight on a change folder. *When:* before applying a change.
63
55
  - `doctrina clarify --all (--lang pt|en)` — smell-test Markdown for ambiguity. *When:* before applying, or before opening a PR.
64
56
  - `doctrina validate (--fix)` — schema, structure, EARS, and index drift. *When:* after any artifact edit, and before considering work done.
65
57
  - `doctrina coverage --strict` — acceptance criteria against cited evidence. *When:* before claiming a capability is proven.
@@ -68,7 +60,7 @@ canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.
68
60
  - `doctrina verify` — the project's declared typecheck/test/build checks. *When:* the real build gate must run.
69
61
  - `doctrina close <id...>` — the whole closing sequence in one attested pass. *When:* a change is implemented and ready to finish.
70
62
  - `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`
63
+ **Maintain** (triggers: `doctrina <command> --help`) — `doctrina templates list` · `doctrina hooks install` · `doctrina index rebuild` · `doctrina watch` · `doctrina metrics` · `doctrina completion` · `doctrina upgrade --write` · `doctrina ci --emit github`
72
64
  <!-- doctrina:surface:end -->
73
65
 
74
66
  ## Stack and tooling
@@ -121,12 +113,13 @@ Finish by running `doctrina validate` and resolving every `error:`.
121
113
 
122
114
  `doctrina context [<capability>] --concat` assembles the read pack in one
123
115
  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.
116
+ accepted ADRs. Run it for ANY task; it skips `changes/archive/`. Skills are
117
+ on demand: read the `description:` / `when:` frontmatter in
118
+ `.doctrina/skills/` and load a body only when its trigger fires.
128
119
 
129
- Keep this file under 150 lines. Density beats prose.
120
+ Keep this file under 150 lines. The scaffold already spends most of them
121
+ and the generated block above grows with the CLI — `doctrina doctor`
122
+ reports what is left. Density beats prose.
130
123
 
131
124
  ## Definition of done
132
125