doctrina-cli 0.15.1 → 0.17.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 (93) hide show
  1. package/README.md +27 -72
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +27 -3
  4. package/src/commands/analyze.js +16 -212
  5. package/src/commands/change.js +144 -267
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +65 -21
  8. package/src/commands/close.js +349 -45
  9. package/src/commands/completion.js +8 -2
  10. package/src/commands/context.js +284 -111
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +66 -7
  14. package/src/commands/doctor.js +293 -130
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +17 -3
  17. package/src/commands/index-rebuild.js +55 -44
  18. package/src/commands/init.js +88 -16
  19. package/src/commands/intake.js +85 -99
  20. package/src/commands/intent.js +14 -1
  21. package/src/commands/metrics.js +65 -88
  22. package/src/commands/next.js +105 -147
  23. package/src/commands/prime.js +23 -91
  24. package/src/commands/report.js +40 -109
  25. package/src/commands/review.js +68 -36
  26. package/src/commands/search.js +18 -4
  27. package/src/commands/show.js +24 -9
  28. package/src/commands/skill.js +23 -39
  29. package/src/commands/spec.js +38 -11
  30. package/src/commands/status.js +69 -171
  31. package/src/commands/templates.js +35 -148
  32. package/src/commands/trace.js +36 -71
  33. package/src/commands/triage.js +4 -82
  34. package/src/commands/upgrade.js +31 -6
  35. package/src/commands/validate.js +44 -877
  36. package/src/commands/verify.js +60 -138
  37. package/src/commands/why.js +2 -2
  38. package/src/commands/work.js +243 -267
  39. package/src/index.js +139 -12
  40. package/src/lib/actions.js +490 -0
  41. package/src/lib/adapters.js +0 -11
  42. package/src/lib/adr-guard.js +19 -0
  43. package/src/lib/agent-changelog.js +133 -0
  44. package/src/lib/analysis.js +264 -0
  45. package/src/lib/args.js +14 -0
  46. package/src/lib/change-ops.js +133 -0
  47. package/src/lib/commands.js +135 -10
  48. package/src/lib/config.js +206 -0
  49. package/src/lib/constitution-model.js +102 -0
  50. package/src/lib/coverage-model.js +431 -0
  51. package/src/lib/criteria.js +23 -0
  52. package/src/lib/doc-model.js +370 -3
  53. package/src/lib/docs-impact.js +270 -27
  54. package/src/lib/ears.js +24 -1
  55. package/src/lib/flag-catalog.js +0 -16
  56. package/src/lib/fs-ops.js +58 -1
  57. package/src/lib/gates.js +231 -29
  58. package/src/lib/git.js +132 -0
  59. package/src/lib/index-json.js +42 -24
  60. package/src/lib/intake-model.js +155 -0
  61. package/src/lib/json-out.js +100 -2
  62. package/src/lib/ledger.js +192 -0
  63. package/src/lib/lexicon.js +228 -0
  64. package/src/lib/metrics-model.js +189 -0
  65. package/src/lib/names.js +42 -0
  66. package/src/lib/playbook.js +97 -0
  67. package/src/lib/project.js +105 -0
  68. package/src/lib/reproducibility.js +137 -0
  69. package/src/lib/runtime.js +191 -5
  70. package/src/lib/scan.js +328 -25
  71. package/src/lib/signoff.js +147 -0
  72. package/src/lib/snapshot.js +231 -0
  73. package/src/lib/spec-ops.js +72 -8
  74. package/src/lib/templates-model.js +289 -0
  75. package/src/lib/trace-model.js +95 -0
  76. package/src/lib/triage-model.js +121 -0
  77. package/src/lib/usage.js +6 -4
  78. package/src/lib/validation-model.js +1381 -0
  79. package/src/lib/version.js +22 -0
  80. package/src/lib/views.js +413 -0
  81. package/src/lib/work-model.js +102 -0
  82. package/templates/AGENTS.md.template +21 -28
  83. package/templates/README.md +35 -0
  84. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  85. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  86. package/templates/change/proposal.md.template +16 -0
  87. package/templates/change/tasks.md.template +3 -8
  88. package/templates/contract.md.template +2 -2
  89. package/templates/hooks/pre-commit.sample +16 -2
  90. package/templates/playbooks/bootstrap.md.template +47 -0
  91. package/templates/playbooks/chore.md.template +24 -0
  92. package/templates/playbooks/work.md.template +83 -0
  93. package/src/commands/constitution.js +0 -145
@@ -1,16 +1,17 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { spawnSync } from "node:child_process";
5
4
  import { readdirSync } from "node:fs";
6
5
  import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
