doctrina-cli 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -212
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +4 -82
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +60 -138
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +56 -4
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +181 -23
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +191 -5
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/hooks/pre-commit.sample +6 -2
  82. package/templates/playbooks/bootstrap.md.template +43 -0
  83. package/templates/playbooks/chore.md.template +19 -0
  84. package/templates/playbooks/work.md.template +76 -0
@@ -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,96 +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
42
 
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
43
 
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
- // AUTHORING VERBS. Both lanes above match domain NOUNS, which turned out
64
- // to misread work ON the machinery as an incident IN it: "declare the
65
- // release workflow wiring in a contract" scored RUNTIME on "workflow" and
66
- // "wiring", and "so an intentional rename stops warning" scored CHORE on
67
- // "rename" — a noun, not the act. Neither prompt was a diagnosis; both
68
- // were someone sitting down to write an artifact.
69
- //
70
- // These are the verbs of authoring. A prompt whose verb is "declare" or
71
- // "document" is describing work on the tree, whatever nouns surround it.
72
- [3, /\b(declare|declaring|document|documenting|record|recording|specify|specifying|define|defining)\b/i],
73
- [2, /\b(new (feature|capability|command|endpoint|screen|flow))\b/i],
74
- [2, /\b(users? (can|should|want)|so that|in order to)\b/i],
75
- [1, /\b(change|behaviour|behavior|spec)\b/i],
76
- ];
77
44
 
78
45
  /**
79
46
  * Score a prompt against each lane. Deterministic, explainable, and cheap:
80
47
  * the caller gets the winning lane AND the signals that produced it, so a
81
48
  * wrong guess is arguable rather than mysterious.
82
49
  */
83
- export function classify(prompt) {
84
- const text = String(prompt ?? "");
85
- const score = (signals) => {
86
- let total = 0;
87
- const hits = [];
88
- for (const [weight, re] of signals) {
89
- const m = text.match(re);
90
- if (m) {
91
- total += weight;
92
- hits.push(m[0].toLowerCase());
93
- }
94
- }
95
- return { total, hits };
96
- };
97
-
98
- const lanes = {
99
- runtime: score(RUNTIME_SIGNALS),
100
- chore: score(CHORE_SIGNALS),
101
- product: score(PRODUCT_SIGNALS),
102
- };
103
-
104
- // Product is the default: when nothing distinguishes the request, the
105
- // ceremony that writes a spec delta is the safe one to fall into. Runtime
106
- // must BEAT product to win, so an ordinary feature request mentioning a
107
- // "job" does not get diverted into a diagnosis.
108
- let lane = "product";
109
- let best = lanes.product.total;
110
- for (const name of ["runtime", "chore"]) {
111
- if (lanes[name].total > best) {
112
- lane = name;
113
- best = lanes[name].total;
114
- }
115
- }
116
- if (best === 0) lane = "product";
117
-
118
- // Confidence is the MARGIN over the runner-up, not the winner's own score:
119
- // a prompt that scores 5 for runtime and 4 for product is a coin toss the
120
- // agent should see as one, and a clear product request must be able to read
121
- // as confident too (comparing a lane against itself never could).
122
- const runnerUp = Object.entries(lanes)
123
- .filter(([name]) => name !== lane)
124
- .reduce((max, [, v]) => Math.max(max, v.total), 0);
125
-
126
- return { lane, scores: lanes, confident: best > 0 && best - runnerUp >= 2 };
127
- }
128
50
 
129
51
  export async function run(positional, cmdFlags) {
130
52
  const projectRoot = process.cwd();