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
@@ -0,0 +1,206 @@
1
+ // @ts-check
2
+ // The project's configuration, in one place, with WHERE each value came from.
3
+ //
4
+ // Five files configured a Doctrina project and only one of them had an
5
+ // `--init`: `index.json` (the context budget, under `config`), `config.json`
6
+ // (the language, read by `clarify` alone), `rules.json` (project rules,
7
+ // read by `validate` alone), `verify.json`, and `verify.signoffs.json`.
8
+ // `config.json` and `rules.json` were created by nothing, documented in no
9
+ // surface block, and reported by no command — they existed only for someone
10
+ // who had read the source. The symptom that found this (audit finding F19):
11
+ // a pt-BR project sits permanently red under `clarify` with no clue why.
12
+ //
13
+ // `.doctrina/config.json` is now the declared home. The old locations are
14
+ // still READ — a project that configured a budget in `index.json` keeps
15
+ // working, and removal is announced rather than performed here — but every
16
+ // consumer asks this module, and every value carries its source so `doctor`
17
+ // can print "configured" or "default" instead of leaving a project to guess.
18
+ import path from "node:path";
19
+ import { isFile, read } from "./fs-ops.js";
20
+
21
+ export const CONFIG_REL = ".doctrina/config.json";
22
+ export const RULES_REL = ".doctrina/rules.json";
23
+
24
+ // The built-in default for every option. `language: null` means "detect per
25
+ // file", which is what the absence of a declaration has always meant.
26
+ export const DEFAULTS = Object.freeze({
27
+ language: null,
28
+ context_budget: 15000,
29
+ rules: [],
30
+ });
31
+
32
+ // Where a value came from, most authoritative first. The strings are printed
33
+ // by `doctor`, so they name FILES a reader can open, not internal ids.
34
+ export const SOURCES = Object.freeze({
35
+ config: CONFIG_REL,
36
+ index: ".doctrina/index.json (config block — legacy home)",
37
+ rules: RULES_REL + " (legacy home)",
38
+ default: "default",
39
+ });
40
+
41
+ export function configPath(projectRoot) {
42
+ return path.join(projectRoot, ".doctrina", "config.json");
43
+ }
44
+
45
+ /**
46
+ * Read the effective configuration.
47
+ *
48
+ * Precedence, per option: `.doctrina/config.json` → the legacy home for that
49
+ * option → the built-in default. Malformed JSON is REPORTED, never thrown:
50
+ * a project cannot be left unable to assemble a context pack because someone
51
+ * left a trailing comma in a preferences file — `validate` is where that
52
+ * becomes an error.
53
+ *
54
+ * @param {string} projectRoot
55
+ * @returns {{
56
+ * language: string|null, context_budget: number, rules: any[],
57
+ * sources: {language: string, context_budget: string, rules: string},
58
+ * errors: string[],
59
+ * unknown: string[]
60
+ * }}
61
+ */
62
+ export function loadConfig(projectRoot) {
63
+ const errors = [];
64
+ const cfg = readJson(configPath(projectRoot), CONFIG_REL, errors);
65
+ // Keys the CLI does not know (change 0115). `{"context_budjet": 10}` was
66
+ // silence at every door, and the author believed the budget was set.
67
+ // Reported, never rejected: the effective values are unchanged.
68
+ const KNOWN_KEYS = new Set(["language", "context_budget", "rules", "$comment", "$schema"]);
69
+ const unknown = cfg && typeof cfg === "object" && !Array.isArray(cfg)
70
+ ? Object.keys(cfg).filter((k) => !KNOWN_KEYS.has(k))
71
+ : [];
72
+ /** @type {{language: string, context_budget: string, rules: string}} */
73
+ const sources = { language: SOURCES.default, context_budget: SOURCES.default, rules: SOURCES.default };
74
+
75
+ // language — config.json has always been its only home.
76
+ //
77
+ // PRESENCE is what makes an option configured, not the value: a key
78
+ // scaffolded as its default is still a declaration, and reporting it as
79
+ // "default" would tell a reader the file does not say what it plainly
80
+ // says. Only a key that is absent, or one whose value is rejected, falls
81
+ // back to the default.
82
+ let language = DEFAULTS.language;
83
+ if (cfg && "language" in cfg) {
84
+ const rawLang = cfg.language;
85
+ sources.language = SOURCES.config;
86
+ if (rawLang === null) {
87
+ language = null; // an explicit "detect per file"
88
+ } else if (typeof rawLang === "string" && normalizeLanguage(rawLang) !== null) {
89
+ language = normalizeLanguage(rawLang);
90
+ } else {
91
+ errors.push(`${CONFIG_REL}: unknown language ${JSON.stringify(rawLang)} (use "en" or "pt", or null to detect per file)`);
92
+ sources.language = SOURCES.default;
93
+ }
94
+ }
95
+
96
+ // context_budget — declared here, historically in index.json's config block.
97
+ let context_budget = DEFAULTS.context_budget;
98
+ if (cfg && "context_budget" in cfg && isPositiveInt(cfg.context_budget)) {
99
+ context_budget = cfg.context_budget;
100
+ sources.context_budget = SOURCES.config;
101
+ } else {
102
+ if (cfg && "context_budget" in cfg) {
103
+ errors.push(`${CONFIG_REL}: context_budget must be a positive integer (got ${JSON.stringify(cfg.context_budget)})`);
104
+ }
105
+ const fromIndex = readJson(path.join(projectRoot, ".doctrina", "index.json"), ".doctrina/index.json", [])?.config?.context_budget;
106
+ if (isPositiveInt(fromIndex)) {
107
+ context_budget = fromIndex;
108
+ sources.context_budget = SOURCES.index;
109
+ }
110
+ }
111
+
112
+ // rules — declared here, historically a file of their own.
113
+ let rules = DEFAULTS.rules;
114
+ if (cfg && Array.isArray(cfg.rules)) {
115
+ rules = cfg.rules;
116
+ sources.rules = SOURCES.config;
117
+ } else {
118
+ if (cfg && "rules" in cfg) {
119
+ errors.push(`${CONFIG_REL}: rules must be an array`);
120
+ }
121
+ const rulesPath = path.join(projectRoot, ".doctrina", "rules.json");
122
+ if (isFile(rulesPath)) {
123
+ const legacy = readJson(rulesPath, RULES_REL, errors);
124
+ if (legacy && !Array.isArray(legacy.rules)) {
125
+ errors.push(`${RULES_REL}: expected { "rules": [...] }`);
126
+ } else if (legacy) {
127
+ rules = legacy.rules;
128
+ sources.rules = SOURCES.rules;
129
+ }
130
+ }
131
+ }
132
+
133
+ return { language, context_budget, rules, sources, errors, unknown };
134
+ }
135
+
136
+ /**
137
+ * The configuration as rows a reader can scan: option, effective value, and
138
+ * where it came from. `doctor` prints these; the point is that a project can
139
+ * see what it configured without reading the CLI's source.
140
+ *
141
+ * @param {string} projectRoot
142
+ * @returns {{option: string, value: string, source: string, configured: boolean}[]}
143
+ */
144
+ export function configRows(projectRoot) {
145
+ const cfg = loadConfig(projectRoot);
146
+ const show = (v) => (v === null ? "auto-detect per file" : String(v));
147
+ return [
148
+ { option: "language", value: show(cfg.language), source: cfg.sources.language, configured: cfg.sources.language !== SOURCES.default },
149
+ { option: "context_budget", value: `${cfg.context_budget} tokens`, source: cfg.sources.context_budget, configured: cfg.sources.context_budget !== SOURCES.default },
150
+ { option: "rules", value: `${cfg.rules.length} rule${cfg.rules.length === 1 ? "" : "s"}`, source: cfg.sources.rules, configured: cfg.sources.rules !== SOURCES.default },
151
+ ];
152
+ }
153
+
154
+ /** "pt-BR" → "pt", "en-GB" → "en", anything else → null. */
155
+ export function normalizeLanguage(raw) {
156
+ const lang = String(raw ?? "").toLowerCase();
157
+ if (lang.startsWith("pt")) return "pt";
158
+ if (lang.startsWith("en")) return "en";
159
+ return null;
160
+ }
161
+
162
+ function isPositiveInt(n) {
163
+ return Number.isInteger(n) && n > 0;
164
+ }
165
+
166
+ // A malformed file is a finding, not an exception: every caller of this
167
+ // module is either a gate that will report it or a command that must keep
168
+ // working without it.
169
+ function readJson(file, rel, errors) {
170
+ if (!isFile(file)) return null;
171
+ try {
172
+ return JSON.parse(read(file));
173
+ } catch (err) {
174
+ errors.push(`${rel} is not valid JSON: ${err.message}`);
175
+ return null;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The file `init` writes, from the same module that reads it.
181
+ *
182
+ * Written from the definition rather than from a template, for the reason
183
+ * index.json is: a template copy is how a project comes to be born already
184
+ * needing a scaffold update (audit item C5).
185
+ *
186
+ * It declares NOTHING. A scaffold that restated every default would make the
187
+ * file lie in two directions: `doctor` could no longer tell a choice from a
188
+ * default, and a written default would silently outrank a legacy declaration
189
+ * — a project that had set its budget in index.json would find it ignored by
190
+ * a file it never edited. So the scaffold is documentation of the options,
191
+ * and the first key a project adds is the first thing it has actually chosen.
192
+ * JSON carries no comments, hence `$comment`.
193
+ *
194
+ * @returns {string} the file's contents, newline-terminated
195
+ */
196
+ export function scaffoldConfig() {
197
+ return JSON.stringify({
198
+ $comment:
199
+ "Doctrina project configuration. Nothing is declared here yet — add a key to " +
200
+ "change it. Options: \"language\" (\"en\" | \"pt\", or null to detect per file); " +
201
+ `"context_budget" (positive integer, default ${DEFAULTS.context_budget}); ` +
202
+ "\"rules\" (array of { id, forbid, paths, message } — permanent project " +
203
+ "constraints enforced by `doctrina validate`). Run `doctrina doctor` to see the " +
204
+ "effective value of each option and where it came from.",
205
+ }, null, 2) + "\n";
206
+ }
@@ -0,0 +1,102 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { readdirSync } from "node:fs";
4
+ import { isDir, isFile, read } from "./fs-ops.js";
5
+ import { listHeader } from "./scan.js";
6
+
7
+ // The standing-rules MODEL: the accepted ADRs and the named sections of
8
+ // product.md that together are the project's constitution. `constitution`
9
+ // renders them in full and `prime` digests them to titles, so the reading
10
+ // belongs here rather than in either command (audit finding F7).
11
+
12
+ // Accepted ADRs (NNNN-slug.md with Status: accepted), oldest first, with the
13
+ // title read from the `# ADR NNNN — <title>` heading. Exported for `prime`,
14
+ // which digests the same standing rules into the session primer.
15
+ export function acceptedDecisions(projectRoot) {
16
+ const dir = path.join(projectRoot, ".doctrina", "decisions");
17
+ const out = [];
18
+ if (!isDir(dir)) return out;
19
+ for (const f of readdirSync(dir).sort()) {
20
+ const m = f.match(/^(\d{4})-.*\.md$/);
21
+ if (!m) continue;
22
+ const text = read(path.join(dir, f));
23
+ if ((listHeader(text, "Status") ?? "").toLowerCase() !== "accepted") continue;
24
+ const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
25
+ out.push({ id: m[1], title: titleMatch ? titleMatch[1].trim() : f });
26
+ }
27
+ return out;
28
+ }
29
+
30
+ // Bullets under a named `## <section>` of product.md, each accumulated across
31
+ // its continuation lines so a wrapped bullet reads as one rule. Exported for
32
+ // `prime`.
33
+ /**
34
+ * Does `product.md` carry this `## ` section at all? Separate from whether it
35
+ * holds anything, so a finding can name the remedy that actually resolves it
36
+ * (rule C2): "add the section" and "fill the section" are different asks.
37
+ *
38
+ * @param {string} projectRoot
39
+ * @param {string} name
40
+ * @returns {boolean}
41
+ */
42
+ export function hasProductSection(projectRoot, name) {
43
+ const p = path.join(projectRoot, ".doctrina", "product.md");
44
+ if (!isFile(p)) return false;
45
+ return new RegExp(`^##\\s+${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "im")
46
+ .test(read(p));
47
+ }
48
+
49
+ export function productSection(projectRoot, name) {
50
+ const p = path.join(projectRoot, ".doctrina", "product.md");
51
+ if (!isFile(p)) return [];
52
+ const lines = read(p).split(/\r?\n/);
53
+ let inSection = false;
54
+ let inComment = false;
55
+ const out = [];
56
+ let cur = null;
57
+ const flush = () => {
58
+ if (cur) out.push(cur.replace(/\s+/g, " ").trim());
59
+ cur = null;
60
+ };
61
+ for (const line of lines) {
62
+ if (/^##\s+/.test(line)) {
63
+ if (inSection) {
64
+ flush();
65
+ break;
66
+ }
67
+ if (new RegExp(`^##\\s+${name}\\b`, "i").test(line)) inSection = true;
68
+ continue;
69
+ }
70
+ if (!inSection) continue;
71
+ // An HTML comment is annotation, not content (change 0055): the template's
72
+ // own instruction for this section must never read as a declared item.
73
+ if (inComment) {
74
+ if (line.includes("-->")) inComment = false;
75
+ continue;
76
+ }
77
+ if (line.trim().startsWith("<!--")) {
78
+ if (!line.includes("-->")) inComment = true;
79
+ continue;
80
+ }
81
+ const m = line.match(/^\s*[-*]\s+(.+)$/);
82
+ if (m) {
83
+ flush();
84
+ cur = m[1];
85
+ } else if (cur && line.trim() !== "") {
86
+ cur += " " + line.trim();
87
+ } else if (line.trim() === "") {
88
+ flush();
89
+ } else {
90
+ // PROSE (change 0069). The section's own template comment invites it —
91
+ // "Explicit things this project will NOT try to be" asks for no bullets
92
+ // — and reading only bullets made `prime --rules` report a filled
93
+ // section as "none declared" and tell the author to add a section that
94
+ // was already there. Rule C2: a finding may only name a remedy that
95
+ // resolves it, and this one named an act already done. A paragraph is
96
+ // one item; a blank line ends it, exactly as it ends a bullet.
97
+ cur = line.trim();
98
+ }
99
+ }
100
+ flush();
101
+ return out;
102
+ }