doctrina-cli 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -199
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +7 -75
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +121 -160
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +109 -6
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +210 -24
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +238 -11
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/contract.md.template +7 -0
  82. package/templates/hooks/pre-commit.sample +6 -2
  83. package/templates/playbooks/bootstrap.md.template +43 -0
  84. package/templates/playbooks/chore.md.template +19 -0
  85. package/templates/playbooks/work.md.template +76 -0
@@ -20,7 +20,7 @@ export const COMMAND_NAMES = [
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
@@ -93,6 +93,7 @@ export const OPERATIONS = [
93
93
  ["metrics", "Local git-derived adoption metrics (no network)"],
94
94
  ["report", "Markdown digest for a period: changes, gates, git summary"],
95
95
  ["completion", "Print bash/zsh/pwsh completions (generated from the catalog)"],
96
+ ["ci", "Emit the CI pipeline for the declared gate sequence (--emit github)"],
96
97
  ["upgrade", "Bring an existing project up to the installed CLI (--write applies)"],
97
98
  ];
98
99
 
@@ -118,7 +119,7 @@ export const COMMAND_META = {
118
119
  intake: { moment: "Bootstrap", when: "you have a full project description and no specs yet", purpose: "store the intent and print the bootstrap playbook" },
119
120
  adapter: { moment: "Bootstrap", when: "adding or removing an agent's pointer files", purpose: "install/remove agent adapters (additive)" },
120
121
 
121
- prime: { moment: "Orient", when: "at the START of every session", purpose: "gates, standing rules, and open work in one read" },
122
+ 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
123
  status: { moment: "Orient", when: "you need the health of the tree at a glance", purpose: "index, coverage, trace, and artifact counts" },
123
124
  next: { moment: "Orient", when: "you finished something and do not know what follows", purpose: "the recommended next workflow action" },
124
125
  context: { moment: "Orient", when: "before working on any task, to load the right files", purpose: "the read pack, assembled to fit a token budget" },
@@ -131,7 +132,7 @@ export const COMMAND_META = {
131
132
  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
133
  work: { moment: "Change", when: "a request arrives that changes behaviour (triage says PRODUCT)", purpose: "scaffold a change and print the playbook to execute" },
133
134
  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" },
135
+ change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "new / apply / archive / check (--verbose) / tick / abandon" },
135
136
  decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
136
137
  contract: { moment: "Change", when: "the change touches ports, env vars, or public endpoints", purpose: "own and verify the integration surface" },
137
138
  intent: { moment: "Change", when: "new product intent appears after the intake", purpose: "append and list product intent anchors" },
@@ -155,6 +156,7 @@ export const COMMAND_META = {
155
156
  metrics: { moment: "Maintain", when: "reporting adoption over a period", purpose: "local git-derived adoption metrics" },
156
157
  report: { moment: "Maintain", when: "summarising a period for a human", purpose: "a Markdown digest of changes, gates, and git" },
157
158
  completion: { moment: "Maintain", when: "setting up a human's shell", purpose: "print shell completions from the catalog" },
159
+ ci: { moment: "Maintain", when: "the declared gate sequence changed and CI must follow", purpose: "emit the CI pipeline from the gate declaration" },
158
160
  };
159
161
 
160
162
  // The order moments appear in the generated block.
@@ -195,10 +197,48 @@ const SURFACE_HINTS = {
195
197
  clarify: "--all (--lang pt|en)",
196
198
  close: "<id...>",
197
199
  upgrade: "--write",
200
+ ci: "--emit github",
198
201
  prime: "(session start)",
199
202
  handoff: "(before compaction/handover)",
200
203
  };
201
204
 
205
+ // ---------------------------------------------------------------------------
206
+ // Deprecations (change 0049).
207
+ //
208
+ // The surface has a hard 40-line budget precisely so that adding a command
209
+ // forces the question "what comes off?" — and for two releases the answer was
210
+ // "nothing", while the budget was met by compressing a whole moment onto one
211
+ // line (audit finding F23). These are the first two removals, and both are
212
+ // MERGES: the surviving command already does everything the retired one did,
213
+ // which is why they can go without waiting for usage evidence. Usage counts
214
+ // can tell you a command is unloved; only redundancy can tell you it is
215
+ // unnecessary, and an unloved command that does something nothing else does
216
+ // has to stay.
217
+ //
218
+ // A deprecated operation keeps working, warns once on the way through, and
219
+ // leaves the surface block — an agent should not reach for it, and the block
220
+ // is the list of things to reach for. Removal is a later, separate change.
221
+ export const DEPRECATED = Object.freeze({
222
+ "constitution": {
223
+ since: "0.16.0",
224
+ use: "doctrina prime --rules",
225
+ why: "prime renders the same standing-rules view, from the same collection",
226
+ },
227
+ "change diff": {
228
+ since: "0.16.0",
229
+ use: "doctrina change check --verbose",
230
+ why: "check runs every ops block against the target spec and now prints the same per-delta preview",
231
+ },
232
+ });
233
+
234
+ /** The deprecation record for an invocation, or null. */
235
+ export function deprecationFor(argv) {
236
+ const words = argv.filter((a) => !a.startsWith("-"));
237
+ const two = words.slice(0, 2).join(" ");
238
+ const one = words[0] ?? "";
239
+ return DEPRECATED[two] ?? DEPRECATED[one] ?? null;
240
+ }
241
+
202
242
  // The declared size budget for the generated block. A surface that cannot
203
243
  // describe itself in this many lines is too large — the answer is to cut
204
244
  // commands (M8), never to raise the number.
@@ -219,8 +259,13 @@ const COMPACT_MOMENTS = new Set(["Maintain"]);
219
259
  // a name index never told it. Same shape `context` prints for skills —
220
260
  // description plus `when:`, body on demand.
221
261
  export function surfaceMarkdown() {
262
+ // A deprecated operation stays in the catalog — `--help` and the shell
263
+ // completions must still know the name someone types — but leaves this
264
+ // block: it is the list of commands to REACH FOR, and pointing an agent at
265
+ // a name that warns when used is worse than not listing it.
222
266
  const subsByCmd = new Map();
223
267
  for (const [op] of OPERATIONS) {
268
+ if (DEPRECATED[op]) continue;
224
269
  const [cmd, sub] = op.split(" ");
225
270
  if (!subsByCmd.has(cmd)) subsByCmd.set(cmd, []);
226
271
  if (sub) subsByCmd.get(cmd).push(sub);
@@ -242,7 +287,7 @@ export function surfaceMarkdown() {
242
287
  "canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.",
243
288
  ];
244
289
  for (const moment of MOMENTS) {
245
- const cmds = COMMAND_NAMES.filter((n) => COMMAND_META[n]?.moment === moment);
290
+ const cmds = COMMAND_NAMES.filter((n) => COMMAND_META[n]?.moment === moment && !DEPRECATED[n]);
246
291
  if (cmds.length === 0) continue;
247
292
  if (COMPACT_MOMENTS.has(moment)) {
248
293
  // Each name still carries the `doctrina ` prefix: the compact line is
@@ -402,6 +447,19 @@ export const AGENT_CHANGELOG_END = "<!-- doctrina:changed:end -->";
402
447
  // introduced it. Only entries that alter what an agent should DO belong
403
448
  // here — a bug fix nobody's behaviour depends on does not.
404
449
  export const AGENT_CHANGELOG = {
450
+ // Each entry lists only ITS OWN delta. The series carries the rest
451
+ // forward (see agentChangelogEntries), so a patch never has to restate
452
+ // the minor that introduced the commands, and never silently erases it.
453
+ "0.16.0": [
454
+ "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.",
455
+ "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.",
456
+ "`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.",
457
+ "`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`.",
458
+ "`change new <id>` requires the `NNNN-slug` shape (lowercase letters, digits, hyphens) and writes nothing otherwise — `doctrina work \"<prompt>\"` derives one for you.",
459
+ ],
460
+ "0.15.1": [
461
+ "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.",
462
+ ],
405
463
  "0.15.0": [
406
464
  "`doctrina triage \"<prompt>\"` — classify a request as PRODUCT / RUNTIME / CHORE BEFORE scaffolding. `work` now holds a runtime-shaped prompt with exit 3 and points here; `--force` opens the change anyway.",
407
465
  "`contract check` now holds the declared wiring to the implementation: a `vars`/`secrets` variable no workflow exports, a default an empty CI value never triggers, an unvalidated enum, a selector matching zero targets (RT01-RT05).",
@@ -420,9 +478,54 @@ export const AGENT_CHANGELOG = {
420
478
 
421
479
  // The block written into AGENTS.md for a given version. Empty string when
422
480
  // that version has no agent-visible changes.
481
+ // How many bullets the block may carry. Not a style preference: AGENTS.md
482
+ // is always-loaded context under a hard 150-line budget, and at the current
483
+ // template size five bullets is exactly what fits. Raising this means
484
+ // cutting elsewhere — see the `agents-md-lines` budget in the contract.
485
+ export const AGENT_CHANGELOG_MAX_BULLETS = 5;
486
+
487
+ /**
488
+ * The bullets an agent arriving at `version` needs, newest first.
489
+ *
490
+ * Keyed per version, but rendered per MINOR SERIES. The block REPLACES its
491
+ * predecessor in AGENTS.md rather than accumulating, so rendering only the
492
+ * current version's own entry meant every patch silently erased the minor
493
+ * that introduced the commands: an agent upgrading 0.14.0 -> 0.15.1 would
494
+ * read one bug fix and never learn `triage` exists. The alternative —
495
+ * making each patch author restate the whole minor by hand — worked once
496
+ * and does not scale, because the restating is invisible and the third
497
+ * patch quietly drops a line to fit the cap.
498
+ *
499
+ * So the series carries itself, newest first, and the cap truncates the
500
+ * OLDEST. A fact that must outlive its series does not belong here: it
501
+ * belongs in the surface block or the docs, which is the honest place for
502
+ * something permanent.
503
+ */
504
+ export function agentChangelogEntries(version) {
505
+ const series = String(version).split(".").slice(0, 2).join(".");
506
+ const versions = Object.keys(AGENT_CHANGELOG)
507
+ .filter((v) => v.split(".").slice(0, 2).join(".") === series && compareVersions(v, version) <= 0)
508
+ .sort((a, b) => compareVersions(b, a));
509
+ const out = [];
510
+ for (const v of versions) out.push(...(AGENT_CHANGELOG[v] ?? []));
511
+ return out.slice(0, AGENT_CHANGELOG_MAX_BULLETS);
512
+ }
513
+
514
+ // Numeric semver comparison — "0.15.10" must sort above "0.15.9", which a
515
+ // string compare gets wrong.
516
+ function compareVersions(a, b) {
517
+ const pa = String(a).split(".").map((n) => Number.parseInt(n, 10) || 0);
518
+ const pb = String(b).split(".").map((n) => Number.parseInt(n, 10) || 0);
519
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
520
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
521
+ if (d !== 0) return d;
522
+ }
523
+ return 0;
524
+ }
525
+
423
526
  export function agentChangelogMarkdown(version) {
424
- const entries = AGENT_CHANGELOG[version];
425
- if (!entries || entries.length === 0) return "";
527
+ const entries = agentChangelogEntries(version);
528
+ if (entries.length === 0) return "";
426
529
  return [
427
530
  `## What changed in ${version}`,
428
531
  "",
@@ -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
+ }