doctrina-cli 0.13.0 → 0.15.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 (71) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -2
  3. package/src/commands/adapter.js +205 -0
  4. package/src/commands/analyze.js +120 -17
  5. package/src/commands/change.js +100 -42
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +57 -2
  8. package/src/commands/completion.js +6 -0
  9. package/src/commands/constitution.js +8 -1
  10. package/src/commands/context.js +391 -58
  11. package/src/commands/contract.js +76 -43
  12. package/src/commands/coverage.js +126 -12
  13. package/src/commands/decision.js +172 -40
  14. package/src/commands/doctor.js +92 -11
  15. package/src/commands/handoff.js +8 -1
  16. package/src/commands/hooks.js +6 -0
  17. package/src/commands/index-rebuild.js +8 -1
  18. package/src/commands/init.js +139 -6
  19. package/src/commands/intake.js +11 -2
  20. package/src/commands/intent.js +8 -1
  21. package/src/commands/metrics.js +80 -7
  22. package/src/commands/next.js +38 -3
  23. package/src/commands/prime.js +8 -1
  24. package/src/commands/report.js +8 -1
  25. package/src/commands/review.js +8 -1
  26. package/src/commands/search.js +8 -1
  27. package/src/commands/show.js +8 -1
  28. package/src/commands/skill.js +201 -8
  29. package/src/commands/spec.js +13 -6
  30. package/src/commands/status.js +14 -2
  31. package/src/commands/templates.js +193 -59
  32. package/src/commands/trace.js +15 -3
  33. package/src/commands/triage.js +248 -0
  34. package/src/commands/upgrade.js +8 -1
  35. package/src/commands/validate.js +148 -5
  36. package/src/commands/verify.js +126 -5
  37. package/src/commands/watch.js +8 -1
  38. package/src/commands/why.js +10 -24
  39. package/src/commands/work.js +44 -3
  40. package/src/index.js +61 -6
  41. package/src/lib/adapters.js +140 -0
  42. package/src/lib/adr-guard.js +1 -0
  43. package/src/lib/args.js +10 -0
  44. package/src/lib/clarity.js +1 -0
  45. package/src/lib/colors.js +1 -0
  46. package/src/lib/commands.js +252 -27
  47. package/src/lib/criteria.js +1 -0
  48. package/src/lib/dates.js +1 -0
  49. package/src/lib/diff.js +1 -0
  50. package/src/lib/doc-model.js +287 -0
  51. package/src/lib/docs-impact.js +167 -0
  52. package/src/lib/ears.js +1 -0
  53. package/src/lib/exit-codes.js +103 -0
  54. package/src/lib/flag-catalog.js +106 -0
  55. package/src/lib/fs-ops.js +1 -0
  56. package/src/lib/gates.js +123 -0
  57. package/src/lib/git.js +97 -0
  58. package/src/lib/index-json.js +14 -6
  59. package/src/lib/json-out.js +83 -0
  60. package/src/lib/pipeline.js +161 -0
  61. package/src/lib/prompt.js +18 -1
  62. package/src/lib/runtime.js +683 -0
  63. package/src/lib/scan.js +81 -33
  64. package/src/lib/spec-ops.js +94 -9
  65. package/src/lib/suggest.js +1 -0
  66. package/src/lib/templates.js +89 -0
  67. package/src/lib/usage.js +89 -0
  68. package/src/lib/version.js +1 -0
  69. package/templates/AGENTS.md.template +59 -53
  70. package/templates/contract.md.template +74 -3
  71. package/templates/spec.md.template +22 -0
