doctrina-cli 0.13.0 → 0.14.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 (66) 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 +66 -19
  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 +359 -53
  11. package/src/commands/contract.js +17 -24
  12. package/src/commands/coverage.js +16 -4
  13. package/src/commands/decision.js +172 -40
  14. package/src/commands/doctor.js +38 -7
  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 +14 -2
  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 +16 -6
  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/upgrade.js +8 -1
  34. package/src/commands/validate.js +58 -2
  35. package/src/commands/verify.js +12 -2
  36. package/src/commands/watch.js +8 -1
  37. package/src/commands/why.js +10 -24
  38. package/src/commands/work.js +8 -1
  39. package/src/index.js +60 -6
  40. package/src/lib/adapters.js +140 -0
  41. package/src/lib/adr-guard.js +1 -0
  42. package/src/lib/args.js +10 -0
  43. package/src/lib/clarity.js +1 -0
  44. package/src/lib/colors.js +1 -0
  45. package/src/lib/commands.js +239 -26
  46. package/src/lib/criteria.js +1 -0
  47. package/src/lib/dates.js +1 -0
  48. package/src/lib/diff.js +1 -0
  49. package/src/lib/doc-model.js +287 -0
  50. package/src/lib/docs-impact.js +167 -0
  51. package/src/lib/ears.js +1 -0
  52. package/src/lib/exit-codes.js +103 -0
  53. package/src/lib/flag-catalog.js +106 -0
  54. package/src/lib/fs-ops.js +1 -0
  55. package/src/lib/gates.js +123 -0
  56. package/src/lib/git.js +97 -0
  57. package/src/lib/index-json.js +14 -6
  58. package/src/lib/json-out.js +83 -0
  59. package/src/lib/prompt.js +18 -1
  60. package/src/lib/scan.js +81 -33
  61. package/src/lib/spec-ops.js +94 -9
  62. package/src/lib/suggest.js +1 -0
  63. package/src/lib/templates.js +89 -0
  64. package/src/lib/usage.js +89 -0
  65. package/src/lib/version.js +1 -0
  66. package/templates/AGENTS.md.template +59 -53
@@ -1,13 +1,17 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import process from "node:process";
3
4
  import { readdirSync } from "node:fs";
4
- import { exists, isDir, isFile, lineCount, read, relPath, walk } from "../lib/fs-ops.js";
5
+ import { exists, isDir, isFile, lineCount, read, relPath, walk , write} from "../lib/fs-ops.js";
5
6
  import * as idx from "../lib/index-json.js";
6
7
  import { SCHEMA_VERSION } from "../lib/index-json.js";
7
8
  import { cliVersion } from "../lib/version.js";
8
9
  import { today } from "../lib/dates.js";
9
10
  import { flagBool } from "../lib/args.js";
10
11
  import { c } from "../lib/colors.js";
12
+ import { kindFromPath, nonConformingHeaders, repairHeaders } from "../lib/doc-model.js";
13
+ import { emitJson } from "../lib/json-out.js";
14
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
11
15
  import { parseFrontmatter } from "./skill.js";
12
16
  import { checkEars, isEarsSpec } from "../lib/ears.js";
