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
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { readdirSync } from "node:fs";
@@ -6,6 +7,7 @@ import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
6
7
  import { flagBool, flagString } from "../lib/args.js";
7
8
  import { today } from "../lib/dates.js";
8
9
  import { c } from "../lib/colors.js";
10
+ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
9
11
 
10
12
  // The build/verify gate `validate` is not. `validate` checks the shape of
11
13
  // the artifact tree; it never runs the project, so a repo that does not
@@ -23,16 +25,24 @@ const STARTER = {
23
25
  "doctrina verify runs each check below, in order, through your shell. " +
24
26
  "Replace the example with your real typecheck/test/build commands. A " +
25
27
  "non-zero exit fails the gate. This file is the project's executable " +
26
- "definition of 'the code actually works'.",
28
+ "definition of 'the code actually works'. A check may add an \"expect\" " +
29
+ "block — {\"fail_if_output_matches\": \"<regex>\"} and/or " +
30
+ "{\"require_output_matches\": \"<regex>\"} — so that a run which exits 0 " +
31
+ "having executed NOTHING fails the gate instead of passing it.",
27
32
  checks: [
28
33
  { name: "example", run: "exit 1" },
29
34
  ],
30
35
  };
31
36
 
37
+ // Flags this command accepts. Declared HERE, with the command, so
38
+ // adding a command never requires editing the entrypoint — the gap that
39
+ // let six flags ship undeclared and silently swallow a positional (C3).
40
+ export const flags = { boolean: ["json", "clean", "force", "init", "list", "strict"], string: ["signoff"] };
41
+
32
42
  export async function run(_positional, flags) {
33
43
  const projectRoot = process.cwd();
34
44
  if (!exists(path.join(projectRoot, ".doctrina"))) {
35
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
45
+ throw notADoctrinaProject();
36
46
  }
37
47
 
38
48
  // --clean is the reproducibility lint (review G4): `verify` runs the
@@ -66,7 +76,10 @@ export async function run(_positional, flags) {
66
76
  // the false confidence this command exists to prevent. Fail loudly.
67
77
  console.error(c.red("error:") + ` no ${CONFIG_REL} — nothing to verify`);
68
78
  console.error(c.gray("hint: ") + "scaffold one with `doctrina verify --init`, then declare your typecheck/test/build commands");
69
- return 1;
79
+ // PRECONDITION, not a gate failure: the work may be perfect; this
80
+ // project has simply never declared a build gate. An agent reading
81
+ // exit 1 would iterate on the code forever (C7).
82
+ return EXIT.PRECONDITION;
70
83
  }
71
84
 
72
85
  let config;
@@ -89,6 +102,15 @@ export async function run(_positional, flags) {
89
102
  console.error(c.red("error:") + ` every check in ${CONFIG_REL} needs a non-empty "run" string (or "type": "manual")`);
90
103
  return 1;
91
104
  }
105
+ // An uncompilable expectation must fail LOUDLY at config time. Skipping
106
+ // it would leave a check that looks fail-closed and is not — the
107
+ // failure mode this whole feature exists to remove.
108
+ try {
109
+ parseExpectation(ch);
110
+ } catch (err) {
111
+ console.error(c.red("error:") + ` check "${ch.name ?? "?"}" declares an "expect" pattern that is not a valid regular expression: ${err.message}`);
112
+ return EXIT.USAGE;
113
+ }
92
114
  }