@@ -0,0 +1,287 @@
1
+ // @ts-check
2
+ // The one owner of the on-disk artifact grammar (audit item M3).
3
+ //
4
+ // `.doctrina/` is Doctrina's public API, and its specification had ten
5
+ // homes. The ADR `Status:` grammar existed as ten regex literals across
6
+ // three files, and they were NOT equivalent — some captured, one only
7
+ // tested, two were anchored to end-of-line and so silently failed on a
8
+ // trailing space. Section extraction was reimplemented six times under six
9
+ // names: `extractSection` (twice), `sectionOf`, `sectionParagraph`,
10
+ // `productSection`, and the sections map in `spec-ops`.
11
+ //
12
+ // This violates the framework's own design principle #1 — "single
13
+ // ownership of every fact; no information has two homes."
14
+ //
15
+ // The contract here:
16
+ //
17
+ // LENIENT ON READ. `- **Status**: x` and `**Status:** x` both parse, and
18
+ // a non-canonical form is REPORTED (so `validate --fix` can repair it)
19
+ // rather than silently accepted or silently missed.
20
+ // STRICT ON WRITE. One canonical form per artifact kind, always.
21
+
22
+ // Artifact kinds and the header form each one canonically uses. Specs and
23
+ // contracts use bare bold; ADRs, proposals, and the intake use list items.
24
+ // Getting this backwards was a documented, recurring authoring mistake, so
25
+ // the CLI now decides it rather than the author remembering.
26
+ export const HEADER_STYLE = Object.freeze({
27
+ BARE: "bare", // **Status:** active
28
+ LIST: "list", // - **Status:** accepted
29
+ });
30
+
31
+ export const ARTIFACT_KIND = Object.freeze({
32
+ SPEC: "spec",
33
+ CONTRACT: "contract",
34
+ DECISION: "decision",
35
+ PROPOSAL: "proposal",
36
+ INTAKE: "intake",
37
+ PRODUCT: "product",
38
+ UNKNOWN: "unknown",
39
+ });
40
+
41
+ const STYLE_BY_KIND = {
42
+ [ARTIFACT_KIND.SPEC]: HEADER_STYLE.BARE,
43
+ [ARTIFACT_KIND.CONTRACT]: HEADER_STYLE.BARE,
44
+ [ARTIFACT_KIND.PRODUCT]: HEADER_STYLE.BARE,
45
+ [ARTIFACT_KIND.DECISION]: HEADER_STYLE.LIST,
46
+ [ARTIFACT_KIND.PROPOSAL]: HEADER_STYLE.LIST,
47
+ [ARTIFACT_KIND.INTAKE]: HEADER_STYLE.LIST,
48
+ [ARTIFACT_KIND.UNKNOWN]: HEADER_STYLE.BARE,
49
+ };
50
+
51
+ export function canonicalStyle(kind) {
52
+ return STYLE_BY_KIND[kind] ?? HEADER_STYLE.BARE;
53
+ }
54
+
55
+ // Infer the artifact kind from its path. Deterministic; never reads content.
56
+ export function kindFromPath(relPathPosix) {
57
+ const p = String(relPathPosix).replace(/\\/g, "/");
58
+ if (/\/specs\/[^/]+\/spec\.md$/.test(p)) return ARTIFACT_KIND.SPEC;
59
+ if (/\/contracts\/[^/]+\.md$/.test(p)) return ARTIFACT_KIND.CONTRACT;
60
+ if (/\/decisions\/\d{4}-.*\.md$/.test(p)) return ARTIFACT_KIND.DECISION;
61
+ if (/\/changes\/.*\/proposal\.md$/.test(p)) return ARTIFACT_KIND.PROPOSAL;
62
+ if (/\/intake\.md$/.test(p)) return ARTIFACT_KIND.INTAKE;
63
+ if (/\/product\.md$/.test(p)) return ARTIFACT_KIND.PRODUCT;
64
+ return ARTIFACT_KIND.UNKNOWN;
65
+ }
66
+
67
+ function escapeRe(s) {
68
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
69
+ }
70
+
71
+ // THE header grammar. One definition.
72
+ //
73
+ // Lenient by construction: the leading "- " is optional, the colon may sit
74
+ // inside or outside the bold span (`**Status:**` or `**Status**:`), and the
75
+ // value runs to end of line with surrounding space trimmed by the caller.
76
+ // Anchoring to `$` without allowing trailing whitespace is what made two of
77
+ // the old copies fail on files saved with a trailing space.
78
+ // A metadata header REQUIRES a colon, inside or outside the bold span.
79
+ // Without that, bold text used as a pseudo-heading in prose — the
80
+ // `**Positive**` / `**Negative**` / `**Neutral**` blocks every ADR carries —
81
+ // parses as a header and floods the conformance report.
82
+ function headerPattern(name) {
83
+ const n = escapeRe(name);
84
+ return new RegExp(`^([ \\t]*)(-[ \\t]+)?\\*\\*${n}[ \\t]*(?::\\*\\*|\\*\\*[ \\t]*:)[ \\t]*(.*?)[ \\t]*$`, "m");
85
+ }
86
+
87
+ // Read one header. Returns { value, style, conforming, line } or null.
88
+ /**
89
+ * @returns {{ value: string, style: string, indent: string, raw: string,
90
+ * line: number, conforming: boolean } | null}
91
+ */
92
+ export function readHeader(text, name) {
93
+ const m = headerPattern(name).exec(String(text));
94
+ if (!m) return null;
95
+ const [raw, indent, dash, value] = m;
96
+ const style = dash ? HEADER_STYLE.LIST : HEADER_STYLE.BARE;
97
+ // Canonical is exactly `**Name:** value` (or `- **Name:** value`).
98
+ const canonical = `${dash ? "- " : ""}**${name}:** ${value}`.replace(/\s+$/, "");
99
+ return {
100
+ value: value.trim(),
101
+ style,
102
+ indent,
103
+ raw,
104
+ line: text.slice(0, m.index).split("\n").length,
105
+ conforming: raw.trim() === canonical.trim(),
106
+ };
107
+ }
108
+
109
+ // The value only, or null. The replacement for specHeader/listHeader and
110
+ // every ad-hoc `match(/^-\s+\*\*Status:\*\*.../)`.
111
+ export function getHeader(text, name) {
112
+ return readHeader(text, name)?.value ?? null;
113
+ }
114
+
115
+ // Write a header in the canonical form for `style`, preserving the style
116
+ // already present when the header exists. Returns the new text, or null
117
+ // when the header is absent — a set that silently does nothing is exactly
118
+ // the drift this prevents.
119
+ /**
120
+ * @param {string} text
121
+ * @param {string} name
122
+ * @param {string} value
123
+ * @param {{ style?: string }} [opts] Force a header style; defaults to the
124
+ * style already present, so a set never silently reformats the artifact.
125
+ */
126
+ export function setHeader(text, name, value, opts = {}) {
127
+ const { style } = opts;
128
+ const existing = readHeader(text, name);
129
+ if (!existing) return null;
130
+ const useStyle = style ?? existing.style;
131
+ const prefix = useStyle === HEADER_STYLE.LIST ? "- " : "";
132
+ const replacement = `${existing.indent}${prefix}**${name}:** ${value}`.replace(/\s+$/, "");
133
+ return String(text).replace(headerPattern(name), replacement);
134
+ }
135
+
136
+ // The PREAMBLE: everything before the first `## ` section. Metadata headers
137
+ // live there in every Doctrina artifact, and only there. Bold text ending in
138
+ // a colon further down is a prose lead-in ("**Standing constraints lived in
139
+ // agent memory:** ..."), not a header — scoping conformance to the preamble
140
+ // is what tells the two apart without guessing.
141
+ export function preamble(text) {
142
+ const s = String(text);
143
+ const m = /^##\s+/m.exec(s);
144
+ return m ? s.slice(0, m.index) : s;
145
+ }
146
+
147
+ // Every header in the document's preamble, in order, with conformance
148
+ // recorded.
149
+ export function readAllHeaders(fullText) {
150
+ const text = preamble(fullText);
151
+ const out = [];
152
+ const re = /^([ \t]*)(-[ \t]+)?\*\*([A-Za-z][A-Za-z .-]*?)[ \t]*(?::\*\*|\*\*[ \t]*:)[ \t]*(.*?)[ \t]*$/gm;
153
+ let m;
154
+ while ((m = re.exec(String(text)))) {
155
+ const [raw, indent, dash, name, value] = m;
156
+ const style = dash ? HEADER_STYLE.LIST : HEADER_STYLE.BARE;
157
+ const canonical = `${indent}${dash ? "- " : ""}**${name}:** ${value}`.replace(/\s+$/, "");
158
+ out.push({
159
+ name, value: value.trim(), style, indent,
160
+ line: String(text).slice(0, m.index).split("\n").length,
161
+ conforming: raw.replace(/\s+$/, "") === canonical,
162
+ });
163
+ }
164
+ return out;
165
+ }
166
+
167
+ // Headers whose written form is recognised but not canonical, optionally
168
+ // also flagging the wrong style for a known artifact kind. This is what
169
+ // `validate` reports and `validate --fix` repairs.
170
+ /** @param {string} text @param {string} [kind] */
171
+ export function nonConformingHeaders(text, kind = ARTIFACT_KIND.UNKNOWN) {
172
+ const want = canonicalStyle(kind);
173
+ const out = [];
174
+ for (const h of readAllHeaders(text)) {
175
+ const wrongStyle = kind !== ARTIFACT_KIND.UNKNOWN && h.style !== want;
176
+ if (!h.conforming || wrongStyle) {
177
+ out.push({ ...h, wrongStyle, expectedStyle: want });
178
+ }
179
+ }
180
+ return out;
181
+ }
182
+
183
+ // Rewrite every recognised header into the canonical form for `kind`.
184
+ // Content is never touched — only the header's own punctuation and, when
185
+ // the kind is known, its style.
186
+ /** @param {string} text @param {string} [kind] */
187
+ export function repairHeaders(text, kind = ARTIFACT_KIND.UNKNOWN) {
188
+ const want = canonicalStyle(kind);
189
+ const lines = String(text).split("\n");
190
+ // Repair only the PREAMBLE, and only lines that carry a colon — the same
191
+ // two constraints reading uses. Without them this rewrote ADR prose:
192
+ // `**Positive**` became `- **Positive:**`, and a bulleted definition list
193
+ // `- **dropped intent** — ...` became `- **dropped intent:** — ...`.
194
+ // A repair that edits prose is worse than the drift it fixes.
195
+ const firstSection = lines.findIndex((l) => /^##\s+/.test(l));
196
+ const limit = firstSection < 0 ? lines.length : firstSection;
197
+ let repaired = 0;
198
+ for (let i = 0; i < limit; i++) {
199
+ // Match against the line without its carriage return. On a CRLF
200
+ // checkout the trailing \r is not [ \t], so anchoring to $ silently
201
+ // matched nothing and repair became a no-op on exactly the files most
202
+ // likely to need it.
203
+ const line = lines[i].replace(/\r$/, "");
204
+ const eol = lines[i].endsWith("\r") ? "\r" : "";
205
+ const m = /^([ \t]*)(-[ \t]+)?\*\*([A-Za-z][A-Za-z .-]*?)[ \t]*(?::\*\*|\*\*[ \t]*:)[ \t]*(.*?)[ \t]*$/.exec(line);
206
+ if (!m) continue;
207
+ const [, indent, dash, name, value] = m;
208
+ const style = kind === ARTIFACT_KIND.UNKNOWN ? (dash ? HEADER_STYLE.LIST : HEADER_STYLE.BARE) : want;
209
+ const canonical = `${indent}${style === HEADER_STYLE.LIST ? "- " : ""}**${name}:** ${value}`.replace(/[ \t]+$/, "");
210
+ if (line.replace(/[ \t]+$/, "") !== canonical) {
211
+ // Preserve the file's existing line ending: repairing a header must
212
+ // not rewrite every line of a CRLF file as a side effect.
213
+ lines[i] = canonical + eol;
214
+ repaired += 1;
215
+ }
216
+ }
217
+ return { text: lines.join("\n"), repaired };
218
+ }
219
+
220
+ // ---------------------------------------------------------------- sections
221
+
222
+ // The body of a `## <name>` section: lines after the heading, up to the
223
+ // next heading of the same or higher level. Replaces six near-identical
224
+ // helpers that disagreed about level handling and trailing whitespace.
225
+ export function getSection(text, name, { level = 2 } = {}) {
226
+ const lines = String(text).split(/\r?\n/);
227
+ const head = new RegExp(`^#{${level}}\\s+${escapeRe(name)}\\b`, "i");
228
+ const boundary = new RegExp(`^#{1,${level}}\\s+`);
229
+ const out = [];
230
+ let inSection = false;
231
+ for (const line of lines) {
232
+ if (inSection && boundary.test(line)) break;
233
+ if (!inSection && head.test(line)) { inSection = true; continue; }
234
+ if (inSection) out.push(line);
235
+ }
236
+ return inSection ? out.join("\n") : "";
237
+ }
238
+
239
+ export function hasSection(text, name, { level = 2 } = {}) {
240
+ return new RegExp(`^#{${level}}\\s+${escapeRe(name)}\\b`, "im").test(String(text));
241
+ }
242
+
243
+ // The first non-empty, non-comment paragraph of a section — what `why` and
244
+ // `skill suggest` actually wanted when they each rolled their own.
245
+ export function getSectionParagraph(text, name, { level = 2 } = {}) {
246
+ const body = getSection(text, name, { level });
247
+ const para = [];
248
+ for (const line of body.split("\n")) {
249
+ const trimmed = line.trim();
250
+ if (trimmed.startsWith("<!--")) continue;
251
+ if (trimmed === "") {
252
+ if (para.length > 0) break;
253
+ continue;
254
+ }
255
+ para.push(trimmed);
256
+ }
257
+ return para.join(" ");
258
+ }
259
+
260
+ // Every `## ` heading in document order.
261
+ export function listSections(text, { level = 2 } = {}) {
262
+ const re = new RegExp(`^#{${level}}\\s+(.+)$`, "gm");
263
+ const out = [];
264
+ let m;
265
+ while ((m = re.exec(String(text)))) out.push(m[1].trim());
266
+ return out;
267
+ }
268
+
269
+ // The document's H1 title, or null.
270
+ export function getTitle(text) {
271
+ const m = /^#\s+(.+)$/m.exec(String(text));
272
+ return m ? m[1].trim() : null;
273
+ }
274
+
275
+ // One parse of an artifact: title, headers, section names. The shape
276
+ // `parseArtifact` promises, so callers stop re-deriving it.
277
+ export function parseArtifact(text, { kind = ARTIFACT_KIND.UNKNOWN } = {}) {
278
+ const headers = readAllHeaders(text);
279
+ return {
280
+ kind,
281
+ title: getTitle(text),
282
+ headers,
283
+ headerMap: new Map(headers.map((h) => [h.name, h.value])),
284
+ sections: listSections(text),
285
+ nonConforming: nonConformingHeaders(text, kind),
286
+ };
287
+ }
@@ -0,0 +1,167 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { isFile, read, walk } from "./fs-ops.js";
5
+ import { COMMAND_NAMES } from "./commands.js";
6
+ import { locateTemplatesDir } from "./templates.js";
7
+
8
+ // "Docs ship inside the change, never after it" (audit item D2).
9
+ //
10
+ // A docs phase scheduled AFTER the work never happens. So a change that
11
+ // alters a documented surface — a command, a flag, an exit code — must also
12
+ // carry the documentation for it, checked at close time and refusable with
13
+ // --force exactly like the archive verification gate.
14
+ //
15
+ // Both halves are deterministic: what the change says it touches is read
16
+ // from its own artifacts, and whether docs moved is read from git. No
17
+ // language understanding (ADR 0005).
18
+
19
+ const DOC_PATHS = [/^docs\//, /^README\.md$/, /^README\.pt\.md$/];
20
+
21
+ // Non-empty lines of the shipped change templates, so authored content can
22
+ // be told apart from scaffold. Computed once; falls back to an empty set
23
+ // when the templates cannot be located (unusual installs), which only makes
24
+ // the gate more eager, never wrong about a real signal.
25
+ // Ticking a scaffold checkbox rewrites "- [ ] ..." to "- [x] ...", which
26
+ // would otherwise stop the line matching its own template and make every
27
+ // completed change look authored. Compare with the box state normalised.
28
+ function normaliseScaffoldLine(line) {
29
+ return line.trim().replace(/^-\s*\[[ xX]\]/, "- [ ]");
30
+ }
31
+
32
+ let scaffoldCache = null;
33
+ function scaffoldLines() {
34
+ if (scaffoldCache) return scaffoldCache;
35
+ const lines = new Set();
36
+ try {
37
+ const changeTemplates = path.join(locateTemplatesDir(), "change");
38
+ for (const f of walk(changeTemplates)) {
39
+ for (const line of read(f).split(/\r?\n/)) {
40
+ const trimmed = normaliseScaffoldLine(line);
41
+ if (trimmed) lines.add(trimmed);
42
+ }
43
+ }
44
+ } catch { /* no templates dir — treat everything as authored */ }
45
+ scaffoldCache = lines;
46
+ return lines;
47
+ }
48
+
49
+ // Does this change alter something the documentation makes promises about?
50
+ // Read from the change's own artifacts — the deltas it will merge into
51
+ // specs, plus the proposal that states its shape. Returns the list of
52
+ // signals found (empty when the change touches no documented surface).
53
+ export function documentedSurfaceSignals(changeDir) {
54
+ const signals = [];
55
+ const known = new Set(COMMAND_NAMES);
56
+
57
+ // Read only what the AUTHOR wrote. The scaffold's own boilerplate names
58
+ // commands — the proposal's Verification checklist cites `doctrina verify`
59
+ // and `doctrina coverage` — so scanning the raw file made every change look
60
+ // like it touched a documented surface. A gate that fires on everything is
61
+ // a gate that gets ignored, so the template's lines are subtracted first.
62
+ const boilerplate = scaffoldLines();
63
+ const authored = (text) => text
64
+ .split(/\r?\n/)
65
+ .filter((line) => !boilerplate.has(normaliseScaffoldLine(line)))
66
+ .join("\n");
67
+
68
+ const sources = [];
69
+ const proposal = path.join(changeDir, "proposal.md");
70
+ if (isFile(proposal)) sources.push(authored(read(proposal)));
71
+ for (const p of walk(path.join(changeDir, "specs"))) {
72
+ if (p.endsWith("delta.md")) sources.push(authored(read(p)));
73
+ }
74
+ const text = sources.join("\n");
75
+ if (!text.trim()) return signals;
76
+
77
+ // Command references, from code context only, so prose about "the
78
+ // doctrina framework" is not a signal.
79
+ const chunks = [];
80
+ const outside = text.replace(/```[\s\S]*?```/g, (b) => { chunks.push(b); return "\n"; });
81
+ for (const m of outside.matchAll(/`[^`]+`/g)) chunks.push(m[0]);
82
+ const code = chunks.join("\n");
83
+
84
+ const commands = new Set();
85
+ for (const m of code.matchAll(/(?:npx\s+doctrina-cli|doctrina)\s+([a-z][a-z-]+)/g)) {
86
+ if (known.has(m[1])) commands.add(m[1]);
87
+ }
88
+ if (commands.size > 0) {
89
+ signals.push(`commands: ${[...commands].sort().join(", ")}`);
90
+ }
91
+
92
+ const flags = new Set();
93
+ for (const m of code.matchAll(/--([a-z][a-z0-9-]{2,})/g)) flags.add(`--${m[1]}`);
94
+ if (flags.size > 0) {
95
+ signals.push(`flags: ${[...flags].sort().slice(0, 8).join(", ")}`);
96
+ }
97
+
98
+ if (/\bexit\s+code|\bexits?\s+(?:with\s+)?[0-4]\b/i.test(text)) {
99
+ signals.push("exit codes");
100
+ }
101
+
102
+ return signals;
103
+ }
104
+
105
+ // Documentation paths touched by the work in flight. Union of the working
106
+ // tree (uncommitted, the common agent case) and this branch's commits
107
+ // against the default branch (the case where the agent already committed).
108
+ // Returns [] outside a git repository — where the gate cannot see, it does
109
+ // not accuse.
110
+ //
111
+ // KNOWN COARSENESS: this answers "did documentation move in this work?",
112
+ // not "did documentation move FOR THIS CHANGE?". Closing several changes
113
+ // from one uncommitted working tree, docs written for an earlier change
114
+ // satisfy the gate for a later one. The gate is deliberately permissive
115
+ // here — its job is to stop a change shipping with NO documentation at
116
+ // all, and attributing a hunk to a change would need guesswork the rest of
117
+ // the framework refuses to do (ADR 0005).
118
+ export function docsTouched(projectRoot) {
119
+ const git = (args) => {
120
+ const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
121
+ if (r.error || r.status !== 0) return [];
122
+ return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
123
+ };
124
+ const set = new Set();
125
+ for (const f of git(["diff", "--name-only", "HEAD"])) set.add(f);
126
+ for (const f of git(["ls-files", "--others", "--exclude-standard"])) set.add(f);
127
+ for (const base of ["main", "master"]) {
128
+ const mb = git(["merge-base", "HEAD", base]);
129
+ if (mb.length > 0) {
130
+ for (const f of git(["diff", "--name-only", `${mb[0]}..HEAD`])) set.add(f);
131
+ break;
132
+ }
133
+ }
134
+ return [...set]
135
+ .map((f) => f.replace(/\\/g, "/"))
136
+ .filter((f) => DOC_PATHS.some((re) => re.test(f)));
137
+ }
138
+
139
+ export function isGitRepo(projectRoot) {
140
+ const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
141
+ cwd: projectRoot, encoding: "utf8",
142
+ });
143
+ return !r.error && r.status === 0;
144
+ }
145
+
146
+ // The gate itself: { ok, signals, touched, reason }. `ok` is true when the
147
+ // change touches no documented surface, when documentation moved with it,
148
+ // or when git cannot answer.
149
+ export function checkDocsImpact(projectRoot, changeDir) {
150
+ const signals = documentedSurfaceSignals(changeDir);
151
+ if (signals.length === 0) {
152
+ return { ok: true, signals, touched: [], reason: "touches no documented surface" };
153
+ }
154
+ if (!isGitRepo(projectRoot)) {
155
+ return { ok: true, signals, touched: [], reason: "not a git repository — cannot tell what moved" };
156
+ }
157
+ const touched = docsTouched(projectRoot);
158
+ if (touched.length > 0) {
159
+ return { ok: true, signals, touched, reason: `documentation moved with it (${touched.length} file${touched.length === 1 ? "" : "s"})` };
160
+ }
161
+ return {
162
+ ok: false,
163
+ signals,
164
+ touched,
165
+ reason: "alters a documented surface but no docs/ or README change accompanies it",
166
+ };
167
+ }
package/src/lib/ears.js CHANGED
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // Structural EARS checks for capability specs. The clarify command catches
2
3
  // word-level smells; this catches grammar-shape problems per EARS section.
3
4
  // All findings are warnings: specs evolve, validation should not block.
@@ -0,0 +1,103 @@
1
+ // @ts-check
2
+ // The exit-code contract (audit item C7).
3
+ //
4
+ // Code 1 used to mean three different things, and an agent driving the
5
+ // loop could not tell them apart:
6
+ //
7
+ // "your change is not ready" -> iterate and retry
8
+ // "this project is not configured" -> run a setup command first
9
+ // "this machine cannot run this" -> stop
10
+ //
11
+ // All three returned 1. The first means keep working, the second means do
12
+ // something else first, the third means give up — and an autonomous loop
13
+ // that cannot distinguish them either spins on an unfixable failure or
14
+ // abandons a fixable one.
15
+ //
16
+ // This is the smallest module in the CLI and the one a machine consumer
17
+ // depends on most. Every command's exit path maps to a member here.
18
+
19
+ export const EXIT = Object.freeze({
20
+ /** The command did what it was asked. Warnings are allowed. */
21
+ OK: 0,
22
+ /** A gate failed: the WORK is not ready. Fix the work and retry. */
23
+ GATE: 1,
24
+ /** The INVOCATION is wrong: unknown command, missing/malformed argument.
25
+ * Do not retry unchanged. */
26
+ USAGE: 2,
27
+ /** A PRECONDITION is missing: the project is not set up for this yet.
28
+ * Run the named setup command, then retry. */
29
+ PRECONDITION: 3,
30
+ /** The ENVIRONMENT cannot run this: git absent, unreadable tree, and
31
+ * other conditions no amount of retrying fixes. Stop. */
32
+ ENVIRONMENT: 4,
33
+ });
34
+
35
+ // What each code means, for `--help`, the docs, and the tests that assert
36
+ // the mapping. Keyed by numeric code so a consumer can look one up.
37
+ export const EXIT_MEANINGS = Object.freeze({
38
+ [EXIT.OK]: {
39
+ name: "OK",
40
+ summary: "success (warnings allowed)",
41
+ agentAction: "continue",
42
+ },
43
+ [EXIT.GATE]: {
44
+ name: "GATE",
45
+ summary: "a gate failed — the work is not ready",
46
+ agentAction: "fix the work, then retry the same command",
47
+ },
48
+ [EXIT.USAGE]: {
49
+ name: "USAGE",
50
+ summary: "the invocation is wrong — unknown command or bad argument",
51
+ agentAction: "do not retry unchanged; correct the invocation",
52
+ },
53
+ [EXIT.PRECONDITION]: {
54
+ name: "PRECONDITION",
55
+ summary: "a precondition is missing — the project is not set up for this",
56
+ agentAction: "run the setup command named in the error, then retry",
57
+ },
58
+ [EXIT.ENVIRONMENT]: {
59
+ name: "ENVIRONMENT",
60
+ summary: "the environment cannot run this command",
61
+ agentAction: "stop; retrying will not help",
62
+ },
63
+ });
64
+
65
+ // The block `doctrina --help` prints, generated so help and contract
66
+ // cannot drift.
67
+ export function exitCodeHelp() {
68
+ return Object.entries(EXIT_MEANINGS)
69
+ .map(([code, m]) => ` ${code} ${m.summary}`)
70
+ .join("\n");
71
+ }
72
+
73
+ // A precondition failure, thrown by a command whose project/config is not
74
+ // ready. The entrypoint maps it to EXIT.PRECONDITION and prints the setup
75
+ // command, so every "you need to run X first" path reports the same code.
76
+ export class PreconditionError extends Error {
77
+ constructor(message, remedy) {
78
+ super(message);
79
+ this.name = "PreconditionError";
80
+ this.exitCode = EXIT.PRECONDITION;
81
+ this.remedy = remedy ?? null;
82
+ }
83
+ }
84
+
85
+ // An environment failure: the machine, not the project, is the problem.
86
+ export class EnvironmentError extends Error {
87
+ constructor(message, remedy) {
88
+ super(message);
89
+ this.name = "EnvironmentError";
90
+ this.exitCode = EXIT.ENVIRONMENT;
91
+ this.remedy = remedy ?? null;
92
+ }
93
+ }
94
+
95
+ // "Not a Doctrina project" is the single most common precondition, raised
96
+ // by nearly every command. One constructor so the message and the code
97
+ // stay identical everywhere.
98
+ export function notADoctrinaProject() {
99
+ return new PreconditionError(
100
+ "not a Doctrina project (no .doctrina/ in cwd)",
101
+ "doctrina init",
102
+ );
103
+ }
@@ -0,0 +1,106 @@
1
+ // @ts-check
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { COMMAND_NAMES } from "./commands.js";
6
+
7
+ // One catalog of "which flags does command X accept", with two consumers:
8
+ //
9
+ // - the static undeclared-flag test (C3), which asserts every flag a
10
+ // command READS is declared, so `parseArgs` never swallows a positional
11
+ // as an undeclared flag's value again; and
12
+ // - the docs accuracy gate (D1, `scripts/check-docs.js`), which asserts
13
+ // every flag the DOCS document against a command is declared.
14
+ //
15
+ // One invariant, two checks: source and prose are held to the same list, so
16
+ // a renamed flag cannot stay green in either place. Both read a command
17
+ // module's exported `flags` — the declaration lives WITH the command, not in
18
+ // the entrypoint, which is why an added command no longer requires editing
19
+ // `src/index.js` to make its flags parse (the structural half of C3).
20
+
21
+ const here = path.dirname(fileURLToPath(import.meta.url));
22
+ const commandsDir = path.join(here, "..", "commands");
23
+
24
+ // Flags the entrypoint owns for every command; a command never redeclares them.
25
+ export const GLOBAL_FLAGS = Object.freeze({
26
+ boolean: ["help", "h", "v", "debug"],
27
+ string: [],
28
+ });
29
+
30
+ // Command name -> module filename. Every command's module is `<name>.js`
31
+ // except where this map says otherwise.
32
+ const MODULE_OVERRIDES = { index: "index-rebuild.js" };
33
+
34
+ export function moduleFileFor(commandName) {
35
+ return MODULE_OVERRIDES[commandName] ?? `${commandName}.js`;
36
+ }
37
+
38
+ export function modulePathFor(commandName) {
39
+ return path.join(commandsDir, moduleFileFor(commandName));
40
+ }
41
+
42
+ // The flag spec a command module exports, normalised to
43
+ // { boolean: string[], string: string[] }. Returns null when the module
44
+ // declares none — which the C3 test treats as a failure and the docs gate
45
+ // treats as "not checkable yet", so the two can land independently.
46
+ export async function loadFlagSpec(commandName) {
47
+ const mod = await import(pathToUrl(modulePathFor(commandName)));
48
+ const spec = mod.flags;
49
+ if (!spec) return null;
50
+ return {
51
+ boolean: [...(spec.boolean ?? [])],
52
+ string: [...(spec.string ?? [])],
53
+ };
54
+ }
55
+
56
+ // Every flag name a command accepts (own + global), as a Set. null when the
57
+ // command declares no spec.
58
+ export async function declaredFlags(commandName) {
59
+ const spec = await loadFlagSpec(commandName);
60
+ if (!spec) return null;
61
+ return new Set([
62
+ ...spec.boolean, ...spec.string,
63
+ ...GLOBAL_FLAGS.boolean, ...GLOBAL_FLAGS.string,
64
+ ]);
65
+ }
66
+
67
+ export async function loadAllFlagSpecs() {
68
+ const out = new Map();
69
+ for (const name of COMMAND_NAMES) out.set(name, await loadFlagSpec(name));
70
+ return out;
71
+ }
72
+
73
+ // Flag names a source file READS, by scanning its call sites:
74
+ // flagBool(flags, "x") flagString(flags, "x") flags.has("x") flags.get("x")
75
+ // Deterministic text scan, not execution — it sees every branch, including
76
+ // the ones a given run never takes.
77
+ export function scanFlagUsage(sourceText) {
78
+ const used = new Set();
79
+ const patterns = [
80
+ /flagBool\(\s*flags\s*,\s*["']([^"']+)["']/g,
81
+ /flagString\(\s*flags\s*,\s*["']([^"']+)["']/g,
82
+ /flags\.has\(\s*["']([^"']+)["']/g,
83
+ /flags\.get\(\s*["']([^"']+)["']/g,
84
+ ];
85
+ for (const re of patterns) {
86
+ for (const m of sourceText.matchAll(re)) used.add(m[1]);
87
+ }
88
+ return used;
89
+ }
90
+
91
+ export function readCommandSource(commandName) {
92
+ return readFileSync(modulePathFor(commandName), "utf8");
93
+ }
94
+
95
+ // Flags a command's `help` string documents: every `--flag` token in it.
96
+ // The docs gate compares prose against this too, so help and reference
97
+ // cannot drift apart from the declaration.
98
+ export function scanHelpFlags(helpText) {
99
+ const out = new Set();
100
+ for (const m of String(helpText ?? "").matchAll(/--([a-z][a-z0-9-]*)/g)) out.add(m[1]);
101
+ return out;
102
+ }
103
+
104
+ function pathToUrl(p) {
105
+ return "file:///" + p.replace(/\\/g, "/");
106
+ }
package/src/lib/fs-ops.js CHANGED
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, renameSync, unlinkSync, rmdirSync } from "node:fs";
2
3
  import path from "node:path";
3
4