13
17
  import { specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
@@ -19,8 +23,21 @@ import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
19
23
  // `doctrina --help` and is never nagged about omissions.
20
24
  const CATALOG_THRESHOLD = 8;
21
25
 
26
+ // Flags this command accepts. Declared HERE, with the command, so
27
+ // adding a command never requires editing the entrypoint — the gap that
28
+ // let six flags ship undeclared and silently swallow a positional (C3).
29
+ // This command builds its own JSON payload; the entrypoint must not
30
+ // wrap it in the generic envelope.
31
+ export const jsonNative = true;
32
+
33
+ export const flags = { boolean: ["fix", "json"], string: [] };
34
+
22
35
  export async function run(_positional, flags) {
23
36
  const projectRoot = process.cwd();
37
+ // Outside a Doctrina project this is a PRECONDITION, not a pile of
38
+ // gate failures about missing files: the remedy is `doctrina init`,
39
+ // not editing artifacts that do not exist yet (C7).
40
+ if (!isDir(path.join(projectRoot, ".doctrina"))) throw notADoctrinaProject();
24
41
  const errors = [];
25
42
  const warnings = [];
26
43
 
@@ -87,6 +104,45 @@ export async function run(_positional, flags) {
87
104
  const productMd = path.join(projectRoot, ".doctrina", "product.md");
88
105
  if (!isFile(productMd)) errors.push(".doctrina/product.md missing");
89
106
 
107
+ // Header repair (M3). One grammar means a non-canonical header can be
108
+ // REWRITTEN, not just reported: `- **Status**: x` becomes
109
+ // `- **Status:** x`, and a spec written with list-style headers is
110
+ // normalised to the bare-bold form its kind uses. Content is never
111
+ // touched — only the header's own punctuation and style.
112
+ const doFix = flagBool(flags, "fix", false);
113
+ {
114
+ const artifacts = [];
115
+ const dot = path.join(projectRoot, ".doctrina");
116
+ for (const f of walk(dot)) {
117
+ if (!f.endsWith(".md")) continue;
118
+ const rel = relPath(projectRoot, f).replace(/\\/g, "/");
119
+ if (rel.includes("/changes/archive/")) continue; // history is immutable
120
+ artifacts.push({ file: f, rel, kind: kindFromPath(rel) });
121
+ }
122
+ let repairedFiles = 0;
123
+ for (const a of artifacts) {
124
+ const text = read(a.file);
125
+ const bad = nonConformingHeaders(text, a.kind);
126
+ if (bad.length === 0) continue;
127
+ if (doFix) {
128
+ const { text: fixed, repaired } = repairHeaders(text, a.kind);
129
+ if (repaired > 0 && fixed !== text) {
130
+ write(a.file, fixed, { force: true });
131
+ repairedFiles += 1;
132
+ }
133
+ } else {
134
+ for (const h of bad) {
135
+ warnings.push(
136
+ `${a.rel}:${h.line} header "${h.name}" is ${h.wrongStyle ? `in ${h.style} style but a ${a.kind} uses ${h.expectedStyle} style` : "not in canonical form"} — run \`doctrina validate --fix\``,
137
+ );
138
+ }
139
+ }
140
+ }
141
+ if (doFix && repairedFiles > 0) {
142
+ console.log(c.green("fixed") + ` normalised headers in ${repairedFiles} artifact${repairedFiles === 1 ? "" : "s"}`);
143
+ }
144
+ }
145
+
90
146
  // 3. index.json
91
147
  let index = null;
92
148
  try {
@@ -574,7 +630,7 @@ export async function run(_positional, flags) {
574
630
 
575
631
  // Output
576
632
  if (flagBool(flags, "json", false)) {
577
- console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings }, null, 2));
633
+ emitJson("validate", { ok: errors.length === 0, errors, warnings });
578
634
  return errors.length === 0 ? 0 : 1;
579
635
  }
580
636
  for (const w of warnings) console.log(c.yellow("warn: ") + w);
@@ -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
@@ -29,10 +31,15 @@ const STARTER = {
29
31
  ],
30
32
  };
31
33
 
34
+ // Flags this command accepts. Declared HERE, with the command, so
35
+ // adding a command never requires editing the entrypoint — the gap that
36
+ // let six flags ship undeclared and silently swallow a positional (C3).
37
+ export const flags = { boolean: ["json", "clean", "force", "init", "list", "strict"], string: ["signoff"] };
38
+
32
39
  export async function run(_positional, flags) {
33
40
  const projectRoot = process.cwd();
34
41
  if (!exists(path.join(projectRoot, ".doctrina"))) {
35
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
42
+ throw notADoctrinaProject();
36
43
  }
37
44
 
38
45
  // --clean is the reproducibility lint (review G4): `verify` runs the
@@ -66,7 +73,10 @@ export async function run(_positional, flags) {
66
73
  // the false confidence this command exists to prevent. Fail loudly.
67
74
  console.error(c.red("error:") + ` no ${CONFIG_REL} — nothing to verify`);
68
75
  console.error(c.gray("hint: ") + "scaffold one with `doctrina verify --init`, then declare your typecheck/test/build commands");
69
- return 1;
76
+ // PRECONDITION, not a gate failure: the work may be perfect; this
77
+ // project has simply never declared a build gate. An agent reading
78
+ // exit 1 would iterate on the code forever (C7).
79
+ return EXIT.PRECONDITION;
70
80
  }
71
81
 
72
82
  let config;
@@ -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,6 +9,7 @@ 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 { notADoctrinaProject } from "../lib/exit-codes.js";
11
13
  import { changeNew } from "./change.js";
12
14
 
13
15
  // `work` is the second half of the no-ceremony path (ADR 0005): a brief
@@ -18,11 +20,16 @@ import { changeNew } from "./change.js";
18
20
  // unproven verification). The CLI's own language processing stops at
19
21
  // slugging and term counting; everything semantic is the agent's job.
20
22
 
23
+ // Flags this command accepts. Declared HERE, with the command, so
24
+ // adding a command never requires editing the entrypoint — the gap that
25
+ // let six flags ship undeclared and silently swallow a positional (C3).
26
+ export const flags = { boolean: ["json", "chore", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "id", "resume", "title"] };
27
+
21
28
  export async function run(positional, flags) {
22
29
  const prompt = positional.join(" ").trim();
23
30
  const projectRoot = process.cwd();
24
31
  if (!exists(path.join(projectRoot, ".doctrina"))) {
25
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
32
+ throw notADoctrinaProject();
26
33
  }
27
34
 
28
35
  // --resume <id>: reprint the playbook for an existing open change rather than
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,7 @@ 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";
44
50
 
45
51
  const COMMANDS = {
46
52
  init, spec, change, decision, validate, hooks, analyze, clarify,
@@ -48,7 +54,7 @@ const COMMANDS = {
48
54
  intake, work, coverage, verify, contract, trace,
49
55
  status, close, review, watch, why, constitution,
50
56
  prime, handoff, show, doctor, report, completion,
51
- intent, upgrade,
57
+ intent, upgrade, adapter,
52
58
  };
53
59
 
54
60
  const TOP_HELP = `
@@ -60,6 +66,10 @@ ${surfaceHelp()}
60
66
  Global flags:
61
67
  --help, -h Show this message (or per-command help if after a command)
62
68
  --version, -v Print the version
69
+ --debug On an unexpected error, also print the stack trace
70
+
71
+ Exit codes (a contract — an agent reads these to decide what to do next):
72
+ ${exitCodeHelp()}
63
73
  `;
64
74
 
65
75
  async function main(argv) {
@@ -69,8 +79,23 @@ async function main(argv) {
69
79
  // version and exit, looking exactly like the spec's version had been set
70
80
  // (0.11.0 field-review papercut). `-v` stays global either way.
71
81
  const versionOnly = argv.every((t) => t === "--version" || t === "-v") && argv.length > 0;
82
+
83
+ // Two passes. The first resolves only the command name, using the global
84
+ // flags; the second re-parses with THAT command's declared flags merged in.
85
+ //
86
+ // One global boolean list used to serve every command, and six flags read
87
+ // via flagBool were missing from it. An undeclared flag whose next token
88
+ // does not start with "-" swallows that token as its value, so
89
+ // `change new --chore ajuste-ci "Ajustar CI"` lost the id AND silently
90
+ // ignored the flag, reporting "requires a title" for a quoted title (C3).
91
+ // Declarations now live with the command, so a new command cannot
92
+ // reintroduce the gap by forgetting to edit this file.
93
+ const bootstrap = parseArgs(argv, { boolean: [...GLOBAL_FLAGS.boolean] });
94
+ const commandModule = COMMANDS[bootstrap.positional[0]];
95
+ const spec = commandModule?.flags;
72
96
  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"],
97
+ boolean: [...GLOBAL_FLAGS.boolean, ...(spec?.boolean ?? [])],
98
+ string: [...GLOBAL_FLAGS.string, ...(spec?.string ?? [])],
74
99
  });
75
100
 
76
101
  if (versionOnly || flags.get("v") === true) {
@@ -93,7 +118,7 @@ async function main(argv) {
93
118
  } else {
94
119
  console.error(c.gray("hint: ") + "try `doctrina --help` for the command surface");
95
120
  }
96
- return 2;
121
+ return EXIT.USAGE;
97
122
  }
98
123
 
99
124
  if (flags.get("help") || flags.get("h")) {
@@ -102,14 +127,43 @@ async function main(argv) {
102
127
  }
103
128
 
104
129
  try {
130
+ // --json on a command that builds no payload of its own still answers in
131
+ // JSON: its output is captured into a versioned envelope beside `ok` and
132
+ // `exit_code`. Branch on those; the lines are for completeness (M7).
133
+ if (wantsJson(flags) && !command.jsonNative) {
134
+ const { code, stdout, stderr } = await captureOutput(
135
+ () => command.run(positional.slice(1), flags),
136
+ );
137
+ emitJson([commandName, ...positional.slice(1)].join(" "), {
138
+ stdout: stdout.map(stripAnsi),
139
+ stderr: stderr.map(stripAnsi),
140
+ }, { ok: code === EXIT.OK, exitCode: code });
141
+ return code;
142
+ }
105
143
  return await command.run(positional.slice(1), flags);
106
144
  } catch (err) {
107
145
  console.error(c.red("error:") + ` ${err.message}`);
146
+ if (err.remedy) {
147
+ console.error(c.gray("hint: ") + `run ${c.cyan(err.remedy)} first, then retry`);
148
+ }
108
149
  if (flags.get("debug") && err.stack) {
109
150
  console.error(c.gray(err.stack));
110
151
  }
111
- return 1;
152
+ // A typed error carries its own class (precondition vs environment).
153
+ // An untyped throw is an unexpected failure of the work itself, which
154
+ // is the GATE class: something is wrong here, fix it and retry (C7).
155
+ return err.exitCode ?? EXIT.GATE;
112
156
  }
113
157
  }
114
158
 
115
- main(process.argv.slice(2)).then((code) => process.exit(code ?? 0));
159
+ main(process.argv.slice(2)).then((code) => {
160
+ // Record which operation ran, if and only if the operator asked for it
161
+ // by setting DOCTRINA_USAGE_LOG (M8). Off by default, local file only,
162
+ // no arguments captured, and never fatal. See lib/usage.js.
163
+ recordUsage(
164
+ process.argv.slice(2).filter((a) => !a.startsWith("-")),
165
+ code,
166
+ new Set(OPERATIONS.map((o) => o[0])),
167
+ );
168
+ process.exit(code ?? 0);
169
+ });
@@ -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";
package/src/lib/args.js CHANGED
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // Tiny argv parser. Returns { positional: string[], flags: Map<string, string|boolean> }.
2
3
  // Supports:
3
4
  // --flag -> flags.set("flag", true)
@@ -82,6 +83,15 @@ export function flagString(flags, name, fallback = undefined) {
82
83
  return String(v);
83
84
  }
84
85
 
86
+ // True when a value-taking flag was written WITHOUT one — `--budget` at the
87
+ // end of the line, or `--budget -100`, where the parser cannot tell a value
88
+ // from the next flag. `flagString` reports both as absent, so without this
89
+ // a mistyped number silently becomes "use the default", which is the one
90
+ // outcome the author definitely did not ask for.
91
+ export function flagGivenWithoutValue(flags, name) {
92
+ return flags.get(name) === true;
93
+ }
94
+
85
95
  export function flagBool(flags, name, fallback = false) {
86
96
  const v = flags.get(name);
87
97
  if (v === undefined) return fallback;
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // Deterministic under-specification detector for free-text briefs and
2
3
  // intakes — the clarification gate (review Topic A). It does NOT judge
3
4
  // meaning (that stays the agent's job, ADR 0005); it flags input that is
package/src/lib/colors.js CHANGED
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // ANSI colour helpers. Falls back to plain text when:
2
3
  // - stdout is not a TTY, OR
3
4
  // - the NO_COLOR environment variable is set (any value), OR