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,17 +1,24 @@
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
5
  import { spawnSync } from "node:child_process";
5
6
  import { exists, isDir, isFile, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
6
- import { locateTemplatesDir, substitute } from "../lib/templates.js";
7
+ import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
7
8
  import * as idx from "../lib/index-json.js";
8
9
  import { today } from "../lib/dates.js";
9
- import { flagBool, flagString } from "../lib/args.js";
10
+ import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
10
11
  import { c } from "../lib/colors.js";
11
12
  import { suggest } from "../lib/suggest.js";
13
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
12
14
 
13
15
  const SUBCOMMANDS = ["new", "list", "sync", "suggest"];
14
16
 
17
+ // Flags this command accepts. Declared HERE, with the command, so
18
+ // adding a command never requires editing the entrypoint — the gap that
19
+ // let six flags ship undeclared and silently swallow a positional (C3).
20
+ export const flags = { boolean: ["json", "force", "write"], string: ["since", "from-error"] };
21
+
15
22
  export async function run(positional, flags) {
16
23
  const sub = positional[0];
17
24
  if (!SUBCOMMANDS.includes(sub)) {
@@ -54,12 +61,187 @@ const FIX_SHAPED_SUBJECT =
54
61
  const GIT_LOG_LIMIT = 200;
55
62
  const MAX_SHOWN = 20;
56
63
 
64
+ // A skill drafted from the ERROR that taught the lesson (change 0029).
65
+ //
66
+ // `suggest` used to fire only after the fact — a skill was born when the
67
+ // agent remembered to write one, which is to say after the incident had
68
+ // already cost a session. The moment the lesson actually exists is the
69
+ // moment the error is on screen, and that text carries exactly what a
70
+ // trigger needs: the paths, the identifiers, the message itself.
71
+ //
72
+ // The draft is a SEED, not a skill. It fills the trigger — the field a
73
+ // human is least likely to write in a matchable form — and leaves the
74
+ // procedure to the person who just solved it.
75
+ const ERROR_STOP = new Set([
76
+ "the", "and", "for", "with", "that", "this", "from", "have", "has", "was",
77
+ "were", "not", "but", "you", "your", "are", "its", "it's", "can", "could",
78
+ "would", "should", "will", "when", "then", "than", "there", "their", "been",
79
+ "error", "warning", "failed", "failure", "exception", "traceback", "at",
80
+ "line", "file", "in", "on", "of", "to", "is", "a", "an", "no", "if",
81
+ // Stack-trace furniture and modal verbs: present in almost every error,
82
+ // distinctive in none, and they crowd out the words that identify one.
83
+ "most", "recent", "call", "calls", "last", "raise", "raised", "throw",
84
+ "thrown", "must", "one", "none", "self", "return", "returns", "def",
85
+ "module", "stack", "trace", "during", "handling", "above", "occurred",
86
+ "caused", "unexpected", "invalid", "expected", "got", "value", "values",
87
+ ]);
88
+
89
+ export function draftFromError(text) {
90
+ const raw = String(text ?? "").trim();
91
+ if (raw === "") return null;
92
+
93
+ // Paths and file references: the most matchable thing an error carries.
94
+ const paths = [...new Set(
95
+ (raw.match(/[\w.@-]+(?:[/\\][\w.@-]+)+(?:\.\w{1,6})?|[\w-]+\.(?:py|js|mjs|cjs|ts|tsx|yml|yaml|json|feature|go|rb|java|rs|sh|toml|ini|env)\b/g) ?? [])
96
+ .map((p) => p.replace(/[.,;:)\]]+$/, "")),
97
+ )].slice(0, 4);
98
+
99
+ // ALL_CAPS identifiers: env vars, error codes, constants.
100
+ const identifiers = [...new Set(raw.match(/\b[A-Z][A-Z0-9_]{2,}\b/g) ?? [])].slice(0, 4);
101
+
102
+ // Quoted fragments are usually the operative part of the message — but a
103
+ // traceback quotes its own filenames, and repeating a path already
104
+ // captured above buys nothing.
105
+ const quoted = [...new Set(
106
+ [...raw.matchAll(/["'`]([^"'`\n]{4,60})["'`]/g)].map((m) => m[1].trim()),
107
+ )].filter((q) => !paths.includes(q)).slice(0, 2);
108
+
109
+ // Distinctive words, most frequent first — the fallback when an error
110
+ // carries no structure at all.
111
+ const counts = new Map();
112
+ for (const w of raw.toLowerCase().match(/[a-z][a-z0-9_-]{3,}/g) ?? []) {
113
+ if (ERROR_STOP.has(w)) continue;
114
+ counts.set(w, (counts.get(w) ?? 0) + 1);
115
+ }
116
+ const keywords = [...counts.entries()]
117
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
118
+ .map(([w]) => w)
119
+ .slice(0, 6);
120
+
121
+ // The trigger, built to be MATCHED: concrete tokens first, prose second.
122
+ const anchors = [...quoted, ...identifiers, ...keywords.slice(0, 3)].slice(0, 4);
123
+ const wherePart = paths.length > 0 ? `, or work touches ${paths.slice(0, 2).map((p) => `\`${p}\``).join(" / ")}` : "";
124
+ const when = anchors.length > 0
125
+ ? `The task hits ${anchors.map((a) => `"${a}"`).join(" / ")}${wherePart}.`
126
+ : `The task reproduces: ${firstLine(raw).slice(0, 90)}.`;
127
+
128
+ // The slug seeds from identifiers first, then keywords — deduped on the
129
+ // NORMALISED token, so AXE_SEVERITY and the "axe_severity" the same error
130
+ // also mentions do not both land in the name.
131
+ const seedTokens = [];
132
+ const seenToken = new Set();
133
+ for (const token of [...identifiers.map((i) => i.toLowerCase()), ...keywords]) {
134
+ const norm = token.replace(/[^a-z0-9]/g, "");
135
+ if (norm === "" || seenToken.has(norm)) continue;
136
+ seenToken.add(norm);
137
+ seedTokens.push(token);
138
+ if (seedTokens.length === 4) break;
139
+ }
140
+ const slug = skillSlug(seedTokens.join("-") || firstLine(raw)) || "captured-error";
141
+
142
+ return {
143
+ slug,
144
+ when,
145
+ keywords,
146
+ paths,
147
+ identifiers,
148
+ excerpt: raw.split(/\r?\n/).slice(0, 12).join("\n"),
149
+ source: "error",
150
+ };
151
+ }
152
+
153
+ function firstLine(text) {
154
+ return String(text).split(/\r?\n/).find((l) => l.trim() !== "")?.trim() ?? "";
155
+ }
156
+
157
+ // Preview or scaffold one error-seeded draft.
158
+ function writeErrorDraft(projectRoot, draft, doWrite) {
159
+ const skillsDir = path.join(projectRoot, ".doctrina", "skills");
160
+ const target = path.join(skillsDir, `${draft.slug}.md`);
161
+
162
+ if (!doWrite) {
163
+ console.log(c.bold("Skill draft") + c.gray(" — seeded from the error you just hit:"));
164
+ console.log("");
165
+ console.log(` ${c.cyan("slug")} ${draft.slug}`);
166
+ console.log(` ${c.cyan("when")} ${draft.when}`);
167
+ if (draft.paths.length > 0) console.log(` ${c.cyan("paths")} ${draft.paths.join(", ")}`);
168
+ console.log("");
169
+ if (exists(target)) {
170
+ console.log(c.yellow("note: ") + `${relPath(projectRoot, target)} already exists — writing would skip it`);
171
+ }
172
+ console.log(c.gray("Scaffold it: ") + c.cyan("doctrina skill suggest --from-error <text|file> --write"));
173
+ console.log(c.gray("The trigger is drafted; the procedure is yours to write while you still remember it."));
174
+ return 0;
175
+ }
176
+
177
+ if (exists(target)) {
178
+ console.log(c.yellow("skip ") + ` ${draft.slug} (already exists)`);
179
+ return 0;
180
+ }
181
+
182
+ const tpl = read(path.join(locateTemplatesDir(), "skill.md.template"));
183
+ let body = substitute(tpl, { SKILL_NAME: draft.slug });
184
+ // Fill the trigger — the field a human is least likely to write in a form
185
+ // anything can match, and the one `context` ranks on.
186
+ body = body.replace(
187
+ /^when: .*$/m,
188
+ `when: ${draft.when.replace(/\r?\n/g, " ")}`,
189
+ );
190
+ body += `\n<!-- Seeded from an error. The failure to capture:\n\n${draft.excerpt}\n-->\n`;
191
+
192
+ mkdirp(skillsDir);
193
+ write(target, body, { force: false });
194
+
195
+ const index = idx.load(projectRoot);
196
+ const date = today();
197
+ idx.addSkill(index, {
198
+ id: draft.slug,
199
+ path: `.doctrina/skills/${draft.slug}.md`,
200
+ description: parseFrontmatter(body, "description") ?? "<edit me — one-sentence summary>",
201
+ last_updated: date,
202
+ });
203
+ idx.touch(index, date);
204
+ idx.save(projectRoot, index);
205
+
206
+ console.log(c.green("created") + ` ${relPath(projectRoot, target)}`);
207
+ console.log(c.gray("The trigger is drafted from the error. Write the procedure while you still remember it,"));
208
+ console.log(c.gray("then fill `description:` — `doctrina validate` warns on a trigger nothing can match."));
209
+ return 0;
210
+ }
211
+
57
212
  function skillSuggest(args, flags) {
58
213
  const projectRoot = process.cwd();
59
214
  ensureDoctrinaProject(projectRoot);
60
215
  const doWrite = flagBool(flags, "write", false);
61
216
  const since = flagString(flags, "since");
62
217
 
218
+ // --from-error: draft from the failure that is on screen RIGHT NOW,
219
+ // rather than waiting for the lesson to be remembered later. When given,
220
+ // it is the only source — this is a specific request, not a survey.
221
+ // A value-taking flag written without a value is a usage error, not a
222
+ // silent fall-through to the survey — `--from-error` with nothing after
223
+ // it would otherwise run the ordinary scan and look like it worked.
224
+ if (flagGivenWithoutValue(flags, "from-error")) {
225
+ console.error(c.red("error:") + " --from-error needs the error text, or a path to a file holding it");
226
+ return 2;
227
+ }
228
+ const fromError = flagString(flags, "from-error");
229
+ if (fromError !== undefined) {
230
+ if (fromError === "" || fromError === null) {
231
+ console.error(c.red("error:") + " --from-error needs the error text, or a path to a file holding it");
232
+ return 2;
233
+ }
234
+ // A path is read; anything else is taken as the text itself.
235
+ const asPath = path.resolve(projectRoot, fromError);
236
+ const text = isFile(asPath) ? read(asPath) : fromError;
237
+ const draft = draftFromError(text);
238
+ if (!draft) {
239
+ console.error(c.red("error:") + " --from-error was given nothing to draft from");
240
+ return 2;
241
+ }
242
+ return writeErrorDraft(projectRoot, draft, doWrite);
243
+ }
244
+
63
245
  const skillsDir = path.join(projectRoot, ".doctrina", "skills");
64
246
  const existingFiles = isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")) : [];
65
247
  const existing = new Set(existingFiles.map((f) => path.basename(f, ".md")));
@@ -81,6 +263,7 @@ function skillSuggest(args, flags) {
81
263
 
82
264
  // Two deterministic sources, deduped by slug. `seen` guards against a lesson
83
265
  // appearing twice (e.g. a fix committed and later archived as a change).
266
+ /** @type {SkillCandidate[]} */
84
267
  const candidates = [];
85
268
  const seen = new Set();
86
269
 
@@ -93,6 +276,7 @@ function skillSuggest(args, flags) {
93
276
  if (!FIX_SHAPED.test(id)) continue;
94
277
  const slug = skillSlug(id);
95
278
  if (seen.has(slug)) continue;
279
+ /** @type {SkillCandidate} */
96
280
  const cand = { id, slug, source: "change", from: name };
97
281
  if (captured(cand)) continue; // captured (any slug) or cited by an existing skill
98
282
  const proposal = path.join(archiveDir, name, "proposal.md");
@@ -198,6 +382,7 @@ function gitFixCommits(projectRoot, { since, limit }) {
198
382
  const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
199
383
  if (r.error || r.status !== 0 || !r.stdout) return [];
200
384
 
385
+ /** @type {SkillCandidate[]} */
201
386
  const out = [];
202
387
  const seen = new Set();
203
388
  for (const record of r.stdout.split("\x1e")) {
@@ -280,11 +465,11 @@ function skillNew(args, flags) {
280
465
  return 1;
281
466
  }
282
467
 
283
- const templatesDir = locateTemplatesDir();
284
- const tplPath = path.join(templatesDir, "skill.md.template");
285
- const body = substitute(read(tplPath), { SKILL_NAME: name });
468
+ const tpl = readTemplate(projectRoot, "skill.md.template");
469
+ const body = substitute(tpl.body, { SKILL_NAME: name });
286
470
  write(targetPath, body, { force });
287
- console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}`);
471
+ console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}` +
472
+ (tpl.source === "project" ? c.gray(" (project template)") : ""));
288
473
 
289
474
  const date = today();
290
475
  const index = idx.load(projectRoot);
@@ -395,7 +580,7 @@ function parseFrontmatter(text, key) {
395
580
 
396
581
  function ensureDoctrinaProject(projectRoot) {
397
582
  if (!exists(path.join(projectRoot, ".doctrina"))) {
398
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
583
+ throw notADoctrinaProject();
399
584
  }
400
585
  }
401
586
 
@@ -413,7 +598,7 @@ Subcommands:
413
598
  sync Copy each skill's frontmatter description into
414
599
  .doctrina/index.json so the index never drifts
415
600
  from the frontmatter (single source of truth).
416
- suggest [--write] [--since <ref>]
601
+ suggest [--write] [--since <ref>] [--from-error <text|file>]
417
602
  Surface fix-shaped lessons not yet captured as skills,
418
603
  from two sources: archived change proposals and the git
419
604
  commit history (ADR 0013). --write scaffolds a stub per
@@ -421,6 +606,14 @@ Subcommands:
421
606
  --since <ref> scans commits in <ref>..HEAD instead of the
422
607
  last 200 (e.g. --since v0.7.0).
423
608
 
609
+ --from-error <text|file> drafts ONE skill from the failure
610
+ on screen right now, instead of waiting for the lesson to
611
+ be remembered later. It fills the \`when:\` trigger from the
612
+ error's own paths, identifiers and message — the field a
613
+ human is least likely to write in a form anything can
614
+ match, and the one \`context\` ranks on. The procedure stays
615
+ yours to write. Add --write to scaffold it.
616
+
424
617
  Skills are written by humans, not generated. See docs/en/skills.md
425
618
  for the design rationale and the distinction from specs / AGENTS.md /
426
619
  the rejected memory/ folder.
@@ -1,8 +1,9 @@
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
5
  import { exists, isDir, isFile, lineCount, read, relPath, write } from "../lib/fs-ops.js";
5
- import { locateTemplatesDir, substitute } from "../lib/templates.js";
6
+ import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
6
7
  import { specHeader, deriveIndex } from "../lib/scan.js";
7
8
  import { setHeader, bumpVersion, setCriterionMark } from "../lib/spec-ops.js";
8
9
  import * as idx from "../lib/index-json.js";
@@ -10,9 +11,15 @@ import { today } from "../lib/dates.js";
10
11
  import { flagBool, flagString } from "../lib/args.js";
11
12
  import { c } from "../lib/colors.js";
12
13
  import { suggest } from "../lib/suggest.js";
14
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
13
15
 
14
16
  const SUBCOMMANDS = ["new", "list", "set"];
15
17
 
18
+ // Flags this command accepts. Declared HERE, with the command, so
19
+ // adding a command never requires editing the entrypoint — the gap that
20
+ // let six flags ship undeclared and silently swallow a positional (C3).
21
+ export const flags = { boolean: ["json", "bug", "force"], string: ["bump", "criterion", "implementation", "status", "version"] };
22
+
16
23
  export async function run(positional, flags) {
17
24
  const sub = positional[0];
18
25
  if (!SUBCOMMANDS.includes(sub)) {
@@ -44,13 +51,13 @@ export async function run(positional, flags) {
44
51
  return 1;
45
52
  }
46
53
 
47
- const templatesDir = locateTemplatesDir();
48
54
  const tplName = bug ? "spec-bug.md.template" : "spec.md.template";
49
- const tplPath = path.join(templatesDir, tplName);
55
+ const tpl = readTemplate(projectRoot, tplName);
50
56
  const date = today();
51
- const body = substitute(read(tplPath), { CAPABILITY: capability, DATE: date });
57
+ const body = substitute(tpl.body, { CAPABILITY: capability, DATE: date });
52
58
  write(targetPath, body, { force });
53
- console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}${bug ? " (bug-spec shape)" : ""}`);
59
+ console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}${bug ? " (bug-spec shape)" : ""}` +
60
+ (tpl.source === "project" ? c.gray(" (project template)") : ""));
54
61
 
55
62
  const index = idx.load(projectRoot);
56
63
  // Two axes: a fresh capability scaffold is a draft document for a
@@ -197,7 +204,7 @@ function specSet(args, flags) {
197
204
 
198
205
  function ensureDoctrinaProject(projectRoot) {
199
206
  if (!exists(path.join(projectRoot, ".doctrina"))) {
200
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
207
+ throw notADoctrinaProject();
201
208
  }
202
209
  }
203
210
 
@@ -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";
@@ -9,6 +10,8 @@ import { summarize as coverageSummary } from "./coverage.js";
9
10
  import { summarize as traceSummary } from "./trace.js";
10
11
  import { flagBool } from "../lib/args.js";
11
12
  import { c } from "../lib/colors.js";
13
+ import { emitJson } from "../lib/json-out.js";
14
+ import { notADoctrinaProject } from "../lib/exit-codes.js";
12
15
 
13
16
  // One-glance project health (review 2026-06-27 passive-user feature #1): a
14
17
  // dashboard that answers "where do things stand?" in a single read, so neither
@@ -20,16 +23,25 @@ import { c } from "../lib/colors.js";
20
23
 
21
24
  const CONFIG_REL = ".doctrina/verify.json";
22
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: ["json"], string: [] };
34
+
23
35
  export async function run(_positional, flags) {
24
36
  const projectRoot = process.cwd();
25
37
  if (!exists(path.join(projectRoot, ".doctrina"))) {
26
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
38
+ throw notADoctrinaProject();
27
39
  }
28
40
 
29
41
  const s = collectStatus(projectRoot);
30
42
 
31
43
  if (flagBool(flags, "json", false)) {
32
- console.log(JSON.stringify(s, null, 2));
44
+ emitJson("status", s);
33
45
  return 0;
34
46
  }
35
47