93
115
  if (checks.length === 0) {
94
116
  console.error(c.red("error:") + ` ${CONFIG_REL} declares zero checks — a verify gate with no checks proves nothing`);
@@ -131,6 +153,15 @@ export async function run(_positional, flags) {
131
153
  checks.forEach((ch, i) => {
132
154
  const what = ch.type === "manual" ? c.gray(`manual${ch.rubric ? " — " + ch.rubric : ""}`) : c.gray(ch.run);
133
155
  console.log(` ${i + 1}. ${c.cyan((ch.name ?? "check").padEnd(16))} ${what}`);
156
+ // A fail-closed check must be visible as one: a reader auditing the
157
+ // gate needs to see which checks judge their own output.
158
+ const exp = parseExpectation(ch);
159
+ if (exp) {
160
+ const parts = [];
161
+ if (exp.failIfSource) parts.push(`fail if output matches /${exp.failIfSource}/`);
162
+ if (exp.requireSource) parts.push(`require output matches /${exp.requireSource}/`);
163
+ console.log(` ${" ".repeat(16)} ${c.gray(parts.join("; "))}`);
164
+ }
134
165
  });
135
166
  return 0;
136
167
  }
@@ -167,14 +198,46 @@ export async function run(_positional, flags) {
167
198
  // monorepo target a sub-package, e.g. { run: "node --test", cwd:
168
199
  // "packages/api" } — the multi-service shape the contract artifact
169
200
  // also serves.
201
+ // A check that declares OUTPUT EXPECTATIONS must have its output read,
202
+ // so it runs piped and is echoed straight through — the operator still
203
+ // sees everything, just at the end of the check rather than streaming.
204
+ // Only checks that opt in pay that cost.
205
+ const expectation = parseExpectation(ch);
170
206
  const res = spawnSync(ch.run, {
171
207
  cwd: ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot,
172
208
  shell: true,
173
- stdio: "inherit",
209
+ stdio: expectation ? "pipe" : "inherit",
210
+ encoding: expectation ? "utf8" : undefined,
211
+ // A suite's output can be large; the default 1MB cap would truncate
212
+ // the very line an expectation is looking for.
213
+ maxBuffer: expectation ? 64 * 1024 * 1024 : undefined,
174
214
  });
175
- const ok = !res.error && res.status === 0;
215
+ if (expectation) {
216
+ if (res.stdout) process.stdout.write(res.stdout);
217
+ if (res.stderr) process.stderr.write(res.stderr);
218
+ }
219
+
220
+ let ok = !res.error && res.status === 0;
221
+ /** @type {string | null} */
222
+ let expectationNote = null;
223
+ if (ok && expectation) {
224
+ // THE FAIL-CLOSED RULE. A green exit code answers "did the runner
225
+ // crash", never "did the runner do anything". A suite whose filter
226
+ // matched nothing prints "0 scenarios" and exits 0, and every gate
227
+ // above this one calls that a pass. An expectation makes the run's
228
+ // own output part of the verdict.
229
+ const output = `${res.stdout ?? ""}\n${res.stderr ?? ""}`;
230
+ const verdict = judgeOutput(expectation, output);
231
+ if (!verdict.ok) {
232
+ ok = false;
233
+ expectationNote = verdict.reason;
234
+ }
235
+ }
236
+
176
237
  if (res.error) {
177
238
  console.log(c.red(`✗ ${name}`) + c.gray(` — could not run: ${res.error.message}`));
239
+ } else if (expectationNote) {
240
+ console.log(c.red(`✗ ${name}`) + c.gray(` — exit 0, but ${expectationNote}`));
178
241
  } else {
179
242
  console.log(ok ? c.green(`✓ ${name}`) : c.red(`✗ ${name} (exit ${res.status})`));
180
243
  }
@@ -203,6 +266,47 @@ export async function run(_positional, flags) {
203
266
  return 1;
204
267
  }
205
268
 
269
+ // An `expect` block, normalised and compiled, or null when the check does
270
+ // not declare one. Runner-agnostic by construction: Doctrina supplies no
271
+ // patterns of its own and knows nothing about what the output means — the
272
+ // project declares the line that proves its run was real.
273
+ //
274
+ // "expect": {
275
+ // "fail_if_output_matches": "0 scenarios|0 features",
276
+ // "require_output_matches": "\\d+ scenarios? passed"
277
+ // }
278
+ export function parseExpectation(check) {
279
+ const raw = check?.expect;
280
+ if (!raw || typeof raw !== "object") return null;
281
+ const out = {};
282
+ if (typeof raw.fail_if_output_matches === "string" && raw.fail_if_output_matches !== "") {
283
+ out.failIf = new RegExp(raw.fail_if_output_matches, "im");
284
+ out.failIfSource = raw.fail_if_output_matches;
285
+ }
286
+ if (typeof raw.require_output_matches === "string" && raw.require_output_matches !== "") {
287
+ out.requireMatch = new RegExp(raw.require_output_matches, "im");
288
+ out.requireSource = raw.require_output_matches;
289
+ }
290
+ return out.failIf || out.requireMatch ? out : null;
291
+ }
292
+
293
+ /** @returns {{ok: boolean, reason?: string}} */
294
+ export function judgeOutput(expectation, output) {
295
+ if (expectation.failIf && expectation.failIf.test(output)) {
296
+ return {
297
+ ok: false,
298
+ reason: `its output matched /${expectation.failIfSource}/ — the check declares that shape a failure (a run that executed nothing exits 0 too)`,
299
+ };
300
+ }
301
+ if (expectation.requireMatch && !expectation.requireMatch.test(output)) {
302
+ return {
303
+ ok: false,
304
+ reason: `its output never matched /${expectation.requireSource}/ — the check requires that proof of a real run`,
305
+ };
306
+ }
307
+ return { ok: true };
308
+ }
309
+
206
310
  // Static reproducibility lint: walk the project's package.json files and
207
311
  // report the two "works on my machine" footguns the review hit — a package
208
312
  // whose entry points live in a build-output dir with nothing that builds it
@@ -360,6 +464,23 @@ A manual check passes when it has been signed off and is otherwise reported
360
464
  as "pending" — non-blocking by default, failing only under --strict (so CI
361
465
  can require the sign-off). Sign-offs are stored in ${SIGNOFF_REL}.
362
466
 
467
+ OUTPUT EXPECTATIONS — fail-closed on a run that did nothing. An exit code
468
+ answers "did the runner crash", never "did the runner run anything": a suite
469
+ whose filter matched no cases prints "0 scenarios" and exits 0, and every
470
+ gate calls it a pass. Add an "expect" block to make the output part of the
471
+ verdict:
472
+
473
+ { "name": "e2e", "run": "behave --tags @smoke",
474
+ "expect": { "fail_if_output_matches": "0 scenarios",
475
+ "require_output_matches": "\\\\d+ scenarios? passed" } }
476
+
477
+ Doctrina supplies no patterns of its own and knows nothing about what the
478
+ output means — the project declares the line that proves its run was real,
479
+ so this works for any runner in any language. A check with an "expect"
480
+ block runs piped (its output is echoed through when it finishes, rather
481
+ than streaming); every other check still streams. An "expect" pattern that
482
+ is not a valid regular expression fails at config time, not silently.
483
+
363
484
  Flags:
364
485
  --init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force)
365
486
  --list Print the configured checks without running them
@@ -1,9 +1,11 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { watch as fsWatch } from "node:fs";
4
5
  import { exists } from "../lib/fs-ops.js";
5
6
  import { flagBool } from "../lib/args.js";
6
7
  import { c } from "../lib/colors.js";
8
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
7
9
  import * as validate from "./validate.js";
8
10
  import * as next from "./next.js";
9
11
 
@@ -15,11 +17,16 @@ import * as next from "./next.js";
15
17
  // makes) so it never loops on its own output. `--once` runs a single pass and
16
18
  // exits — the testable, scriptable form.
17
19
 
20
+ // Flags this command accepts. Declared HERE, with the command, so
21
+ // adding a command never requires editing the entrypoint — the gap that
22
+ // let six flags ship undeclared and silently swallow a positional (C3).
23
+ export const flags = { boolean: ["json", "once"], string: [] };
24
+
18
25
  export async function run(_positional, flags) {
19
26
  const projectRoot = process.cwd();
20
27
  const dot = path.join(projectRoot, ".doctrina");
21
28
  if (!exists(dot)) {
22
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
29
+ throw notADoctrinaProject();
23
30
  }
24
31
 
25
32
  if (flagBool(flags, "once", false)) {
@@ -1,3 +1,5 @@
1
+ // @ts-check
2
+ import { getSectionParagraph } from "../lib/doc-model.js";
1
3
  import path from "node:path";
2
4
  import process from "node:process";
3
5
  import { readdirSync } from "node:fs";
@@ -7,6 +9,7 @@ import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
7
9
  import * as idx from "../lib/index-json.js";
8
10
  import { c } from "../lib/colors.js";
9
11
  import { suggest } from "../lib/suggest.js";
12
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
10
13
 
11
14
  // Provenance explainer (review 2026-06-27 passive-user feature #5): answer
12
15
  // "why does this capability exist / why was it built this way?" by assembling
@@ -16,11 +19,16 @@ import { suggest } from "../lib/suggest.js";
16
19
  // asks; the tool answers from the artifacts, instead of grepping by hand.
17
20
  // Read-only. Builds on the Realizes:/trace provenance (ADR 0006/0011).
18
21
 
22
+ // Flags this command accepts. Declared HERE, with the command, so
23
+ // adding a command never requires editing the entrypoint — the gap that
24
+ // let six flags ship undeclared and silently swallow a positional (C3).
25
+ export const flags = { boolean: ["json"], string: [] };
26
+
19
27
  export async function run(positional, _flags) {
20
28
  const cap = positional[0];
21
29
  const projectRoot = process.cwd();
22
30
  if (!exists(path.join(projectRoot, ".doctrina"))) {
23
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
31
+ throw notADoctrinaProject();
24
32
  }
25
33
  const specsDir = path.join(projectRoot, ".doctrina", "specs");
26
34
  const known = isDir(specsDir) ? readdirSync(specsDir).filter((e) => isFile(path.join(specsDir, e, "spec.md"))) : [];
@@ -72,7 +80,7 @@ export async function run(positional, _flags) {
72
80
  const status = specHeader(text, "Status") ?? "—";
73
81
  const impl = specHeader(text, "Implementation") ?? "—";
74
82
  console.log(` ${c.gray("status:")} ${status} ${c.gray("implementation:")} ${impl}`);
75
- const purpose = sectionParagraph(text, "Purpose");
83
+ const purpose = getSectionParagraph(text, "Purpose");
76
84
  if (purpose) console.log(" " + purpose.replace(/\s+/g, " ").trim());
77
85
 
78
86
  // Dependency graph, both directions (the machine-readable **Depends on:**
@@ -229,28 +237,6 @@ function productAnchors(projectRoot) {
229
237
  return out;
230
238
  }
231
239
 
232
- // First non-comment paragraph under a "## <name>" heading.
233
- function sectionParagraph(text, name) {
234
- const lines = text.split(/\r?\n/);
235
- let inSection = false;
236
- const buf = [];
237
- for (const line of lines) {
238
- if (/^##\s+/.test(line)) {
239
- if (inSection) break;
240
- if (new RegExp(`^##\\s+${name}\\b`, "i").test(line)) inSection = true;
241
- continue;
242
- }
243
- if (!inSection) continue;
244
- if (/^\s*<!--/.test(line) || /-->/.test(line)) continue;
245
- if (line.trim() === "") {
246
- if (buf.length) break;
247
- continue;
248
- }
249
- buf.push(line.trim());
250
- }
251
- return buf.join(" ");
252
- }
253
-
254
240
  // Accepted ADRs whose body names the capability (word-ish boundary).
255
241
  function decisionsMentioning(projectRoot, cap) {
256
242
  const adrDir = path.join(projectRoot, ".doctrina", "decisions");
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { readdirSync } from "node:fs";
@@ -8,7 +9,9 @@ import * as idx from "../lib/index-json.js";
8
9
  import { c } from "../lib/colors.js";
9
10
  import { assessBrief } from "../lib/clarity.js";
10
11
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
12
+ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
11
13
  import { changeNew } from "./change.js";
14
+ import { classify } from "./triage.js";
12
15
 
13
16
  // `work` is the second half of the no-ceremony path (ADR 0005): a brief
14
17
  // prompt ("add login") becomes a fully scaffolded change plus a playbook
@@ -18,11 +21,16 @@ import { changeNew } from "./change.js";
18
21
  // unproven verification). The CLI's own language processing stops at
19
22
  // slugging and term counting; everything semantic is the agent's job.
20
23
 
24
+ // Flags this command accepts. Declared HERE, with the command, so
25
+ // adding a command never requires editing the entrypoint — the gap that
26
+ // let six flags ship undeclared and silently swallow a positional (C3).
27
+ export const flags = { boolean: ["json", "chore", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "id", "resume", "title"] };
28
+
21
29
  export async function run(positional, flags) {
22
30
  const prompt = positional.join(" ").trim();
23
31
  const projectRoot = process.cwd();
24
32
  if (!exists(path.join(projectRoot, ".doctrina"))) {
25
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
33
+ throw notADoctrinaProject();
26
34
  }
27
35
 
28
36
  // --resume <id>: reprint the playbook for an existing open change rather than
@@ -65,6 +73,31 @@ export async function run(positional, flags) {
65
73
  }
66
74
  }
67
75
 
76
+ // LANE CHECK (change 0029). `work` was the default answer to every
77
+ // request, so a broken workflow or a suite that ran nothing became a
78
+ // change with a proposal, tasks and a spec delta — ceremony spent on a
79
+ // diagnosis, and a close that attested to nothing. A runtime-shaped
80
+ // prompt is stopped here, once, with the diagnosis path named. It is a
81
+ // deterministic term match and it can be wrong, so --force proceeds and
82
+ // the message says so.
83
+ if (!fromDiff && !chore && !flagBool(flags, "force", false)) {
84
+ const verdict = classify(prompt);
85
+ if (verdict.lane === "runtime" && verdict.confident) {
86
+ console.error(c.yellow("hold:") + " this reads as a RUNTIME problem, not a change of behaviour");
87
+ console.error(c.gray(`signals: ${[...new Set(verdict.scores.runtime.hits)].slice(0, 6).join(", ")}`));
88
+ console.error("");
89
+ console.error("Nothing here says the spec is wrong — diagnose the running system first:");
90
+ console.error(` ${c.cyan(`doctrina triage "${prompt.length > 60 ? `${prompt.slice(0, 59)}…` : prompt}"`)}`);
91
+ console.error("");
92
+ console.error(c.gray("If the requirement really is missing, open the change anyway:"));
93
+ console.error(c.gray(" doctrina work --force \"<prompt>\" · or --chore for wiring already specified"));
94
+ // PRECONDITION: the work may be perfectly valid; this project has
95
+ // simply not been diagnosed yet. A gate code (1) would read as
96
+ // "the prompt is bad" and invite rewording it forever.
97
+ return EXIT.PRECONDITION;
98
+ }
99
+ }
100
+
68
101
  const pinned = flagString(flags, "capability");
69
102
  if (pinned && !/^[a-z][a-z0-9-]*$/.test(pinned)) {
70
103
  console.error(c.red("error:") + ` invalid capability "${pinned}" (lowercase letters, digits, hyphens)`);
@@ -538,6 +571,14 @@ Options:
538
571
  prompt needed) and print a code-first playbook (F8)
539
572
  --chore, --no-spec Open a spec-less chore change (infra/docs/build) with a
540
573
  playbook that skips the spec-delta steps (F9)
541
- --force Open a new change even when one is open, and overwrite
542
- an existing change folder
574
+ --force Open a new change even when one is open, overwrite an
575
+ existing change folder, and proceed past the lane hold
576
+ below
577
+
578
+ A prompt that reads as a RUNTIME problem — a workflow, an env value, a run
579
+ that executed nothing — is HELD before anything is scaffolded, and pointed
580
+ at \`doctrina triage\` instead (exit 3: a precondition, not a bad prompt).
581
+ The classifier is deterministic term matching and can be wrong; --force
582
+ opens the change anyway, and --chore is the lane for wiring the spec
583
+ already covers.
543
584
  `;
package/src/index.js CHANGED
@@ -1,10 +1,15 @@
1
1
  #!/usr/bin/env node
2
+ // @ts-check
2
3
  import process from "node:process";
3
4
  import { parseArgs } from "./lib/args.js";
4
5
  import { c } from "./lib/colors.js";
5
6
  import { suggest } from "./lib/suggest.js";
6
7
  import { cliVersion } from "./lib/version.js";
7
- import { surfaceHelp } from "./lib/commands.js";
8
+ import { surfaceHelp, OPERATIONS } from "./lib/commands.js";
9
+ import { GLOBAL_FLAGS } from "./lib/flag-catalog.js";
10
+ import { EXIT, exitCodeHelp } from "./lib/exit-codes.js";
11
+ import { recordUsage } from "./lib/usage.js";
12
+ import { wantsJson, emitJson, captureOutput, stripAnsi } from "./lib/json-out.js";
8
13
 
9
14
  import * as init from "./commands/init.js";
10
15
  import * as spec from "./commands/spec.js";
@@ -41,6 +46,8 @@ import * as report from "./commands/report.js";
41
46
  import * as completion from "./commands/completion.js";
42
47
  import * as intent from "./commands/intent.js";
43
48
  import * as upgrade from "./commands/upgrade.js";
49
+ import * as adapter from "./commands/adapter.js";
50
+ import * as triage from "./commands/triage.js";
44
51
 
45
52
  const COMMANDS = {
46
53
  init, spec, change, decision, validate, hooks, analyze, clarify,
@@ -48,7 +55,7 @@ const COMMANDS = {
48
55
  intake, work, coverage, verify, contract, trace,
49
56
  status, close, review, watch, why, constitution,
50
57
  prime, handoff, show, doctor, report, completion,
51
- intent, upgrade,
58
+ intent, upgrade, adapter, triage,
52
59
  };
53
60
 
54
61
  const TOP_HELP = `
@@ -60,6 +67,10 @@ ${surfaceHelp()}
60
67
  Global flags:
61
68
  --help, -h Show this message (or per-command help if after a command)
62
69
  --version, -v Print the version
70
+ --debug On an unexpected error, also print the stack trace
71
+
72
+ Exit codes (a contract — an agent reads these to decide what to do next):
73
+ ${exitCodeHelp()}
63
74
  `;
64
75
 
65
76
  async function main(argv) {
@@ -69,8 +80,23 @@ async function main(argv) {
69
80
  // version and exit, looking exactly like the spec's version had been set
70
81
  // (0.11.0 field-review papercut). `-v` stays global either way.
71
82
  const versionOnly = argv.every((t) => t === "--version" || t === "-v") && argv.length > 0;
83
+
84
+ // Two passes. The first resolves only the command name, using the global
85
+ // flags; the second re-parses with THAT command's declared flags merged in.
86
+ //
87
+ // One global boolean list used to serve every command, and six flags read
88
+ // via flagBool were missing from it. An undeclared flag whose next token
89
+ // does not start with "-" swallows that token as its value, so
90
+ // `change new --chore ajuste-ci "Ajustar CI"` lost the id AND silently
91
+ // ignored the flag, reporting "requires a title" for a quoted title (C3).
92
+ // Declarations now live with the command, so a new command cannot
93
+ // reintroduce the gap by forgetting to edit this file.
94
+ const bootstrap = parseArgs(argv, { boolean: [...GLOBAL_FLAGS.boolean] });
95
+ const commandModule = COMMANDS[bootstrap.positional[0]];
96
+ const spec = commandModule?.flags;
72
97
  const { positional, flags } = parseArgs(argv, {
73
- boolean: ["help", "h", "v", "force", "non-interactive", "check", "save", "bug", "write", "all", "concat", "archive", "strict", "list", "init", "fix", "once", "clean", "json"],
98
+ boolean: [...GLOBAL_FLAGS.boolean, ...(spec?.boolean ?? [])],
99
+ string: [...GLOBAL_FLAGS.string, ...(spec?.string ?? [])],
74
100
  });
75
101
 
76
102
  if (versionOnly || flags.get("v") === true) {
@@ -93,7 +119,7 @@ async function main(argv) {
93
119
  } else {
94
120
  console.error(c.gray("hint: ") + "try `doctrina --help` for the command surface");
95
121
  }
96
- return 2;
122
+ return EXIT.USAGE;
97
123
  }
98
124
 
99
125
  if (flags.get("help") || flags.get("h")) {
@@ -102,14 +128,43 @@ async function main(argv) {
102
128
  }
103
129
 
104
130
  try {
131
+ // --json on a command that builds no payload of its own still answers in
132
+ // JSON: its output is captured into a versioned envelope beside `ok` and
133
+ // `exit_code`. Branch on those; the lines are for completeness (M7).
134
+ if (wantsJson(flags) && !command.jsonNative) {
135
+ const { code, stdout, stderr } = await captureOutput(
136
+ () => command.run(positional.slice(1), flags),
137
+ );
138
+ emitJson([commandName, ...positional.slice(1)].join(" "), {
139
+ stdout: stdout.map(stripAnsi),
140
+ stderr: stderr.map(stripAnsi),
141
+ }, { ok: code === EXIT.OK, exitCode: code });
142
+ return code;
143
+ }
105
144
  return await command.run(positional.slice(1), flags);
106
145
  } catch (err) {
107
146
  console.error(c.red("error:") + ` ${err.message}`);
147
+ if (err.remedy) {
148
+ console.error(c.gray("hint: ") + `run ${c.cyan(err.remedy)} first, then retry`);
149
+ }
108
150
  if (flags.get("debug") && err.stack) {
109
151
  console.error(c.gray(err.stack));
110
152
  }
111
- return 1;
153
+ // A typed error carries its own class (precondition vs environment).
154
+ // An untyped throw is an unexpected failure of the work itself, which
155
+ // is the GATE class: something is wrong here, fix it and retry (C7).
156
+ return err.exitCode ?? EXIT.GATE;
112
157
  }
113
158
  }
114
159
 
115
- main(process.argv.slice(2)).then((code) => process.exit(code ?? 0));
160
+ main(process.argv.slice(2)).then((code) => {
161
+ // Record which operation ran, if and only if the operator asked for it
162
+ // by setting DOCTRINA_USAGE_LOG (M8). Off by default, local file only,
163
+ // no arguments captured, and never fatal. See lib/usage.js.
164
+ recordUsage(
165
+ process.argv.slice(2).filter((a) => !a.startsWith("-")),
166
+ code,
167
+ new Set(OPERATIONS.map((o) => o[0])),
168
+ );
169
+ process.exit(code ?? 0);
170
+ });
@@ -0,0 +1,140 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { readdirSync } from "node:fs";
4
+ import { exists, isDir, isFile, read, relPath, walk } from "./fs-ops.js";
5
+ import { locateTemplatesDir, substitute } from "./templates.js";
6
+
7
+ // Agent adapters: the thin per-agent files that point at AGENTS.md.
8
+ //
9
+ // Installing one used to be reachable ONLY through a full `doctrina init`
10
+ // run, and `init` refuses to run twice — so `init --force` was the only
11
+ // path, and it regenerated AGENTS.md and product.md from blank templates,
12
+ // destroying every hand-authored rule and the entire product definition
13
+ // with no warning (audit item C1, data loss). This module owns adapters as
14
+ // their own concern so adding one can be strictly additive.
15
+ //
16
+ // Three states, which the old surface could not tell apart:
17
+ // installed — the adapter's files are present in the project
18
+ // available — the adapter ships files and none are installed
19
+ // native — the agent reads AGENTS.md directly and needs no file
20
+ // (its template dir carries only a README)
21
+
22
+ export const ADAPTER_STATES = Object.freeze({
23
+ INSTALLED: "installed",
24
+ AVAILABLE: "available",
25
+ NATIVE: "native",
26
+ });
27
+
28
+ // Where a named adapter's templates live. A project-local adapter under
29
+ // `.doctrina/templates/adapters/<name>/` wins over the bundled one, so a
30
+ // team can ship an adapter for an agent Doctrina does not know about.
31
+ export function resolveAdapterDir(projectRoot, name) {
32
+ const local = path.join(projectRoot, ".doctrina", "templates", "adapters", name);
33
+ if (isDir(local) && walk(local).length > 0) return { dir: local, source: "project" };
34
+ const bundledRoot = path.join(locateTemplatesDir(), "adapters");
35
+ const bundled = path.join(bundledRoot, name);
36
+ if (isDir(bundled)) return { dir: bundled, source: "bundled" };
37
+ return null;
38
+ }
39
+
40
+ // Every adapter name known to this project: bundled plus project-local.
41
+ export function listAdapterNames(projectRoot) {
42
+ const names = new Set();
43
+ try {
44
+ const bundledRoot = path.join(locateTemplatesDir(), "adapters");
45
+ if (isDir(bundledRoot)) {
46
+ for (const e of readdirSync(bundledRoot)) {
47
+ if (isDir(path.join(bundledRoot, e))) names.add(e);
48
+ }
49
+ }
50
+ } catch { /* unusual install without a templates dir */ }
51
+ const localRoot = path.join(projectRoot, ".doctrina", "templates", "adapters");
52
+ if (isDir(localRoot)) {
53
+ for (const e of readdirSync(localRoot)) {
54
+ if (isDir(path.join(localRoot, e))) names.add(e);
55
+ }
56
+ }
57
+ return [...names].sort();
58
+ }
59
+
60
+ // The files a named adapter would write, as project-relative POSIX paths
61
+ // paired with their template source. A README inside the adapter template
62
+ // dir documents the adapter and is never materialised.
63
+ export function adapterFiles(projectRoot, name) {
64
+ const resolved = resolveAdapterDir(projectRoot, name);
65
+ if (!resolved) return null;
66
+ const out = [];
67
+ for (const templatePath of walk(resolved.dir)) {
68
+ const rel = relPath(resolved.dir, templatePath).replace(/\\/g, "/");
69
+ if (rel === "README.md" || rel.endsWith(".gitkeep")) continue;
70
+ out.push({
71
+ templatePath,
72
+ relativePath: rel.replace(/\.template$/, ""),
73
+ });
74
+ }
75
+ return { source: resolved.source, files: out.sort((a, b) => a.relativePath.localeCompare(b.relativePath)) };
76
+ }
77
+
78
+ // Adapter state plus the files backing it, for `adapter list`.
79
+ export function describeAdapter(projectRoot, name) {
80
+ const spec = adapterFiles(projectRoot, name);
81
+ if (!spec) return { name, state: ADAPTER_STATES.AVAILABLE, source: "unknown", files: [], installed: [] };
82
+ if (spec.files.length === 0) {
83
+ // Reads AGENTS.md natively — "no adapter installed" and "no adapter
84
+ // needed" were indistinguishable before this distinction existed.
85
+ return { name, state: ADAPTER_STATES.NATIVE, source: spec.source, files: [], installed: [] };
86
+ }
87
+ const installed = spec.files.filter((f) => isFile(path.join(projectRoot, f.relativePath)));
88
+ return {
89
+ name,
90
+ source: spec.source,
91
+ files: spec.files,
92
+ installed,
93
+ state: installed.length > 0 ? ADAPTER_STATES.INSTALLED : ADAPTER_STATES.AVAILABLE,
94
+ };
95
+ }
96
+
97
+ // The AGENTS.md path an adapter file should point at, relative to its own
98
+ // location — a nested command file needs "../../AGENTS.md".
99
+ export function agentsMdPathFor(relativePath) {
100
+ const depth = relativePath.split("/").length - 1;
101
+ return depth === 0 ? "AGENTS.md" : "../".repeat(depth) + "AGENTS.md";
102
+ }
103
+
104
+ // Is this adapter file a HUB POINTER — a file whose job is to route the
105
+ // agent at AGENTS.md?
106
+ //
107
+ // Declared by the template itself: a template that uses the
108
+ // {{AGENTS_MD_PATH}} token is saying "I point at the hub". Everything else
109
+ // is a command shim — a Claude/Cursor slash command like `/doctrina-status`
110
+ // whose job is to invoke the CLI, and which reaches the hub through its
111
+ // parent CLAUDE.md rather than by naming it.
112
+ //
113
+ // This distinction is the fix for audit item C2: the pointer check demanded
114
+ // the literal string "AGENTS.md" in EVERY adapter file, so a clean
115
+ // `init --agent claude` failed `templates check` on four command shims that
116
+ // were never pointers, and neither suggested remedy could clear it.
117
+ export function isHubPointer(file) {
118
+ return read(file.templatePath).includes("{{AGENTS_MD_PATH}}");
119
+ }
120
+
121
+ // Render one adapter file's body for this project.
122
+ export function renderAdapterFile(file, tokens) {
123
+ return substitute(read(file.templatePath), {
124
+ ...tokens,
125
+ AGENTS_MD_PATH: agentsMdPathFor(file.relativePath),
126
+ });
127
+ }
128
+
129
+ // Is this project-relative path one that some adapter owns? Used by
130
+ // `adapter remove` so it deletes only what an adapter created, and by
131
+ // `init` so it never counts adapter files as user content.
132
+ export function isAdapterOwnedPath(projectRoot, relPathPosix) {
133
+ for (const name of listAdapterNames(projectRoot)) {
134
+ const spec = adapterFiles(projectRoot, name);
135
+ if (spec?.files.some((f) => f.relativePath === relPathPosix)) return true;
136
+ }
137
+ return false;
138
+ }
139
+
140
+ export { exists };
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import { isDir, read, walk } from "./fs-ops.js";
3
4
  import { listHeader } from "./scan.js";