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
@@ -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();
@@ -9,6 +9,7 @@ import { flagBool } from "../lib/args.js";
9
9
  import { c } from "../lib/colors.js";
10
10
  import { today } from "../lib/dates.js";
11
11
  import { notADoctrinaProject } from "../lib/exit-codes.js";
12
+ import { collectFindings } from "../lib/templates-model.js";
12
13
  import * as templates from "./templates.js";
13
14
  import * as validate from "./validate.js";
14
15
 
@@ -56,11 +57,23 @@ export async function run(_positional, flags) {
56
57
  // 2. Additive template updates (missing recommended sections in AGENTS.md /
57
58
  // product.md, missing index.json fields). Reuses `templates update`
58
59
  // verbatim: preview without --write, apply with it.
59
- console.log(c.gray("──── 1/3 scaffold shape (templates update)"));
60
+ console.log(c.gray("──── 1/3 scaffold shape"));
60
61
  const tplFlags = writeMode ? new Map([["write", true]]) : new Map();
61
62
  const tplCode = await templates.run(["update"], tplFlags);
62
63
  if (tplCode !== 0 && !writeMode) pending += 1; // preview exits 1 when updates are pending
63
64
 
65
+ // What the additive update cannot fix — a drifted adapter, a broken
66
+ // playbook, an index schema it does not know — used to be visible only to
67
+ // `templates check`. Printed here, each with its own remedy, so the one
68
+ // command that brings a project up to date also says what is left for a
69
+ // person (change 0178: the check and the update became this step).
70
+ const manual = collectFindings(projectRoot).findings
71
+ .filter((f) => !/upgrade --write|index rebuild/.test(f.remedy ?? ""));
72
+ for (const f of manual) {
73
+ console.log(c.yellow("✗ ") + f.message);
74
+ console.log(c.gray(" fix: ") + (f.remedy ? c.cyan(f.remedy) : c.gray("manual repair — no command can fix this")));
75
+ }
76
+
64
77
  // 3. Index: rebuild from the tree, which also migrates the framework stamp.
65
78
  console.log("");
66
79
  console.log(c.gray("──── 2/3 index + framework stamp"));
@@ -101,14 +114,22 @@ export async function run(_positional, flags) {
101
114
  const vCode = await validate.run([], vFlags);
102
115
 
103
116
  console.log("");
117
+ if (manual.length > 0) {
118
+ console.log(c.yellow("manual") + ` ${manual.length} finding${manual.length === 1 ? "" : "s"} the upgrade cannot repair — see step 1`);
119
+ }
104
120
  if (!writeMode) {
105
- if (pending > 0) {
121
+ if (pending > 0 || manual.length > 0) {
122
+ if (pending === 0) return 1;
106
123
  console.log(c.yellow("pending") + ` upgrade steps found — apply them: ${c.cyan("doctrina upgrade --write")}`);
107
124
  return 1;
108
125
  }
109
126
  console.log(c.green("ok") + " nothing to upgrade — the project matches the installed CLI");
110
127
  return vCode;
111
128
  }
129
+ if (vCode === 0 && manual.length > 0) {
130
+ console.log(c.yellow(`upgraded to ${running} with manual repairs left`) + " — each finding in step 1 names its fix.");
131
+ return 1;
132
+ }
112
133
  if (vCode === 0) {
113
134
  console.log(c.green(`✓ project upgraded to ${running}`) + c.gray(" — review any warnings above; new commands: `doctrina --help`."));
114
135
  console.log(c.gray("AGENTS.md's doctrina:surface block was regenerated from the installed catalog, so agents reading the hub now discover the current commands."));
@@ -125,16 +146,20 @@ Bring an existing project up to the installed CLI after an npm update.
125
146
  The project keeps the scaffold of the version that init-ed it; this is
126
147
  the one command that closes the gap, orchestrating the existing pieces:
127
148
 
128
- 1. templates update — regenerate the AGENTS.md doctrina:surface block
149
+ 1. scaffold shape — regenerate the AGENTS.md doctrina:surface block
129
150
  from the installed command catalog (the block is
130
151
  CLI-owned, ADR 0015 — this is how agents reading
131
- the hub discover commands added since init), and
152
+ the hub discover commands added since init),
132
153
  append missing recommended sections / index.json
133
- fields (additive-only outside the block)
154
+ fields (additive-only outside the block), and
155
+ list what no command can repair (a drifted
156
+ adapter, a broken playbook), each with its fix
134
157
  2. index rebuild — regenerate index.json from the tree and migrate
135
158
  the framework_version stamp to the running CLI
136
159
  3. validate (--fix) — surface anything the upgrade cannot fix
137
160
  (hand-authored drift, new validate checks)
138
161
 
139
- Preview by default (exits 1 when steps are pending); --write applies.
162
+ Preview by default (exits 1 when steps are pending or a manual repair is
163
+ left); --write applies. The preview is what \`templates check\` reported
164
+ and --write what \`templates update --write\` did — both deprecated.
140
165
  `;