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
@@ -8,6 +8,11 @@ import { flagBool } from "../lib/args.js";
8
8
  import { c } from "../lib/colors.js";
9
9
  import { emitJson } from "../lib/json-out.js";
10
10
  import { notADoctrinaProject } from "../lib/exit-codes.js";
11
+ import { summarize, collectAnchors, collectAnchorDuplicates, collectSpecs } from "../lib/trace-model.js";
12
+
13
+ // The provenance arithmetic lives in lib/trace-model.js, which `status`,
14
+ // `review` and the project snapshot read too (audit finding F7).
15
+ export { summarize } from "../lib/trace-model.js";
11
16
 
12
17
  // Intent-provenance report (ADR 0006). `coverage` proves a criterion has a
13
18
  // test; `trace` proves a capability traces to a stated intent. Together they
@@ -48,9 +53,23 @@ export async function run(_positional, flags) {
48
53
 
49
54
  const anchors = collectAnchors(projectRoot); // [{ id }], in document order
50
55
  const specs = collectSpecs(projectRoot); // [{ cap, status, realizes: [id] }]
56
+ // An id declared twice is two intents under one name (change 0108): the
57
+ // collector keeps the first, so the second is invisible everywhere unless
58
+ // named here. A gap, like a dropped anchor — `validate` reports it as an
59
+ // error as well.
60
+ const duplicates = collectAnchorDuplicates(projectRoot); // [{ id, lines }]
51
61
 
52
62
  const anchorIds = new Set(anchors.map((a) => a.id));
53
- const anyRealizes = specs.some((s) => s.realizes !== null);
63
+ // A spec OPTED IN when it cites at least one anchor id. The header alone
64
+ // is not opting in: `spec new` scaffolds `**Realizes:** n/a — <reason>`,
65
+ // which parses to an empty id list and used to count as participation.
66
+ // One scaffolded spec was therefore enough to take a project out of the
67
+ // never-opted-in branch and into the normal report, where zero anchors
68
+ // rendered as `ok 0 of 0 intent anchors realized` — a green verdict over
69
+ // nothing, on the very first read a new project gets about itself, while
70
+ // `doctor` read the same collection and warned (change 0083). Absence is
71
+ // not approval — the half change 0057 fixed in `coverage` and not here.
72
+ const anyRealizes = specs.some((s) => s.realizes !== null && s.realizes.length > 0);
54
73
 
55
74
  // The feature is unused: do not nag a project that never opted in.
56
75
  if (anchors.length === 0 && !anyRealizes) {
@@ -88,8 +107,8 @@ export async function run(_positional, flags) {
88
107
 
89
108
  if (json) {
90
109
  const rows = anchors.map((a) => ({ id: a.id, realizedBy: (realizedBy.get(a.id) ?? []).sort() }));
91
- const clean = rows.every((r) => r.realizedBy.length > 0) && dangling.length === 0 && untraceable.length === 0;
92
- emitJson("trace", { anchors: rows, dangling, untraceable, summary: summarize(projectRoot) });
110
+ const clean = rows.every((r) => r.realizedBy.length > 0) && dangling.length === 0 && untraceable.length === 0 && duplicates.length === 0;
111
+ emitJson("trace", { anchors: rows, dangling, untraceable, duplicates, summary: summarize(projectRoot) });
93
112
  return clean ? 0 : strict ? 1 : 0;
94
113
  }
95
114
 
@@ -107,7 +126,10 @@ export async function run(_positional, flags) {
107
126
  }
108
127
  }
109
128
 
110
- if (dangling.length > 0 || untraceable.length > 0) console.log("");
129
+ if (dangling.length > 0 || untraceable.length > 0 || duplicates.length > 0) console.log("");
130
+ for (const d of duplicates) {
131
+ console.log(` ${c.red("✗")} duplicate anchor [${d.id}] — declared at product.md lines ${d.lines.join(" and ")}; only the first is read, give the second its own id`);
132
+ }
111
133
  for (const d of dangling) {
112
134
  console.log(` ${c.yellow("!")} dangling: spec "${d.cap}" realizes ${d.id} (no such anchor in product.md)`);
113
135
  }
@@ -117,10 +139,16 @@ export async function run(_positional, flags) {
117
139
 
118
140
  const dropped = anchors.length - realized;
119
141
  console.log("");
120
- const summary =
121
- `${realized} of ${anchors.length} intent anchor${anchors.length === 1 ? "" : "s"} realized` +
122
- `; ${dropped} dropped, ${dangling.length} dangling, ${untraceable.length} untraceable`;
123
- const clean = dropped === 0 && dangling.length === 0 && untraceable.length === 0;
142
+ const summary = anchors.length === 0
143
+ ? `no intent anchors declared in product.md` +
144
+ `; ${dangling.length} dangling, ${untraceable.length} untraceable`
145
+ : `${realized} of ${anchors.length} intent anchor${anchors.length === 1 ? "" : "s"} realized` +
146
+ `; ${dropped} dropped, ${dangling.length} dangling, ${untraceable.length} untraceable` +
147
+ (duplicates.length > 0 ? `, ${duplicates.length} duplicate` : "");
148
+ // Reaching the report with no anchors at all is never clean: there is
149
+ // nothing to have realized, and a ratio over zero says nothing true.
150
+ const clean = anchors.length > 0
151
+ && dropped === 0 && dangling.length === 0 && untraceable.length === 0 && duplicates.length === 0;
124
152
  if (clean) {
125
153
  console.log(c.green("ok") + " " + summary);
126
154
  return 0;
@@ -130,69 +158,6 @@ export async function run(_positional, flags) {
130
158
  return strict ? 1 : 0;
131
159
  }
132
160
 
133
- // Pure summary of intent provenance, for other commands (`status`, `review`)
134
- // that need the numbers without the report output.
135
- export function summarize(projectRoot) {
136
- const anchors = collectAnchors(projectRoot);
137
- const specs = collectSpecs(projectRoot);
138
- const anchorIds = new Set(anchors.map((a) => a.id));
139
- const realizedBy = new Map();
140
- let dangling = 0;
141
- for (const s of specs) {
142
- if (s.realizes === null) continue;
143
- for (const id of s.realizes) {
144
- if (anchorIds.has(id)) {
145
- if (!realizedBy.has(id)) realizedBy.set(id, []);
146
- realizedBy.get(id).push(s.cap);
147
- } else {
148
- dangling += 1;
149
- }
150
- }
151
- }
152
- const untraceable = specs.filter((s) => s.realizes === null && s.status === "active").length;
153
- let realized = 0;
154
- for (const a of anchors) if ((realizedBy.get(a.id) ?? []).length > 0) realized += 1;
155
- return {
156
- anchors: anchors.length,
157
- realized,
158
- dropped: anchors.length - realized,
159
- dangling,
160
- untraceable,
161
- };
162
- }
163
-
164
- // Every "[A-Z]+\d+" tag at the head of a bullet in product.md is an intent
165
- // anchor. Section-agnostic so Success-criteria and In-scope bullets both work.
166
- function collectAnchors(projectRoot) {
167
- const productPath = path.join(projectRoot, ".doctrina", "product.md");
168
- if (!isFile(productPath)) return [];
169
- const out = [];
170
- const seen = new Set();
171
- for (const line of read(productPath).split(/\r?\n/)) {
172
- const m = line.match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s+/);
173
- if (m && !seen.has(m[1])) {
174
- seen.add(m[1]);
175
- out.push({ id: m[1] });
176
- }
177
- }
178
- return out;
179
- }
180
-
181
- function collectSpecs(projectRoot) {
182
- const specsDir = path.join(projectRoot, ".doctrina", "specs");
183
- if (!isDir(specsDir)) return [];
184
- const out = [];
185
- for (const cap of readdirSync(specsDir).sort()) {
186
- const specPath = path.join(specsDir, cap, "spec.md");
187
- if (!isFile(specPath)) continue;
188
- const text = read(specPath);
189
- const realizesRaw = specHeader(text, "Realizes");
190
- const realizes = realizesRaw === null ? null : (realizesRaw.match(ANCHOR_RE) ?? []);
191
- const status = (specHeader(text, "Status") ?? "active").trim().toLowerCase();
192
- out.push({ cap, status, realizes });
193
- }
194
- return out;
195
- }
196
161
 
197
162
  export const help = `
198
163
  Usage: doctrina trace [--strict]
@@ -4,6 +4,10 @@ import process from "node:process";
4
4
  import { exists } from "../lib/fs-ops.js";
5
5
  import { flagBool } from "../lib/args.js";
6
6
  import { c } from "../lib/colors.js";
7
+ import { classify } from "../lib/triage-model.js";
8
+
9
+ // The classifier is shared with `work`; see lib/triage-model.js.
10
+ export { classify } from "../lib/triage-model.js";
7
11
  import { emitJson } from "../lib/json-out.js";
8
12
  import { collectRuntimeFindings, checkLocalEnv } from "../lib/runtime.js";
9
13
  import { notADoctrinaProject } from "../lib/exit-codes.js";
@@ -35,86 +39,14 @@ export const jsonNative = true;
35
39
 
36
40
  export const flags = { boolean: ["json", "env"], string: [] };
37
41
 
38
- // Signals that a request is about the running system rather than about what
39
- // the system should do. Weighted: a phrase that can only mean runtime counts
40
- // for more than a word that merely leans that way.
41
- const RUNTIME_SIGNALS = [
42
- [3, /\b(workflow|pipeline|ci|github actions|actions run|job|runner)\b/i],
43
- [3, /\b(env var|environment variable|dotenv|\.env|secret|secrets|vars)\b/i],
44
- [3, /\b(0 (tests?|scenarios?|cases?)|no (tests?|scenarios?) ran|ran nothing|empty (run|suite|summary))\b/i],
45
- [2, /\b(exit code|stack ?trace|traceback|logs?|step summary|artifact missing)\b/i],
46
- [2, /\b(green but|passes but|silently|never (fires|applies|reaches|ran))\b/i],
47
- [2, /\b(flaky|timeout|timed out|hangs?|crash(es|ed)?|broken build|failing build)\b/i],
48
- [2, /\b(tag|selector|filter|dispatch)\b/i],
49
- [1, /\b(config|configuration|wiring|wired|deploy(ment)?|docker|compose)\b/i],
50
- [1, /\b(debug|diagnos(e|is)|investigate|why (is|does|did)|not working|doesn't work)\b/i],
51
- ];
52
-
53
- const CHORE_SIGNALS = [
54
- [3, /\b(bump|upgrade|pin|lockfile|dependency|dependencies|devdependenc)\b/i],
55
- [2, /\b(rename|move|reorganis|reorganiz|refactor|tidy|cleanup|clean up|format(ting)?|lint)\b/i],
56
- [2, /\b(typo|comment|readme|changelog|docs? only)\b/i],
57
- [2, /\b(already (specified|in the spec)|wiring only|no behaviour change|no behavior change)\b/i],
58
- ];
59
-
60
- const PRODUCT_SIGNALS = [
61
- [3, /\b(add|introduce|support|allow|enable|implement|build)\b/i],
62
- [3, /\b(should|shall|must|requirement|acceptance|criteri)\b/i],
63
- [2, /\b(new (feature|capability|command|endpoint|screen|flow))\b/i],
64
- [2, /\b(users? (can|should|want)|so that|in order to)\b/i],
65
- [1, /\b(change|behaviour|behavior|spec)\b/i],
66
- ];
42
+
43
+
67
44
 
68
45
  /**
69
46
  * Score a prompt against each lane. Deterministic, explainable, and cheap:
70
47
  * the caller gets the winning lane AND the signals that produced it, so a
71
48
  * wrong guess is arguable rather than mysterious.
72
49
  */
73
- export function classify(prompt) {
74
- const text = String(prompt ?? "");
75
- const score = (signals) => {
76
- let total = 0;
77
- const hits = [];
78
- for (const [weight, re] of signals) {
79
- const m = text.match(re);
80
- if (m) {
81
- total += weight;
82
- hits.push(m[0].toLowerCase());
83
- }
84
- }
85
- return { total, hits };
86
- };
87
-
88
- const lanes = {
89
- runtime: score(RUNTIME_SIGNALS),
90
- chore: score(CHORE_SIGNALS),
91
- product: score(PRODUCT_SIGNALS),
92
- };
93
-
94
- // Product is the default: when nothing distinguishes the request, the
95
- // ceremony that writes a spec delta is the safe one to fall into. Runtime
96
- // must BEAT product to win, so an ordinary feature request mentioning a
97
- // "job" does not get diverted into a diagnosis.
98
- let lane = "product";
99
- let best = lanes.product.total;
100
- for (const name of ["runtime", "chore"]) {
101
- if (lanes[name].total > best) {
102
- lane = name;
103
- best = lanes[name].total;
104
- }
105
- }
106
- if (best === 0) lane = "product";
107
-
108
- // Confidence is the MARGIN over the runner-up, not the winner's own score:
109
- // a prompt that scores 5 for runtime and 4 for product is a coin toss the
110
- // agent should see as one, and a clear product request must be able to read
111
- // as confident too (comparing a lane against itself never could).
112
- const runnerUp = Object.entries(lanes)
113
- .filter(([name]) => name !== lane)
114
- .reduce((max, [, v]) => Math.max(max, v.total), 0);
115
-
116
- return { lane, scores: lanes, confident: best > 0 && best - runnerUp >= 2 };
117
- }
118
50
 
119
51
  export async function run(positional, cmdFlags) {
120
52
  const projectRoot = process.cwd();
@@ -202,7 +134,7 @@ function renderRuntime(runtime, envFindings, withEnv, hadLane) {
202
134
 
203
135
  const all = [...runtime.findings, ...envFindings];
204
136
  if (all.length === 0) {
205
- console.log(c.green(" ok") + ` ${runtime.declared} declared row${runtime.declared === 1 ? "" : "s"} across ${runtime.contracts} contract${runtime.contracts === 1 ? "" : "s"} hold` + (withEnv ? " (including local .env)" : ""));
137
+ console.log(c.green(" ok") + ` ${runtime.declared} declared row${runtime.declared === 1 ? " holds" : "s hold"} across ${runtime.contracts} contract${runtime.contracts === 1 ? "" : "s"}` + (withEnv ? " (including local .env)" : ""));
206
138
  console.log("");
207
139
  return;
208
140
  }