7
6
  import { flagBool, flagString } from "../lib/args.js";
8
7
  import { c } from "../lib/colors.js";
9
- import { rankCapabilitiesByDiff } from "./work.js";
10
- import { parseDependsOn } from "../lib/scan.js";
11
- import { notADoctrinaProject } from "../lib/exit-codes.js";
12
- import { summarize as coverageSummary } from "./coverage.js";
13
- import { summarize as traceSummary } from "./trace.js";
8
+ import { rankCapabilitiesByDiff } from "../lib/work-model.js";
9
+ import { readLedger, churnByCapability } from "../lib/ledger.js";
10
+ import { changedFiles, windowCutoff, historyState, refExists } from "../lib/git.js";
11
+ import { dependentsOf } from "../lib/scan.js";
12
+ import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
13
+ import { summarize as coverageSummary } from "../lib/coverage-model.js";
14
+ import { summarize as traceSummary } from "../lib/trace-model.js";
14
15
 
15
16
  // Deterministic conformance review (review 2026-06-27 passive-user feature #3).
16
17
  // Given the working tree (or a diff against a ref), report STRUCTURAL breaks
@@ -25,6 +26,17 @@ import { summarize as traceSummary } from "./trace.js";
25
26
  // Flags this command accepts. Declared HERE, with the command, so
26
27
  // adding a command never requires editing the entrypoint — the gap that
27
28
  // let six flags ship undeclared and silently swallow a positional (C3).
29
+ // How far back the churn note looks, and how many landed changes make it
30
+ // worth saying at all. A window rather than "all time": a capability that
31
+ // moved nine times two years ago is history, not news.
32
+ const CHURN_WINDOW_DAYS = 60;
33
+ const CHURN_NOTABLE = 3;
34
+
35
+ // How many unclaimed files the orphan note names before it summarises the
36
+ // rest. Long enough to act on, short enough that a wide refactor does not
37
+ // bury every other finding under a file listing.
38
+ const ORPHAN_LIST_LIMIT = 5;
39
+
28
40
  export const flags = { boolean: ["json", "strict"], string: ["diff"] };
29
41
 
