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
@@ -16,11 +16,11 @@ export const COMMAND_NAMES = [
16
16
  // authoring
17
17
  "spec", "change", "contract", "decision", "skill", "intent", "adapter",
18
18
  // read / orient
19
- "prime", "context", "show", "search", "status", "next", "why", "handoff", "constitution",
19
+ "prime", "context", "show", "search", "status", "next", "why", "handoff",
20
20
  // gates
21
21
  "analyze", "clarify", "validate", "coverage", "trace", "review", "verify", "close", "doctor",
22
22
  // maintenance
23
- "templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade",
23
+ "templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade", "ci",
24
24
  ];
25
25
 
26
26
  // The full operation surface — every `doctrina <command> [<subcommand>]` a
@@ -46,7 +46,6 @@ export const OPERATIONS = [
46
46
  ["change archive", "Archive an applied change (batch ids ok)"],
47
47
  ["change check", "Pre-close dry-run: everything close would refuse, listed first"],
48
48
  ["change tick", "List/tick the unchecked boxes (tasks + verification; --all)"],
49
- ["change diff", "Preview spec deltas (line diff for MODIFIED)"],
50
49
  ["change abandon", "Discard an open change cleanly (recorded in the ledger)"],
51
50
  ["contract new", "Own the integration surface (ports, env, interfaces)"],
52
51
  ["contract list", "List contracts with status and last-updated date"],
@@ -82,7 +81,6 @@ export const OPERATIONS = [
82
81
  ["status", "One-glance project health dashboard"],
83
82
  ["why", "Explain provenance: a capability's chain, or an anchor's (SC1)"],
84
83
  ["handoff", "Markdown handoff note: open work, task state, resume command"],
85
- ["constitution", "Print the standing rules: accepted ADRs + product non-goals"],
86
84
  ["templates list", "List the templates shipped by the installed CLI"],
87
85
  ["templates check", "Compare the project against the recommended template shape"],
88
86
  ["templates update", "Additive fixer for check findings (preview; --write applies)"],
@@ -93,6 +91,7 @@ export const OPERATIONS = [
93
91
  ["metrics", "Local git-derived adoption metrics (no network)"],
94
92
  ["report", "Markdown digest for a period: changes, gates, git summary"],
95
93
  ["completion", "Print bash/zsh/pwsh completions (generated from the catalog)"],
94
+ ["ci", "Emit the CI pipeline for the declared gate sequence (--emit github)"],
96
95
  ["upgrade", "Bring an existing project up to the installed CLI (--write applies)"],
97
96
  ];
98
97
 
@@ -118,20 +117,19 @@ export const COMMAND_META = {
118
117
  intake: { moment: "Bootstrap", when: "you have a full project description and no specs yet", purpose: "store the intent and print the bootstrap playbook" },
119
118
  adapter: { moment: "Bootstrap", when: "adding or removing an agent's pointer files", purpose: "install/remove agent adapters (additive)" },
120
119
 
121
- prime: { moment: "Orient", when: "at the START of every session", purpose: "gates, standing rules, and open work in one read" },
120
+ prime: { moment: "Orient", when: "at the START of every session", purpose: "gates, open work and next steps in one read (--rules for the full standing rules)" },
122
121
  status: { moment: "Orient", when: "you need the health of the tree at a glance", purpose: "index, coverage, trace, and artifact counts" },
123
122
  next: { moment: "Orient", when: "you finished something and do not know what follows", purpose: "the recommended next workflow action" },
124
123
  context: { moment: "Orient", when: "before working on any task, to load the right files", purpose: "the read pack, assembled to fit a token budget" },
125
124
  show: { moment: "Orient", when: "you need one requirement, criterion, or ADR, not a file", purpose: "point-read a single artifact by reference" },
126
125
  search: { moment: "Orient", when: "you do not know which artifact mentions a term", purpose: "search the artifact tree, grouped by category" },
127
126
  why: { moment: "Orient", when: "you need to justify or trace a capability's existence", purpose: "provenance: intent, proof, ADRs, and history" },
128
- constitution: { moment: "Orient", when: "you need the standing rules before deciding something", purpose: "accepted ADRs and product non-goals" },
129
127
  handoff: { moment: "Orient", when: "BEFORE compaction or handing over to another session", purpose: "a resume note: open work, task state, next command" },
130
128
 
131
129
  triage: { moment: "Change", when: "a request arrives — BEFORE scaffolding, especially if it smells like an incident", purpose: "classify the lane (product/runtime/chore) and check the declared runtime surface" },
132
130
  work: { moment: "Change", when: "a request arrives that changes behaviour (triage says PRODUCT)", purpose: "scaffold a change and print the playbook to execute" },
133
131
  spec: { moment: "Change", when: "a capability needs creating or its headers advancing", purpose: "create, list, and edit capability specs" },
134
- change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "new / apply / archive / check / tick / diff / abandon" },
132
+ change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "drive a change by hand — check --verbose previews every delta" },
135
133
  decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
136
134
  contract: { moment: "Change", when: "the change touches ports, env vars, or public endpoints", purpose: "own and verify the integration surface" },
137
135
  intent: { moment: "Change", when: "new product intent appears after the intake", purpose: "append and list product intent anchors" },
@@ -147,7 +145,7 @@ export const COMMAND_META = {
147
145
  close: { moment: "Gate", when: "a change is implemented and ready to finish", purpose: "the whole closing sequence in one attested pass" },
148
146
  doctor: { moment: "Gate", when: "something looks wrong and you do not know which gate to ask", purpose: "aggregate diagnostic with per-finding remedies" },
149
147
 
150
- templates: { moment: "Maintain", when: "after upgrading the CLI, or to customise a scaffold", purpose: "inspect, check, and refresh the template shape" },
148
+ templates: { moment: "Maintain", when: "customising a scaffold, to see which template file wins", purpose: "where each shipped template resolves from" },
151
149
  upgrade: { moment: "Maintain", when: "you just updated the doctrina-cli package", purpose: "bring this project up to the installed CLI" },
152
150
  index: { moment: "Maintain", when: "the index drifted from the tree", purpose: "regenerate index.json from the artifacts on disk" },
153
151
  hooks: { moment: "Maintain", when: "setting up a repo so drift cannot be committed", purpose: "install the pre-commit gate" },
@@ -155,6 +153,7 @@ export const COMMAND_META = {
155
153
  metrics: { moment: "Maintain", when: "reporting adoption over a period", purpose: "local git-derived adoption metrics" },
156
154
  report: { moment: "Maintain", when: "summarising a period for a human", purpose: "a Markdown digest of changes, gates, and git" },
157
155
  completion: { moment: "Maintain", when: "setting up a human's shell", purpose: "print shell completions from the catalog" },
156
+ ci: { moment: "Maintain", when: "the declared gate sequence changed and CI must follow", purpose: "emit the CI pipeline from the gate declaration" },
158
157
  };
159
158
 
160
159
  // The order moments appear in the generated block.
@@ -162,10 +161,49 @@ export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Maintain"];
162
161
 
163
162
  // The Commands block of `doctrina --help`, generated from OPERATIONS so the
164
163
  // printed surface can never omit an operation the catalog knows about.
164
+ //
165
+ // Grouped by the MOMENT an operation is reached for, in the same order as
166
+ // the AGENTS.md block, and opened by the two lines a newcomer needs: a flat
167
+ // list of sixty operations, deprecated ones mixed in, answered "where do I
168
+ // start?" with the whole catalog (change 0174). Deprecated operations stay
169
+ // listed — someone types those names — but last, each pointing at its
170
+ // replacement.
165
171
  export function surfaceHelp() {
166
- return OPERATIONS.map(([op, summary]) => ` ${op.padEnd(21)}${summary}`).join("\n");
172
+ // A name longer than the column puts its text on the next line, aligned,
173
+ // so the summaries stay one scannable column at 80 characters.
174
+ const line = (op, text) => op.length < 20
175
+ ? ` ${op.padEnd(21)}${text}`
176
+ : ` ${op}\n ${"".padEnd(21)}${text}`;
177
+ const out = [
178
+ "Start here:",
179
+ line("doctrina init", "set up this repository (asks for a one-line description)"),
180
+ line("doctrina next", "what to do now — your agent runs the rest from AGENTS.md"),
181
+ ];
182
+ // One line per COMMAND, its subcommands joined: the summary a newcomer
183
+ // scans; `doctrina <command> --help` has each subcommand's detail.
184
+ const live = OPERATIONS.filter(([op]) => !DEPRECATED[op]);
185
+ for (const moment of MOMENTS) {
186
+ const cmds = [...new Set(live.map(([op]) => op.split(" ")[0]))]
187
+ .filter((cmd) => COMMAND_META[cmd]?.moment === moment);
188
+ if (cmds.length === 0) continue;
189
+ out.push("", `${moment}:`);
190
+ for (const cmd of cmds) {
191
+ const ops = live.filter(([op]) => op.split(" ")[0] === cmd);
192
+ const subs = ops.map(([op]) => op.split(" ")[1]).filter(Boolean);
193
+ const text = ops.length === 1 ? ops[0][1] : capitalise(COMMAND_META[cmd].purpose);
194
+ out.push(line(subs.length > 0 ? `${cmd} ${subs.join("|")}` : cmd, text));
195
+ }
196
+ }
197
+ const old = OPERATIONS.filter(([op]) => DEPRECATED[op]);
198
+ if (old.length > 0) {
199
+ out.push("", "Deprecated (still run, with a notice on stderr):",
200
+ ...old.map(([op]) => line(op, `→ ${DEPRECATED[op].use}`)));
201
+ }
202
+ return out.join("\n");
167
203
  }
168
204
 
205
+ const capitalise = (s) => s.charAt(0).toUpperCase() + s.slice(1);
206
+
169
207
  // ---------------------------------------------------------------------------
170
208
  // The CLI-owned AGENTS.md command-surface block (operator review 2026-07-19,
171
209
  // meta-conclusion): for an agent, AGENTS.md IS the discovery interface — it
@@ -195,10 +233,78 @@ const SURFACE_HINTS = {
195
233
  clarify: "--all (--lang pt|en)",
196
234
  close: "<id...>",
197
235
  upgrade: "--write",
236
+ ci: "--emit github",
198
237
  prime: "(session start)",
199
238
  handoff: "(before compaction/handover)",
200
239
  };
201
240
 
241
+ // ---------------------------------------------------------------------------
242
+ // Deprecations (change 0049).
243
+ //
244
+ // The surface has a hard 40-line budget precisely so that adding a command
245
+ // forces the question "what comes off?" — and for two releases the answer was
246
+ // "nothing", while the budget was met by compressing a whole moment onto one
247
+ // line (audit finding F23). These are the first two removals, and both are
248
+ // MERGES: the surviving command already does everything the retired one did,
249
+ // which is why they can go without waiting for usage evidence. Usage counts
250
+ // can tell you a command is unloved; only redundancy can tell you it is
251
+ // unnecessary, and an unloved command that does something nothing else does
252
+ // has to stay.
253
+ //
254
+ // A deprecated operation keeps working, warns once on the way through, and
255
+ // leaves the surface block — an agent should not reach for it, and the block
256
+ // is the list of things to reach for. Removal is a later, separate change.
257
+ export const DEPRECATED = Object.freeze({
258
+ "analyze": {
259
+ since: "0.17.0",
260
+ use: "doctrina change check",
261
+ why: "check reports the same structural checks first, then the ops dry-run and the archive-gate preview — it answers 'would the close pass?', so it also exits 1 while a Verification box is open; `change apply` still refuses what analyze refused",
262
+ },
263
+ "report": {
264
+ since: "0.17.0",
265
+ use: "doctrina status --view report",
266
+ why: "status renders the same digest from the same collector; --agent-changelog is status --view agent-changelog",
267
+ },
268
+ "skill sync": {
269
+ since: "0.17.0",
270
+ use: "doctrina index rebuild",
271
+ why: "the rebuild registers every skill and mirrors its description, keeping a written one over a scaffold placeholder",
272
+ },
273
+ "templates check": {
274
+ since: "0.17.0",
275
+ use: "doctrina upgrade",
276
+ why: "the upgrade preview reports every template finding, each with its fix, and exits 1 while one is left",
277
+ },
278
+ "templates update": {
279
+ since: "0.17.0",
280
+ use: "doctrina upgrade --write",
281
+ why: "the upgrade's first step is this update, previewed without --write and applied with it",
282
+ },
283
+ });
284
+
285
+ // A deprecated operation that has since been REMOVED. Typing the old name
286
+ // answers with its replacement instead of "unknown command, did you mean
287
+ // ...?" — whoever still has it in a script or a habit learns the one thing
288
+ // they need. Usage class: the fix is the invocation (ADR 0018).
289
+ export const REMOVED = Object.freeze({
290
+ "constitution": { since: "0.17.0", use: "doctrina prime --rules" },
291
+ "change diff": { since: "0.17.0", use: "doctrina change check --verbose" },
292
+ });
293
+
294
+ /** The removal record for an invocation, or null. */
295
+ export function removalFor(argv) {
296
+ const words = argv.filter((a) => !a.startsWith("-"));
297
+ return REMOVED[words.slice(0, 2).join(" ")] ?? REMOVED[words[0] ?? ""] ?? null;
298
+ }
299
+
300
+ /** The deprecation record for an invocation, or null. */
301
+ export function deprecationFor(argv) {
302
+ const words = argv.filter((a) => !a.startsWith("-"));
303
+ const two = words.slice(0, 2).join(" ");
304
+ const one = words[0] ?? "";
305
+ return DEPRECATED[two] ?? DEPRECATED[one] ?? null;
306
+ }
307
+
202
308
  // The declared size budget for the generated block. A surface that cannot
203
309
  // describe itself in this many lines is too large — the answer is to cut
204
310
  // commands (M8), never to raise the number.
@@ -219,8 +325,13 @@ const COMPACT_MOMENTS = new Set(["Maintain"]);
219
325
  // a name index never told it. Same shape `context` prints for skills —
220
326
  // description plus `when:`, body on demand.
221
327
  export function surfaceMarkdown() {
328
+ // A deprecated operation stays in the catalog — `--help` and the shell
329
+ // completions must still know the name someone types — but leaves this
330
+ // block: it is the list of commands to REACH FOR, and pointing an agent at
331
+ // a name that warns when used is worse than not listing it.
222
332
  const subsByCmd = new Map();
223
333
  for (const [op] of OPERATIONS) {
334
+ if (DEPRECATED[op]) continue;
224
335
  const [cmd, sub] = op.split(" ");
225
336
  if (!subsByCmd.has(cmd)) subsByCmd.set(cmd, []);
226
337
  if (sub) subsByCmd.get(cmd).push(sub);
@@ -242,7 +353,7 @@ export function surfaceMarkdown() {
242
353
  "canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.",
243
354
  ];
244
355
  for (const moment of MOMENTS) {
245
- const cmds = COMMAND_NAMES.filter((n) => COMMAND_META[n]?.moment === moment);
356
+ const cmds = COMMAND_NAMES.filter((n) => COMMAND_META[n]?.moment === moment && !DEPRECATED[n]);
246
357
  if (cmds.length === 0) continue;
247
358
  if (COMPACT_MOMENTS.has(moment)) {
248
359
  // Each name still carries the `doctrina ` prefix: the compact line is
@@ -405,6 +516,20 @@ export const AGENT_CHANGELOG = {
405
516
  // Each entry lists only ITS OWN delta. The series carries the rest
406
517
  // forward (see agentChangelogEntries), so a patch never has to restate
407
518
  // the minor that introduced the commands, and never silently erases it.
519
+ "0.17.0": [
520
+ "Preview a close with `doctrina change check <id>` (it exits 1 while a Verification box is open), then run `doctrina close <id>` — do not run `analyze`, `change apply` or `change archive` by hand; a chore closes the same way.",
521
+ "Deprecated names still run with a notice — use the replacement: `analyze` → `change check`, `report` → `status --view report`, `skill sync` → `index rebuild`, `templates check|update` → `upgrade [--write]`. `constitution` and `change diff` are removed (exit 2).",
522
+ "`doctrina upgrade` now reports an installed slash command that still teaches a retired command; run the fix it prints (`doctrina adapter add <agent> --force`).",
523
+ "`doctrina work \"<prompt>\" --design` also scaffolds design.md — no need to fall back to `change new` for a change with non-trivial choices.",
524
+ "A new tasks.md has no Closing steps — the close applies, archives and indexes. `intake --converted` refuses while `validate` reports an error; a change reference is its folder name, never a path (exit 2).",
525
+ ],
526
+ "0.16.0": [
527
+ "A reference or path that does not resolve now answers exit 2 (USAGE) everywhere — `clarify`, `show`, `why`, `analyze`, `context`, `change check`, `spec set`, `decision accept|scope`. Correct the invocation; retrying it unchanged never succeeds.",
528
+ "The `--json` envelope names the OPERATION in `command` and carries arguments in `args` — branch on those two, not on a joined string. An undeclared flag now answers with `{ok: false, exit_code: 2}` instead of an empty stdout.",
529
+ "`analyze` executes a MODIFIED delta's `ops` block, so `analyze`, `change apply` and `close` all refuse the same unappliable block. Fix the ops before closing; a delta with no ops block is still a legitimate manual merge.",
530
+ "`validate` refuses more: an active spec declaring no acceptance criteria, an empty artifact, a scaffold section left unwritten, a reference that resolves to nothing. Write the criteria before flipping a spec to `active`.",
531
+ "`change new <id>` requires the `NNNN-slug` shape (lowercase letters, digits, hyphens) and writes nothing otherwise — `doctrina work \"<prompt>\"` derives one for you.",
532
+ ],
408
533
  "0.15.1": [
409
534
  "Exporting a variable under a name that is not its source's is routine — declare it in the Wiring row's Origin cell as `<origin>:<source>` (e.g. `secrets:NPM_TOKEN`) and RT02 stays silent until either side moves.",
410
535
  ],
@@ -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
+ }