30
42
  export async function run(_positional, flags) {
@@ -35,7 +47,20 @@ export async function run(_positional, flags) {
35
47
  const strict = flagBool(flags, "strict", false);
36
48
  const against = flagString(flags, "diff"); // optional git ref to diff against
37
49
 
38
- const { all, sourceFiles } = changedFiles(projectRoot, against);
50
+ // A ref the git cannot resolve is a filter that matches nothing, and the
51
+ // review used to read it as "nothing changed" — exit 0, `--strict`
52
+ // included, on a tree where a valid ref reported breaks. A CI job running
53
+ // `doctrina review --diff main --strict` stayed green forever on a shallow
54
+ // clone with no local `main` (change 0092). Only asked inside a usable
55
+ // repository: outside one the command still stays silent rather than
56
+ // accusing.
57
+ if (against && historyState(projectRoot).usable && !refExists(projectRoot, against)) {
58
+ console.error(c.red("error:") + ` --diff names a ref this repository cannot resolve: "${against}"`);
59
+ console.error(c.gray("hint: ") + "check the branch or commit name — a ref that resolves to nothing is not an empty diff");
60
+ return EXIT.USAGE;
61
+ }
62
+
63
+ const { all, sourceFiles } = reviewScope(projectRoot, against);
39
64
  console.log(c.bold("Review") + c.gray(against ? ` — vs ${against}` : " — working-tree changes"));
40
65
  console.log("");
41
66
 
@@ -69,23 +94,39 @@ export async function run(_positional, flags) {
69
94
  // 1b. Dependents of touched capabilities (the machine-readable
70
95
  // **Depends on:** header): a spec that builds on something you changed
71
96
  // may silently no longer hold. Advisory — a pointer, not a verdict.
72
- const specsDir = path.join(projectRoot, ".doctrina", "specs");
73
- if (isDir(specsDir) && touched.size > 0) {
74
- for (const cap of readdirSync(specsDir).sort()) {
75
- if (touched.has(cap)) continue;
76
- const specPath = path.join(specsDir, cap, "spec.md");
77
- if (!isFile(specPath)) continue;
78
- const deps = parseDependsOn(read(specPath)).filter((d) => touched.has(d));
79
- if (deps.length > 0) {
80
- notes.push(`capability "${cap}" depends on touched ${deps.map((d) => `"${d}"`).join(", ")} — confirm it still holds (\`doctrina why ${cap}\`)`);
81
- }
97
+ for (const dep of dependentsOf(projectRoot, touched)) {
98
+ notes.push(`capability "${dep.capability}" depends on touched ${dep.dependsOn.map((d) => `"${d}"`).join(", ")} — confirm it still holds (\`doctrina why ${dep.capability}\`)`);
99
+ }
100
+
101
+ // 1c. How often each touched capability has moved lately, from the archive
102
+ // ledger (change 0046). Reported as a NUMBER and never as a verdict:
103
+ // frequent change can mean a spec that was drawn badly or a spec that
104
+ // is simply where the work is, and nothing here can tell those apart
105
+ // (ADR 0005). It is context for the human reading the review, not a
106
+ // finding — so it goes in the notes even when the count is high.
107
+ if (touched.size > 0) {
108
+ const since = windowCutoff(CHURN_WINDOW_DAYS);
109
+ const churn = churnByCapability(readLedger(projectRoot).entries, { since })
110
+ .filter((row) => touched.has(row.capability) && row.changes >= CHURN_NOTABLE);
111
+ for (const row of churn) {
112
+ notes.push(`capability "${row.capability}" landed ${row.changes} changes in the last ${CHURN_WINDOW_DAYS} days (last ${row.last}) — history, not a verdict: read it as "this area is moving", not "this area is wrong"`);
82
113
  }
83
114
  }
84
115
 
85
- // 2. New source files that map to no capability at all — code with no home
86
- // in any spec (a likely new, unspecced capability).
87
- if (ranked.length === 0 && sourceFiles.length > 0) {
88
- notes.push(`changed code maps to no existing capability spec — if this is a new capability, scaffold it: \`doctrina spec new <capability>\` (or \`doctrina work --from-diff\`)`);
116
+ // 2. Changed source files that map to no capability at all — code with no
117
+ // home in any spec. Reported PER FILE (change 0077): the old form fired
118
+ // only when the WHOLE diff matched nothing, so one incidental match —
119
+ // any file under `docs/`, which matches the `docs` capability because
120
+ // the directory is named after it — silenced the finding for every other
121
+ // file in the change. A review of an adapter change reported
122
+ // "Capabilities touched: docs" and said nothing about the adapter.
123
+ const orphans = sourceFiles.filter(
124
+ (f) => rankCapabilitiesByDiff(projectRoot, [f], { limit: 1 }).length === 0,
125
+ );
126
+ if (orphans.length > 0) {
127
+ const shown = orphans.slice(0, ORPHAN_LIST_LIMIT).map((f) => `\`${f}\``).join(", ");
128
+ const more = orphans.length > ORPHAN_LIST_LIMIT ? `, and ${orphans.length - ORPHAN_LIST_LIMIT} more` : "";
129
+ notes.push(`${orphans.length} changed file(s) belong to no capability: ${shown}${more} — claim them with a \`**Source:**\` header on the owning spec, or scaffold the capability they are (\`doctrina spec new <capability>\`)`);
89
130
  }
90
131
 
91
132
  // 3. Coverage — acceptance criteria whose cited proof is missing/skipped.
@@ -133,21 +174,12 @@ export async function run(_positional, flags) {
133
174
  }
134
175
 
135
176
  // Changed paths between a base (a git ref, or HEAD + untracked for the working
136
- // tree) and now. Returns the full list and the subset outside .doctrina/.
137
- function changedFiles(projectRoot, against) {
138
- const run = (args) => {
139
- const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
140
- if (r.error || r.status !== 0) return [];
141
- return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
142
- };
143
- const set = new Set();
144
- if (against) {
145
- for (const f of run(["diff", "--name-only", against])) set.add(f);
146
- } else {
147
- for (const f of run(["diff", "--name-only", "HEAD"])) set.add(f);
148
- for (const f of run(["ls-files", "--others", "--exclude-standard"])) set.add(f);
149
- }
150
- const all = [...set];
177
+ // tree) and now, split into the full list and the subset outside .doctrina/.
178
+ // The git question itself goes through the one door (lib/git.js).
179
+ function reviewScope(projectRoot, against) {
180
+ // Against a named ref, untracked files are noise: the question is what this
181
+ // branch changed, not what is lying around uncommitted.
182
+ const all = changedFiles(projectRoot, { since: against, untracked: !against }).files;
151
183
  const sourceFiles = all.filter((f) => !f.replace(/\\/g, "/").startsWith(".doctrina/"));
152
184
  return { all, sourceFiles };
153
185
  }
@@ -4,6 +4,7 @@ import process from "node:process";
4
4
  import { exists, isDir, read, relPath, walk } from "../lib/fs-ops.js";
5
5
  import { flagBool } from "../lib/args.js";
6
6
  import { c } from "../lib/colors.js";
7
+ import { fold } from "../lib/lexicon.js";
7
8
  import { notADoctrinaProject } from "../lib/exit-codes.js";
8
9
 
9
10
  // Category-aware full-text search across the artifact tree. Answers
@@ -25,7 +26,13 @@ const MAX_MATCHES_PER_FILE = 5;
25
26
  export const flags = { boolean: ["json", "archive"], string: [] };
26
27
 
27
28
  export async function run(positional, flags) {
28
- const terms = positional.map((t) => t.toLowerCase()).filter(Boolean);
29
+ // Folded, not merely lowercased (third audit, finding 8). `search` matched
30
+ // raw substrings, so an accent decided the answer: `search patrimonio`
31
+ // found nothing in a spec that says "patrimônio" — and typing without the
32
+ // accent is the common case, not the rare one. The shared lexicon (ADR
33
+ // 0040) already folds for `work` and `context --for`; `search` was the
34
+ // one retrieval surface still deciding on its own.
35
+ const terms = positional.map((t) => fold(t)).filter(Boolean);
29
36
  if (terms.length === 0) {
30
37
  console.error(c.red("error:") + " search requires at least one term");
31
38
  return 2;
@@ -64,7 +71,7 @@ export async function run(positional, flags) {
64
71
  const hits = [];
65
72
  for (let i = 0; i < lines.length; i++) {
66
73
  const raw = lines[i];
67
- const low = raw.toLowerCase();
74
+ const low = fold(raw);
68
75
  if (terms.every((t) => low.includes(t))) {
69
76
  hits.push({ line: i + 1, text: raw.trim(), score: lineScore(raw, low, phrase) });
70
77
  }
@@ -92,7 +99,14 @@ export async function run(positional, flags) {
92
99
 
93
100
  if (totalMatches === 0) {
94
101
  console.log(c.gray(`no matches for "${positional.join(" ")}"${includeArchive ? "" : " (archive excluded; pass --archive to include it)"}`));
95
- return 1;
102
+ // Not finding is the answer, not a failure. Exit 1 is GATE in the
103
+ // five-class contract — "fix the work, retry the same command" — and
104
+ // this command fixes nothing. The `insight` spec says it outright: a
105
+ // VIEW assembles what is there and never refuses anything, and the
106
+ // other six listings (spec, decision, skill, contract, templates,
107
+ // intent) all exit 0 on an empty result. The grep convention is a
108
+ // shell habit; this CLI declared its own contract (change 0093).
109
+ return 0;
96
110
  }
97
111
  console.log(c.gray(`${totalMatches} match${totalMatches === 1 ? "" : "es"} in ${filesWithMatches} file${filesWithMatches === 1 ? "" : "s"}`));
98
112
  return 0;
@@ -116,7 +130,7 @@ function lineScore(raw, low, phrase) {
116
130
  // A file whose name carries the query is almost always the right one;
117
131
  // reward a full filename match more than a partial one.
118
132
  function filenameBonus(rel, terms) {
119
- const base = rel.toLowerCase();
133
+ const base = fold(rel);
120
134
  if (terms.every((t) => base.includes(t))) return 3;
121
135
  return terms.some((t) => base.includes(t)) ? 1 : 0;
122
136
  }
@@ -6,7 +6,8 @@ import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
6
6
  import { parseAcceptanceCriteria } from "../lib/criteria.js";
7
7
  import { suggest } from "../lib/suggest.js";
8
8
  import { c } from "../lib/colors.js";
9
- import { notADoctrinaProject } from "../lib/exit-codes.js";
9
+ import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
10
+ import { maskComments } from "../lib/doc-model.js";
10
11
 
11
12
  // Point reads. An agent that needs ONE requirement re-reads a whole spec —
12
13
  // hundreds of lines for a two-line fact. `show` resolves a compact reference
@@ -57,7 +58,7 @@ function showAdr(projectRoot, num) {
57
58
  const file = isDir(dir) ? readdirSync(dir).find((f) => f.startsWith(`${num}-`) && f.endsWith(".md")) : null;
58
59
  if (!file) {
59
60
  console.error(c.red("error:") + ` no ADR ${num} under .doctrina/decisions/`);
60
- return 1;
61
+ return EXIT.USAGE;
61
62
  }
62
63
  const full = path.join(dir, file);
63
64
  console.log(c.gray(`— ${relPath(projectRoot, full)} —`));
@@ -79,7 +80,7 @@ function loadSpec(projectRoot, cap) {
79
80
 
80
81
  function showSpecHead(projectRoot, cap) {
81
82
  const spec = loadSpec(projectRoot, cap);
82
- if (!spec) return 1;
83
+ if (!spec) return EXIT.USAGE;
83
84
  const lines = spec.text.split(/\r?\n/);
84
85
  const out = [];
85
86
  let mode = "head"; // header block -> seek Purpose -> purpose body -> stop
@@ -98,14 +99,14 @@ function showSpecHead(projectRoot, cap) {
98
99
 
99
100
  function showSpecItem(projectRoot, cap, kind, n) {
100
101
  const spec = loadSpec(projectRoot, cap);
101
- if (!spec) return 1;
102
+ if (!spec) return EXIT.USAGE;
102
103
 
103
104
  if (kind === "C") {
104
105
  const criteria = parseAcceptanceCriteria(spec.text);
105
106
  const crit = criteria.find((r) => r.n === n);
106
107
  if (!crit) {
107
108
  console.error(c.red("error:") + ` ${cap} has no acceptance criterion #${n} (it declares ${criteria.length})`);
108
- return 1;
109
+ return EXIT.USAGE;
109
110
  }
110
111
  console.log(c.gray(`— ${cap}-C${n} · ${relPath(projectRoot, spec.specPath)} · ## Acceptance criteria —`));
111
112
  console.log(`${n}. ${crit.body}`);
@@ -119,7 +120,7 @@ function showSpecItem(projectRoot, cap, kind, n) {
119
120
  const req = reqs[n - 1];
120
121
  if (!req) {
121
122
  console.error(c.red("error:") + ` ${cap} has no requirement R${n} (it declares ${reqs.length})`);
122
- return 1;
123
+ return EXIT.USAGE;
123
124
  }
124
125
  console.log(c.gray(`— ${cap}-R${n} · ${relPath(projectRoot, spec.specPath)} · ${req.section} —`));
125
126
  console.log(req.text);
@@ -129,7 +130,13 @@ function showSpecItem(projectRoot, cap, kind, n) {
129
130
  // Top-level bullets of the "## Requirements (EARS)" section, in file order,
130
131
  // each with its `### <grammar>` sub-section name and continuation lines.
131
132
  export function parseRequirements(text) {
132
- const lines = text.split(/\r?\n/);
133
+ // The scaffolded spec writes the five-line EARS legend as bullets inside
134
+ // an HTML comment under `## Requirements (EARS)`. Counted as content, the
135
+ // legend became R1..R5 and the capability's first real requirement was R6
136
+ // — on every spec `doctrina spec new` had ever created. Comments are
137
+ // blanked by position (same offsets, same line count), so the numbering
138
+ // below is over authored bullets only.
139
+ const lines = maskComments(text).split(/\r?\n/);
133
140
  const out = [];
134
141
  let inSection = false;
135
142
  let sub = "";
@@ -175,6 +182,14 @@ Point-read one artifact fragment instead of a whole file:
175
182
  doctrina show 0007 ADR 0007 (the whole decision)
176
183
  doctrina show cli the spec's header block + Purpose only
177
184
 
178
- R-refs are positional (they shift when a requirement is inserted above);
179
- C-refs use the criteria's own numbers. Read-only.
185
+ R-refs are positional across the WHOLE spec, in file order, and they shift
186
+ when a requirement is inserted above. C-refs use the criteria's own numbers.
187
+ Both count authored content only — a bullet inside an HTML comment (the
188
+ scaffold's EARS legend, for one) is annotation and is never numbered.
189
+
190
+ A spec delta's replace-requirement <section> <n> numbers differently, and
191
+ on purpose: it counts WITHIN one "### <section>", so the number stays put
192
+ when another section grows. doctrina show <cap>-RN prints the section it
193
+ landed in, which is what turns an R-ref into the delta's pair of coordinates.
194
+ Read-only.
180
195
  `;
@@ -2,7 +2,6 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { readdirSync } from "node:fs";
5
- import { spawnSync } from "node:child_process";
6
5
  import { exists, isDir, isFile, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
7
6
  import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
8
7
  import * as idx from "../lib/index-json.js";
@@ -11,6 +10,10 @@ import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
11
10
  import { c } from "../lib/colors.js";
12
11
  import { suggest } from "../lib/suggest.js";
13
12
  import { notADoctrinaProject } from "../lib/exit-codes.js";
13
+ import { parseFrontmatter } from "../lib/doc-model.js";
14
+ import { git, GIT_STATE } from "../lib/git.js";
15
+ import { FIX_SHAPED, FIX_SHAPED_SUBJECT, fold, skillSlug } from "../lib/lexicon.js";
16
+ import { artifactNameError } from "../lib/names.js";
14
17
 
15
18
  const SUBCOMMANDS = ["new", "list", "sync", "suggest"];
16
19
 
@@ -46,15 +49,7 @@ export async function run(positional, flags) {
46
49
  // --write it scaffolds a stub per candidate, pre-seeded from the source so the
47
50
  // human/agent only fills the body. Both sources are a pattern match — a hint,
48
51
  // never a decision (ADR 0005); skills are still authored, never generated.
49
- const FIX_SHAPED = /(?:^|-)(fix|bug|hotfix|patch|parse|parsing|tolerate|workaround|race|deadlock|flaky|retry|escape|sanitize|sanitise)(?:-|$)/;
50
52
 
51
- // The same idea applied to a commit subject. Narrower than FIX_SHAPED on
52
- // purpose: a deliberate folder slug tolerates more noise than a free-form
53
- // subject line. Matches conventional fix-type prefixes (fix:, fix(scope):,
54
- // bug:, hotfix:, patch:, and the free-form "Fix the…") plus the strongly
55
- // fix-flavoured debugging keywords — never feat:/refactor:/docs:/chore:.
56
- const FIX_SHAPED_SUBJECT =
57
- /^(?:fix|bug|hotfix|patch)\b|\b(?:tolerate|workaround|deadlock|flaky|race condition|retry|sanitiz|sanitis)/i;
58
53
 
59
54
  // How far back the git source looks when --since is not given, and how many
60
55
  // candidates we print before collapsing the rest into a "+N more" line.
@@ -109,7 +104,7 @@ export function draftFromError(text) {
109
104
  // Distinctive words, most frequent first — the fallback when an error
110
105
  // carries no structure at all.
111
106
  const counts = new Map();
112
- for (const w of raw.toLowerCase().match(/[a-z][a-z0-9_-]{3,}/g) ?? []) {
107
+ for (const w of fold(raw).match(/[a-z][a-z0-9_-]{3,}/g) ?? []) {
113
108
  if (ERROR_STOP.has(w)) continue;
114
109
  counts.set(w, (counts.get(w) ?? 0) + 1);
115
110
  }
@@ -130,8 +125,8 @@ export function draftFromError(text) {
130
125
  // also mentions do not both land in the name.
131
126
  const seedTokens = [];
132
127
  const seenToken = new Set();
133
- for (const token of [...identifiers.map((i) => i.toLowerCase()), ...keywords]) {
134
- const norm = token.replace(/[^a-z0-9]/g, "");
128
+ for (const token of [...identifiers.map((i) => fold(i)), ...keywords]) {
129
+ const norm = fold(token).replace(/[^a-z0-9]/g, "");
135
130
  if (norm === "" || seenToken.has(norm)) continue;
136
131
  seenToken.add(norm);
137
132
  seedTokens.push(token);
@@ -349,7 +344,7 @@ function skillSuggest(args, flags) {
349
344
  idx.save(projectRoot, index);
350
345
  }
351
346
  console.log("");
352
- console.log(c.green("ok") + ` scaffolded ${created} skill stub${created === 1 ? "" : "s"} — fill the description/when/body, then \`doctrina skill sync\``);
347
+ console.log(c.green("ok") + ` scaffolded ${created} skill stub${created === 1 ? "" : "s"} — fill the description/when/body, then \`doctrina index rebuild\``);
353
348
  return 0;
354
349
  }
355
350
 
@@ -379,8 +374,8 @@ function gitFixCommits(projectRoot, { since, limit }) {
379
374
  if (since) args.push(`${since}..HEAD`);
380
375
  else args.push("-n", String(limit));
381
376
 
382
- const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
383
- if (r.error || r.status !== 0 || !r.stdout) return [];
377
+ const r = git(projectRoot, args);
378
+ if (r.state !== GIT_STATE.OK || !r.stdout) return [];
384
379
 
385
380
  /** @type {SkillCandidate[]} */
386
381
  const out = [];
@@ -405,16 +400,9 @@ function gitFixCommits(projectRoot, { since, limit }) {
405
400
  }
406
401
 
407
402
  // Derive a skill slug from a commit subject: drop a conventional-commit
408
- // "type(scope): " prefix, then reuse skillSlug for the lowercase/hyphenate/cap.
403
+ // "type(scope): " prefix and hand the rest to the shared slug, which folds.
409
404
  function commitSlug(subject) {
410
- const stripped = subject.replace(/^[a-z]+(?:\([^)]*\))?!?\s*:\s*/i, "");
411
- return skillSlug(stripped.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""));
412
- }
413
-
414
- function skillSlug(id) {
415
- // Reuse the change id as the skill slug, trimmed to a sane length.
416
- const slug = id.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
417
- return slug.length > 40 ? slug.slice(0, 40).replace(/-+$/g, "") : (slug || "lesson");
405
+ return skillSlug(subject.replace(/^[a-z]+(?:\([^)]*\))?!?\s*:\s*/i, ""));
418
406
  }
419
407
 
420
408
  // Token-overlap similarity between two kebab slugs (Jaccard over meaningful
@@ -449,8 +437,9 @@ function firstWhyLine(proposalText) {
449
437
 
450
438
  function skillNew(args, flags) {
451
439
  const name = args[0];
452
- if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) {
453
- console.error(c.red("error:") + " skill name must be lowercase letters, digits, or hyphens (e.g. \"db-migration\")");
440
+ const nameError = artifactNameError(name, "skill name");
441
+ if (nameError) {
442
+ console.error(c.red("error:") + ` ${nameError} (e.g. "db-migration")`);
454
443
  return 2;
455
444
  }
456
445
  const force = flagBool(flags, "force", false);
@@ -486,7 +475,7 @@ function skillNew(args, flags) {
486
475
  idx.save(projectRoot, index);
487
476
  console.log(c.green("indexed") + ` skill "${name}"`);
488
477
  console.log("");
489
- console.log(c.gray("hint: ") + "fill the description and when frontmatter fields, then run `doctrina skill sync` to update index.json");
478
+ console.log(c.gray("hint: ") + "fill the description and when frontmatter fields, then run `doctrina index rebuild` to update index.json");
490
479
  return 0;
491
480
  }
492
481
 
@@ -512,6 +501,13 @@ function skillSync() {
512
501
  console.log(c.yellow("skip ") + ` ${id} (no description frontmatter)`);
513
502
  continue;
514
503
  }
504
+ // A description still in the template's `<...>` form is not "up to
505
+ // date" (change 0111): it is a file nobody wrote yet, and mirroring it
506
+ // into the index is what put the placeholder in every context pack.
507
+ if (/^<[^<>]*>$/.test(desc.trim())) {
508
+ console.log(c.yellow("scaffold") + ` ${id} — description is still the scaffold's placeholder; write description/when, then sync`);
509
+ continue;
510
+ }
515
511
  const entry = (index.artifacts.skills ?? []).find((s) => s.id === id);
516
512
  if (!entry) {
517
513
  idx.addSkill(index, {
@@ -568,15 +564,6 @@ function skillList() {
568
564
  return 0;
569
565
  }
570
566
 
571
- function parseFrontmatter(text, key) {
572
- // Match frontmatter blocks bounded by `---` lines at start of file.
573
- const fmMatch = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
574
- if (!fmMatch) return null;
575
- const block = fmMatch[1];
576
- const lineRe = new RegExp(`^${key}\\s*:\\s*(.+)$`, "m");
577
- const m = block.match(lineRe);
578
- return m ? m[1].trim() : null;
579
- }
580
567
 
581
568
  function ensureDoctrinaProject(projectRoot) {
582
569
  if (!exists(path.join(projectRoot, ".doctrina"))) {
@@ -618,6 +605,3 @@ Skills are written by humans, not generated. See docs/en/skills.md
618
605
  for the design rationale and the distinction from specs / AGENTS.md /
619
606
  the rejected memory/ folder.
620
607
  `;
621
-
622
- // Re-export the frontmatter parser so validate.js can reuse it.
623
- export { parseFrontmatter };
@@ -11,7 +11,9 @@ import { today } from "../lib/dates.js";
11
11
  import { flagBool, flagString } from "../lib/args.js";
12
12
  import { c } from "../lib/colors.js";
13
13
  import { suggest } from "../lib/suggest.js";
14
- import { notADoctrinaProject } from "../lib/exit-codes.js";
14
+ import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
15
+ import { derivedImplementations } from "../lib/coverage-model.js";
16
+ import { artifactNameError } from "../lib/names.js";
15
17
 
16
18
  const SUBCOMMANDS = ["new", "list", "set"];
17
19
 
@@ -34,8 +36,9 @@ export async function run(positional, flags) {
34
36
  if (sub === "set") return specSet(positional.slice(1), flags);
35
37
 
36
38
  const capability = positional[1];
37
- if (!capability || !/^[a-z][a-z0-9-]*$/.test(capability)) {
38
- console.error(c.red("error:") + " capability must be lowercase letters, digits, or hyphens (e.g. \"core\", \"templates\").");
39
+ const nameError = artifactNameError(capability, "capability");
40
+ if (nameError) {
41
+ console.error(c.red("error:") + ` ${nameError} (e.g. "core", "templates")`);
39
42
  return 2;
40
43
  }
41
44
 
@@ -135,7 +138,7 @@ function specSet(args, flags) {
135
138
  const specPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
136
139
  if (!isFile(specPath)) {
137
140
  console.error(c.red("error:") + ` no spec at ${relPath(projectRoot, specPath)} (create it with \`doctrina spec new ${capability}\`)`);
138
- return 1;
141
+ return EXIT.USAGE;
139
142
  }
140
143
 
141
144
  const impl = flagString(flags, "implementation");
@@ -152,32 +155,56 @@ function specSet(args, flags) {
152
155
  let text = read(specPath);
153
156
  const applied = [];
154
157
  const errors = [];
158
+ // An error names what to correct: the INVOCATION (a value out of domain, a
159
+ // criterion that does not exist, a malformed flag) or the SPEC (a header
160
+ // or section it lacks). Only the second is fixed by editing the work.
161
+ const refused = (msg) => errors.push({ msg, argument: true });
155
162
  const apply = (res) => {
156
- if (res.error) errors.push(res.error);
163
+ if (res.error) errors.push({ msg: res.error, argument: res.cause === "argument" });
157
164
  else { text = res.text; applied.push(res.summary); }
158
165
  };
159
166
  // Order: headers, then version, then criterion — independent edits, all or
160
167
  // none (a failed op leaves the spec untouched, like `change apply`).
161
168
  if (status !== undefined) apply(setHeader(text, "Status", status));
162
- if (impl !== undefined) apply(setHeader(text, "Implementation", impl));
169
+ if (impl !== undefined) {
170
+ // `--implementation auto` reads the value off the coverage arithmetic
171
+ // instead of the author's memory (audit finding F10): coverage already
172
+ // knows how many of this spec's criteria cite proof that resolves, and
173
+ // that is the definition of "verified". A spec with no acceptance
174
+ // criteria has nothing to derive from, so `auto` refuses rather than
175
+ // guessing — the spec is left untouched, like any other failed op.
176
+ if (String(impl).trim().toLowerCase() === "auto") {
177
+ const derivedValue = derivedImplementations(projectRoot, { only: new Set([capability]) })
178
+ .get(capability)?.derived;
179
+ if (!derivedValue) {
180
+ errors.push({ msg: `--implementation auto needs acceptance criteria to derive from; ${capability} declares none`, argument: false });
181
+ } else {
182
+ apply(setHeader(text, "Implementation", derivedValue));
183
+ }
184
+ } else {
185
+ apply(setHeader(text, "Implementation", impl));
186
+ }
187
+ }
163
188
  if (bump !== undefined) {
164
- if (!["major", "minor", "patch"].includes(bump)) errors.push(`--bump needs major|minor|patch (got "${bump}")`);
189
+ if (!["major", "minor", "patch"].includes(bump)) refused(`--bump needs major|minor|patch (got "${bump}")`);
165
190
  else apply(bumpVersion(text, bump));
166
191
  }
167
192
  if (versionExplicit !== undefined) {
168
- if (!/^\d+\.\d+\.\d+$/.test(versionExplicit)) errors.push(`--version needs semver X.Y.Z (got "${versionExplicit}")`);
193
+ if (!/^\d+\.\d+\.\d+$/.test(versionExplicit)) refused(`--version needs semver X.Y.Z (got "${versionExplicit}")`);
169
194
  else apply(setHeader(text, "Version", versionExplicit));
170
195
  }
171
196
  if (criterion !== undefined) {
172
197
  const m = String(criterion).match(/^(\d+)\s*:\s*(.+)$/);
173
- if (!m) errors.push(`--criterion needs "<n>:<mark>" (got "${criterion}")`);
198
+ if (!m) refused(`--criterion needs "<n>:<mark>" (got "${criterion}")`);
174
199
  else apply(setCriterionMark(text, Number(m[1]), m[2].trim()));
175
200
  }
176
201
 
177
202
  if (errors.length > 0) {
178
203
  console.error(c.red("error:") + ` ${errors.length} operation error${errors.length === 1 ? "" : "s"} — spec left untouched:`);
179
- for (const e of errors) console.error(` - ${e}`);
180
- return 1;
204
+ for (const e of errors) console.error(` - ${e.msg}`);
205
+ // Retrying an invocation error unchanged never succeeds (ADR 0018), so
206
+ // one in the list is enough to answer USAGE.
207
+ return errors.some((e) => e.argument) ? EXIT.USAGE : EXIT.GATE;
181
208
  }
182
209
 
183
210
  // Stamp Last updated when the header exists (best-effort, never an error).