doctrina-cli 0.16.0 → 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 (60) hide show
  1. package/README.md +27 -72
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +3 -2
  4. package/src/commands/analyze.js +8 -0
  5. package/src/commands/change.js +28 -77
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +65 -10
  8. package/src/commands/completion.js +8 -2
  9. package/src/commands/context.js +6 -4
  10. package/src/commands/decision.js +12 -0
  11. package/src/commands/doctor.js +1 -1
  12. package/src/commands/hooks.js +6 -1
  13. package/src/commands/index-rebuild.js +52 -2
  14. package/src/commands/init.js +19 -4
  15. package/src/commands/intake.js +55 -7
  16. package/src/commands/intent.js +14 -1
  17. package/src/commands/metrics.js +13 -5
  18. package/src/commands/next.js +1 -1
  19. package/src/commands/prime.js +4 -5
  20. package/src/commands/report.js +9 -20
  21. package/src/commands/skill.js +8 -15
  22. package/src/commands/spec.js +13 -7
  23. package/src/commands/status.js +39 -20
  24. package/src/commands/templates.js +4 -10
  25. package/src/commands/upgrade.js +31 -6
  26. package/src/commands/validate.js +32 -8
  27. package/src/commands/work.js +10 -5
  28. package/src/index.js +34 -6
  29. package/src/lib/actions.js +30 -24
  30. package/src/lib/args.js +14 -0
  31. package/src/lib/change-ops.js +3 -1
  32. package/src/lib/commands.js +88 -15
  33. package/src/lib/coverage-model.js +40 -1
  34. package/src/lib/doc-model.js +47 -6
  35. package/src/lib/docs-impact.js +74 -0
  36. package/src/lib/ears.js +24 -1
  37. package/src/lib/gates.js +53 -9
  38. package/src/lib/index-json.js +40 -5
  39. package/src/lib/intake-model.js +71 -2
  40. package/src/lib/lexicon.js +38 -1
  41. package/src/lib/metrics-model.js +30 -0
  42. package/src/lib/project.js +49 -1
  43. package/src/lib/scan.js +126 -14
  44. package/src/lib/snapshot.js +23 -1
  45. package/src/lib/spec-ops.js +24 -5
  46. package/src/lib/templates-model.js +35 -8
  47. package/src/lib/validation-model.js +116 -34
  48. package/src/lib/views.js +4 -1
  49. package/src/lib/work-model.js +25 -0
  50. package/templates/AGENTS.md.template +3 -4
  51. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  52. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  53. package/templates/change/proposal.md.template +15 -0
  54. package/templates/change/tasks.md.template +3 -8
  55. package/templates/contract.md.template +2 -2
  56. package/templates/hooks/pre-commit.sample +10 -0
  57. package/templates/playbooks/bootstrap.md.template +6 -2
  58. package/templates/playbooks/chore.md.template +9 -4
  59. package/templates/playbooks/work.md.template +10 -3
  60. package/src/commands/constitution.js +0 -68
@@ -152,8 +152,20 @@ function decisionSupersede(args) {
152
152
  });
153
153
  // Inject Supersedes link
154
154
  body = body.replace(/(\*\*Supersedes:\*\*)\s+—/, `$1 ${padded}`);
155
+ // Carry the predecessor's Scope. A successor built from the bare template
156
+ // had none, so it was GLOBAL: refining a decision that governed one
157
+ // capability, then accepting the refinement, loaded it into every
158
+ // capability's pack — ones the original never governed — and nothing said
159
+ // so. A replacement decides the same subject; its scope starts where the
160
+ // predecessor's was, written into the file where the author can widen it
161
+ // on purpose (ADR 0022: a pack is retrieval, not a dump).
162
+ const inheritedScope = parseAdrScope(oldText);
163
+ if (inheritedScope.length > 0) body = insertScopeHeader(body, inheritedScope) ?? body;
155
164
  write(newPath, body, { force: false });
156
165
  console.log(c.green("created") + ` ${relPath(projectRoot, newPath)}`);
166
+ if (inheritedScope.length > 0) {
167
+ console.log(c.gray("scope: ") + `inherited from ${padded} — ${inheritedScope.join(", ")} (edit the Scope: header to change it)`);
168
+ }
157
169
 
158
170
  // Mutate ONLY the Status: and Superseded by: headers of the old ADR.
159
171
  // Both go through the document model, so an ADR written with a slightly
@@ -188,7 +188,7 @@ export async function run(_positional, _flags) {
188
188
  ? tmplFindings[0].message
189
189
  : `${tmplFindings.length} template findings`;
190
190
  const remedy = [...byRemedy.keys()].filter((k) => k !== "(manual repair)");
191
- row("warn", "templates", summary, remedy.length === 1 ? remedy[0] : "doctrina templates check (each finding names its own fix)");
191
+ row("warn", "templates", summary, remedy.length === 1 ? remedy[0] : "doctrina upgrade (each finding names its own fix)");
192
192
  warningsTotal += 1;
193
193
  },
194
194
 
@@ -10,6 +10,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
10
10
  import { flagBool } from "../lib/args.js";
11
11
  import { c } from "../lib/colors.js";
12
12
  import { suggest } from "../lib/suggest.js";
13
+ import { EXIT } from "../lib/exit-codes.js";
13
14
 
14
15
  const SUBCOMMANDS = ["install"];
15
16
 
@@ -33,8 +34,12 @@ export async function run(positional, flags) {
33
34
  const projectRoot = process.cwd();
34
35
 
35
36
  if (!exists(path.join(projectRoot, ".git"))) {
37
+ // A PRECONDITION, not a gate: nothing was measured and found wanting,
38
+ // the place to put the hook simply does not exist yet. Class 1 told an
39
+ // agent to fix its work and retry the same command, which never clears.
36
40
  console.error(c.red("error:") + " not a git repository (no .git/ in cwd)");
37
- return 1;
41
+ console.error(c.gray("hint: ") + "run `git init` here, then `doctrina hooks install`");
42
+ return EXIT.PRECONDITION;
38
43
  }
39
44
 
40
45
  const hooksDir = path.join(projectRoot, ".git", "hooks");
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { exists } from "../lib/fs-ops.js";
5
5
  import * as idx from "../lib/index-json.js";
6
- import { collectIndexDrift } from "../lib/scan.js";
6
+ import { collectIndexDrift, collectStagedIndexDrift } from "../lib/scan.js";
7
7
  import { today } from "../lib/dates.js";
8
8
  import { flagBool } from "../lib/args.js";
9
9
  import { c } from "../lib/colors.js";
@@ -15,7 +15,7 @@ const SUBCOMMANDS = ["rebuild"];
15
15
  // Flags this command accepts. Declared HERE, with the command, so
16
16
  // adding a command never requires editing the entrypoint — the gap that
17
17
  // let six flags ship undeclared and silently swallow a positional (C3).
18
- export const flags = { boolean: ["json", "check"], string: [] };
18
+ export const flags = { boolean: ["json", "check", "staged"], string: [] };
19
19
 
20
20
  export async function run(positional, flags) {
21
21
  const sub = positional[0];
@@ -32,11 +32,21 @@ export async function run(positional, flags) {
32
32
 
33
33
  function rebuild(flags) {
34
34
  const check = flagBool(flags, "check", false);
35
+ const staged = flagBool(flags, "staged", false);
35
36
  const projectRoot = process.cwd();
36
37
  if (!exists(path.join(projectRoot, ".doctrina"))) {
37
38
  throw notADoctrinaProject();
38
39
  }
39
40
 
41
+ if (staged) {
42
+ if (!check) {
43
+ console.error(c.red("error:") + " --staged reads the commit, so it can only check; it never writes");
44
+ console.error(c.gray("hint: ") + "run `doctrina index rebuild --check --staged`");
45
+ return 2;
46
+ }
47
+ return checkStaged(projectRoot);
48
+ }
49
+
40
50
  const state = collectIndexDrift(projectRoot);
41
51
  if (state.unreadable) {
42
52
  if (check) {
@@ -70,6 +80,39 @@ function rebuild(flags) {
70
80
  return 0;
71
81
  }
72
82
 
83
+ // The drift check, asked of what git will record. `index.json` is derived
84
+ // from the WHOLE tree, so it cannot describe a subset of it: stage part of
85
+ // `.doctrina/` and the index riding along names artifacts the commit does
86
+ // not carry. Every other gate reads the working tree, where those artifacts
87
+ // are still sitting, so none of them can see it.
88
+ function checkStaged(projectRoot) {
89
+ const state = collectStagedIndexDrift(projectRoot);
90
+ if (!state.applicable) {
91
+ console.log(c.green("ok") + ` nothing to check — ${state.reason}`);
92
+ return 0;
93
+ }
94
+ if (state.ok) {
95
+ console.log(c.green("ok") + " the staged index matches the staged tree");
96
+ return 0;
97
+ }
98
+ if (state.reason) {
99
+ console.error(c.red("error:") + ` ${state.reason}`);
100
+ return 1;
101
+ }
102
+ for (const p of state.missing) {
103
+ console.log(c.yellow("staged: ") + `${p} — named by the staged index, absent from the commit`);
104
+ }
105
+ for (const p of state.unindexed) {
106
+ console.log(c.yellow("staged: ") + `${p} — in the commit, absent from the staged index`);
107
+ }
108
+ const n = state.missing.length + state.unindexed.length;
109
+ console.log("");
110
+ console.log(c.red("fail") + ` the staged index does not describe this commit (${n} difference${n === 1 ? "" : "s"})`);
111
+ console.log(c.gray("hint: ") + "stage the artifacts above with the index, or unstage the index — "
112
+ + "index.json is derived from the whole tree, so a partial commit of .doctrina/ cannot carry a matching one");
113
+ return 1;
114
+ }
115
+
73
116
  export const help = `
74
117
  Usage: doctrina index <subcommand>
75
118
 
@@ -80,6 +123,13 @@ Subcommands:
80
123
  source of truth; the index is derived.
81
124
  rebuild --check Write nothing; exit 1 with a drift summary when the
82
125
  index no longer matches the tree. CI-friendly.
126
+ rebuild --check --staged
127
+ Ask the same question of the COMMIT rather than the
128
+ disk: every artifact the staged index names must be in
129
+ the commit, and every staged artifact must be in the
130
+ index. A partial commit of .doctrina/ cannot satisfy
131
+ both, because the index is derived from the whole tree.
132
+ Exits 0 when nothing relevant is staged.
83
133
 
84
134
  Fields with no on-disk source (project name, framework_version, product
85
135
  metadata) are carried over from the existing index.
@@ -14,7 +14,7 @@ import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
14
14
  import { c } from "../lib/colors.js";
15
15
  import { EXIT } from "../lib/exit-codes.js";
16
16
  import { ask, isInteractive } from "../lib/prompt.js";
17
- import { looksLikePath, writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake } from "../lib/intake-model.js";
17
+ import { looksLikePath, writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake, nextStepAfterInit } from "../lib/intake-model.js";
18
18
 
19
19
  const SUPPORTED_AGENTS = [
20
20
  "claude",
@@ -107,9 +107,20 @@ export async function run(_positional, flags) {
107
107
  // isTTY check the adapter prompt already had. A non-interactive caller
108
108
  // that forgot --non-interactive read EOF, got an empty answer, and
109
109
  // scaffolded a project with a blank description — silently (C9).
110
+ // At a terminal, ONE question, and its answer is the intake (change 0173):
111
+ // it was asked as a one-line description, written into product.md, and
112
+ // then `next` asked for the same thing again as an intake. A person
113
+ // describes the project once.
114
+ let intakeFromTty = false;
110
115
  if (!description && !nonInteractive) {
111
116
  if (isInteractive()) {
112
- description = await ask("One-sentence project description:");
117
+ const answer = await ask("Describe the project — what it is, who it is for, what it must do:");
118
+ if (answer && answer.trim() !== "") {
119
+ intakeBody = answer.trim();
120
+ intakeSource = "typed at `doctrina init`";
121
+ intakeFromTty = true;
122
+ description = firstLineSummary(intakeBody);
123
+ }
113
124
  }
114
125
  if (!description) {
115
126
  console.error(c.red("error:") + " no project description given and no terminal to ask on");
@@ -293,7 +304,11 @@ export async function run(_positional, flags) {
293
304
 
294
305
  console.log("");
295
306
  console.log(c.bold("Doctrina initialised."));
296
- if (intakeBody) {
307
+ if (intakeFromTty) {
308
+ // A person at a terminal gets the one instruction, not the agent's
309
+ // playbook: the agent reprints it with `doctrina intake`.
310
+ for (const line of nextStepAfterInit("tty")) console.log(line);
311
+ } else if (intakeBody) {
297
312
  console.log(`Next: execute the bootstrap playbook below — it converts the intake into`);
298
313
  console.log(`${c.cyan(".doctrina/product.md")} and capability specs. Any AGENTS.md-aware agent`);
299
314
  console.log(`runs it on its own; reprint anytime with ${c.cyan("doctrina intake")}.`);
@@ -301,7 +316,7 @@ export async function run(_positional, flags) {
301
316
  warnIfThinIntake(intakeBody);
302
317
  printBootstrapPlaybook(projectRoot);
303
318
  } else {
304
- console.log(`Next: edit ${c.cyan("AGENTS.md")} and ${c.cyan(".doctrina/product.md")} for your project.`);
319
+ for (const line of nextStepAfterInit(null)) console.log(line);
305
320
  }
306
321
  return 0;
307
322
  }
@@ -2,17 +2,19 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
5
+ import { ensureDoctrinaProject, projectName } from "../lib/project.js";
5
6
  import { listHeader } from "../lib/scan.js";
6
7
  import { assessBrief } from "../lib/clarity.js";
7
8
  import * as idx from "../lib/index-json.js";
8
9
  import { today } from "../lib/dates.js";
9
10
  import { flagBool, flagString } from "../lib/args.js";
10
11
  import { c } from "../lib/colors.js";
11
- import { looksLikePath, warnIfThinIntake, writeIntakeFile, printBootstrapPlaybook } from "../lib/intake-model.js";
12
+ import { looksLikePath, markIntakeConverted, printBootstrapPlaybook, warnIfThinIntake, writeIntakeFile } from "../lib/intake-model.js";
12
13
 
13
14
  // `init --intake` performs the same operations; they live in
14
15
  // lib/intake-model.js so neither command imports out of the other (F7).
15
16
  export { warnIfThinIntake, writeIntakeFile, printBootstrapPlaybook } from "../lib/intake-model.js";
17
+ import { collectValidation } from "../lib/validation-model.js";
16
18
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
17
19
 
18
20
  // `intake` is the first half of the no-ceremony path (ADR 0005): store the
@@ -24,10 +26,47 @@ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
24
26
  // Flags this command accepts. Declared HERE, with the command, so
25
27
  // adding a command never requires editing the entrypoint — the gap that
26
28
  // let six flags ship undeclared and silently swallow a positional (C3).
27
- export const flags = { boolean: ["json", "force"], string: ["text"] };
29
+ export const flags = { boolean: ["json", "force", "converted"], string: ["text"] };
28
30
 
29
31
  export async function run(positional, flags) {
30
32
  const projectRoot = process.cwd();
33
+
34
+ // Close the bootstrap through the CLI, not by hand.
35
+ //
36
+ // Step 7 of the playbook used to read "flip Status to converted in
37
+ // .doctrina/intake.md" — the one place this framework told an agent to
38
+ // hand-author a metadata header, against its own standing rule, and the one
39
+ // header nothing read back. A mistyped word left `next` asking forever for
40
+ // a bootstrap that was already done.
41
+ if (flagBool(flags, "converted", false)) {
42
+ ensureDoctrinaProject(projectRoot);
43
+ // Converting is the claim that the bootstrap is done — step 6 of the
44
+ // playbook, "fix everything the gates report", made checkable (change
45
+ // 0194). It used to convert over a tree `validate` failed, so the specs
46
+ // became "the source of truth" while an error still said they were not
47
+ // well-formed. --force converts anyway, for a tree whose errors are not
48
+ // the bootstrap's to fix.
49
+ if (isFile(path.join(projectRoot, ".doctrina", "intake.md")) && !flagBool(flags, "force", false)) {
50
+ const { errors } = collectValidation(projectRoot);
51
+ if (errors.length > 0) {
52
+ console.error(c.red("error:") + ` the bootstrap is not done — \`doctrina validate\` reports ${errors.length} error${errors.length === 1 ? "" : "s"}:`);
53
+ for (const e of errors.slice(0, 5)) console.error(" - " + e);
54
+ if (errors.length > 5) console.error(` … and ${errors.length - 5} more`);
55
+ console.error(c.gray("hint: ") + "fix them (`doctrina validate --fix` heals index drift), then rerun; `--force` converts anyway");
56
+ return EXIT.GATE;
57
+ }
58
+ }
59
+ const written = markIntakeConverted(projectRoot);
60
+ if (!written) {
61
+ console.error(c.red("error:") + " no intake to mark converted at .doctrina/intake.md");
62
+ console.error(c.gray("hint: ") + 'store one first: `doctrina intake --text "<description>"`');
63
+ return EXIT.PRECONDITION;
64
+ }
65
+ console.log(c.green("converted") + ` ${relPath(projectRoot, written)} — specs are the source of truth now`);
66
+ console.log(c.gray("next: ") + 'doctrina next');
67
+ return EXIT.OK;
68
+ }
69
+
31
70
  if (!exists(path.join(projectRoot, ".doctrina"))) {
32
71
  throw notADoctrinaProject();
33
72
  }
@@ -102,8 +141,8 @@ export async function run(positional, flags) {
102
141
  return EXIT.PRECONDITION;
103
142
  }
104
143
 
105
- const projectName = idx.load(projectRoot).project ?? path.basename(projectRoot);
106
- writeIntakeFile(projectRoot, { body, source, projectName, date: today(), force });
144
+ const name = projectName(projectRoot);
145
+ writeIntakeFile(projectRoot, { body, source, projectName: name, date: today(), force });
107
146
  console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)}`);
108
147
  console.log("");
109
148
  warnIfThinIntake(body);
@@ -127,11 +166,20 @@ Forms:
127
166
  doctrina intake --text "<text>" Ingest an inline description
128
167
  doctrina intake Reprint the playbook for the
129
168
  existing pending intake
169
+ doctrina intake --converted Close the bootstrap: flip the stored
170
+ intake's Status to converted
130
171
 
131
172
  Options:
132
173
  --text "<description>" Inline description instead of a file
133
- --force Overwrite an existing .doctrina/intake.md
174
+ --force Overwrite an existing .doctrina/intake.md; with
175
+ --converted, convert although validate fails
176
+ --converted Mark the stored intake converted (ends the
177
+ bootstrap; nothing else writes that header).
178
+ Refuses (exit 1) while validate reports an error
134
179
 
135
- After conversion the agent flips the intake header to
136
- "- **Status:** converted"; specs become the only source of truth.
180
+ Close the bootstrap with \`doctrina intake --converted\` rather than editing
181
+ the header by hand: \`next\` branches on that value, and \`validate\` reports a
182
+ Status that is neither pending nor converted, because every other word read
183
+ as pending in silence. After conversion the specs are the only source of
184
+ truth.
137
185
  `;
@@ -2,6 +2,7 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
5
+ import { intentKey } from "../lib/validation-model.js";
5
6
  import { c } from "../lib/colors.js";
6
7
  import { suggest } from "../lib/suggest.js";
7
8
  import { notADoctrinaProject } from "../lib/exit-codes.js";
@@ -102,9 +103,21 @@ function intentAdd(projectRoot, textArg) {
102
103
  let id = null;
103
104
  let text = textArg;
104
105
  const pinned = textArg.match(/^([A-Z]+)(\d+)\s*:\s*(.+)$/);
106
+ if (pinned) text = pinned[3].trim();
107
+
108
+ // The same intent twice is two anchors for one goal: realize either and the
109
+ // other stays "dropped", so `trace --strict` fails on a gap nobody can
110
+ // close. An agent re-running a playbook step is enough to do it. Compared
111
+ // folded, so case, accents and spacing do not slip a twin past.
112
+ const twin = anchors.find((a) => intentKey(a.text) === intentKey(text));
113
+ if (twin) {
114
+ console.error(c.red("error:") + ` [${twin.id}] already states this intent in product.md`);
115
+ console.error(c.gray("hint: ") + `realize [${twin.id}] with a spec's \`Realizes:\` header, or reword this one if it is a different goal`);
116
+ return 1;
117
+ }
118
+
105
119
  if (pinned) {
106
120
  id = pinned[1] + pinned[2];
107
- text = pinned[3].trim();
108
121
  if (anchors.some((a) => a.id === id)) {
109
122
  console.error(c.red("error:") + ` anchor [${id}] already exists in product.md`);
110
123
  return 1;
@@ -11,7 +11,7 @@ import { GIT_STATE, historyState, git } from "../lib/git.js";
11
11
  import { notADoctrinaProject } from "../lib/exit-codes.js";
12
12
  import { USAGE_ENV, summarise } from "../lib/usage.js";
13
13
  import { OPERATIONS } from "../lib/commands.js";
14
- import { REEDIT_WINDOW_DAYS, computeSnapshot, parseLog, readSeries, latestSnapshot, trend, round } from "../lib/metrics-model.js";
14
+ import { REEDIT_WINDOW_DAYS, sinceWindow, computeSnapshot, parseLog, readSeries, latestSnapshot, trend, round } from "../lib/metrics-model.js";
15
15
 
16
16
  // Local-only adoption metrics derived from git history. No network calls,
17
17
  // no telemetry: the numbers stay in the repository, versioned like any
@@ -37,6 +37,14 @@ export async function run(_positional, flags) {
37
37
  // recent one (audit finding F15) — a year of measurement answering one
38
38
  // question. Reading the series is what makes the saving worth doing.
39
39
  if (flagBool(flags, "trend", false)) return printTrend(projectRoot);
40
+ // The window is the invocation's: refused the same with or without history.
41
+ const sinceRaw = flagString(flags, "since", "90");
42
+ const since = sinceWindow(sinceRaw);
43
+ if (since === null) {
44
+ console.error(c.red("error:") + ` --since expects a day count, a date (YYYY-MM-DD) or "<n> <unit>s ago", got "${sinceRaw}"`);
45
+ console.error(c.gray("hint: ") + "examples: --since 30 · --since 2026-01-01 · --since \"3 months ago\"");
46
+ return EXIT.USAGE;
47
+ }
40
48
  const probe = git(projectRoot, ["rev-parse", "--is-inside-work-tree"]);
41
49
  // "No history yet" is a VALID state, not a failure — a brand-new project
42
50
  // is the most common state in which someone explores this command, and it
@@ -55,8 +63,6 @@ export async function run(_positional, flags) {
55
63
  return EXIT.OK;
56
64
  }
57
65
 
58
- const sinceRaw = flagString(flags, "since", "90");
59
- const since = /^\d+$/.test(sinceRaw) ? `${sinceRaw} days ago` : sinceRaw;
60
66
  const save = flagBool(flags, "save", false);
61
67
 
62
68
  const log = git(projectRoot, [
@@ -218,8 +224,10 @@ Flags:
218
224
  opt-in usage log. Recording is OFF unless DOCTRINA_USAGE_LOG
219
225
  names a file; only the operation is recorded, never
220
226
  arguments, and nothing leaves the machine.
221
- --since <n|date> Window: a day count (default 90) or any git-parseable
222
- date ("2026-01-01", "3 months ago").
227
+ --since <n|date> Window: a day count (default 90), a calendar date
228
+ ("2026-01-01") or "<n> <unit>s ago" ("3 months ago").
229
+ Anything else is refused: git reads any text as a
230
+ date, so a typo would measure the wrong window.
223
231
  --save Write .doctrina/metrics/YYYY-MM-DD.json and print the
224
232
  deltas against the most recent prior snapshot.
225
233
  --trend Read the saved snapshots instead of git: every snapshot
@@ -79,7 +79,7 @@ export async function run(_positional, flags) {
79
79
  export const RUNNERS = {
80
80
  triage: () => import("./triage.js"),
81
81
  intake: () => import("./intake.js"),
82
- analyze: () => import("./analyze.js"),
82
+ "change check": () => import("./change.js"),
83
83
  "change apply": () => import("./change.js"),
84
84
  "change archive": () => import("./change.js"),
85
85
  "index rebuild": () => import("./index-rebuild.js"),
@@ -29,8 +29,8 @@ export async function run(_positional, flags) {
29
29
  if (!exists(path.join(projectRoot, ".doctrina"))) {
30
30
  throw notADoctrinaProject();
31
31
  }
32
- // --rules: the standing rules in full, which is what `constitution` printed
33
- // (change 0049). The primer names the ADRs and counts the non-goals; this
32
+ // --rules: the standing rules in full, which is what the removed
33
+ // `constitution` printed (changes 0049, 0175). The primer names the ADRs and counts the non-goals; this
34
34
  // prints both lists, from the same collection, so the two cannot disagree.
35
35
  const view = flagBool(flags, "rules", false) ? "rules" : "prime";
36
36
  for (const line of renderView(view, collectSnapshot(projectRoot))) console.log(line);
@@ -49,9 +49,8 @@ The same view as \`doctrina status --view prime\`; both render one
49
49
  collection of the tree, so they cannot disagree.
50
50
 
51
51
  --rules Print the standing rules in full instead of the primer: every
52
- accepted ADR and every declared non-goal. The same lines
53
- \`doctrina constitution\` printed, from the same collection —
54
- that command is deprecated and delegates here.
52
+ accepted ADR and every declared non-goal — what the removed
53
+ \`doctrina constitution\` printed, from the same collection.
55
54
 
56
55
  Read-only; always exits 0. Deeper reads: \`doctrina context\`,
57
56
  \`doctrina why <cap>\`, \`doctrina show <ref>\`.
@@ -2,14 +2,12 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { exists } from "../lib/fs-ops.js";
5
- import { flagBool, flagString } from "../lib/args.js";
5
+ import { flagBool, flagString, parsePositiveInt } from "../lib/args.js";
6
6
  import { c } from "../lib/colors.js";
7
7
  import { notADoctrinaProject } from "../lib/exit-codes.js";
8
- import { collectSnapshot } from "../lib/snapshot.js";
8
+ import { collectSnapshot, collectReportWindow } from "../lib/snapshot.js";
9
9
  import { renderView } from "../lib/views.js";
10
- import { gitWindow, historyState, windowCutoff } from "../lib/git.js";
11
10
  import { draftAgentChangelog, renderDraft } from "../lib/agent-changelog.js";
12
- import { collectMetrics } from "../lib/metrics-model.js";
13
11
  import { cliVersion } from "../lib/version.js";
14
12
 
15
13
  // Periodic digest in Markdown — the standup/PR-description view: what was
@@ -33,8 +31,8 @@ export async function run(_positional, flags) {
33
31
  }
34
32
 
35
33
  const sinceRaw = flagString(flags, "since");
36
- const days = Number.parseInt(sinceRaw ?? "7", 10);
37
- if (!Number.isFinite(days) || days <= 0) {
34
+ const days = parsePositiveInt(sinceRaw ?? "7");
35
+ if (days === null) {
38
36
  console.error(c.red("error:") + ` --since expects a positive day count, got "${sinceRaw}"`);
39
37
  return 2;
40
38
  }
@@ -51,19 +49,9 @@ export async function run(_positional, flags) {
51
49
  return 0;
52
50
  }
53
51
 
54
- const options = {
55
- days,
56
- cutoffIso: windowCutoff(days),
57
- git: gitWindow(projectRoot, days),
58
- // Why there is no window, when there is none — the same door `metrics`
59
- // and `context` already ask, so the three cannot disagree about the
60
- // state of one repository (change 0080).
61
- gitState: historyState(projectRoot),
62
- // The same snapshot `metrics` renders, for the same window (change
63
- // 0050). One definition of "revert rate", so the digest and the metrics
64
- // command cannot report two different numbers for one period.
65
- metrics: collectMetrics(projectRoot, `${days} days ago`),
66
- };
52
+ // One collector with `status --view report` (change 0176), so the
53
+ // deprecated name and its survivor cannot print different digests.
54
+ const options = collectReportWindow(projectRoot, days);
67
55
  for (const line of renderView("report", collectSnapshot(projectRoot), options)) console.log(line);
68
56
  return 0;
69
57
  }
@@ -78,7 +66,8 @@ work with task progress, artifact counts, and a local-git summary
78
66
  descriptions. Read-only; no network. \`doctrina metrics\` has the
79
67
  deeper git-derived numbers.
80
68
 
81
- The same view as \`doctrina status --view report --since <days>\`.
69
+ Deprecated: this is \`doctrina status --view report --since <days>\`, and
70
+ \`--agent-changelog\` is \`doctrina status --view agent-changelog\`.
82
71
 
83
72
  --agent-changelog Draft the AGENTS.md "What changed" block instead of
84
73
  the digest: one candidate bullet per archived change
@@ -12,7 +12,7 @@ import { suggest } from "../lib/suggest.js";
12
12
  import { notADoctrinaProject } from "../lib/exit-codes.js";
13
13
  import { parseFrontmatter } from "../lib/doc-model.js";
14
14
  import { git, GIT_STATE } from "../lib/git.js";
15
- import { FIX_SHAPED, FIX_SHAPED_SUBJECT } from "../lib/lexicon.js";
15
+ import { FIX_SHAPED, FIX_SHAPED_SUBJECT, fold, skillSlug } from "../lib/lexicon.js";
16
16
  import { artifactNameError } from "../lib/names.js";
17
17
 
18
18
  const SUBCOMMANDS = ["new", "list", "sync", "suggest"];
@@ -104,7 +104,7 @@ export function draftFromError(text) {
104
104
  // Distinctive words, most frequent first — the fallback when an error
105
105
  // carries no structure at all.
106
106
  const counts = new Map();
107
- 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) ?? []) {
108
108
  if (ERROR_STOP.has(w)) continue;
109
109
  counts.set(w, (counts.get(w) ?? 0) + 1);
110
110
  }
@@ -125,8 +125,8 @@ export function draftFromError(text) {
125
125
  // also mentions do not both land in the name.
126
126
  const seedTokens = [];
127
127
  const seenToken = new Set();
128
- for (const token of [...identifiers.map((i) => i.toLowerCase()), ...keywords]) {
129
- 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, "");
130
130
  if (norm === "" || seenToken.has(norm)) continue;
131
131
  seenToken.add(norm);
132
132
  seedTokens.push(token);
@@ -344,7 +344,7 @@ function skillSuggest(args, flags) {
344
344
  idx.save(projectRoot, index);
345
345
  }
346
346
  console.log("");
347
- 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\``);
348
348
  return 0;
349
349
  }
350
350
 
@@ -400,16 +400,9 @@ function gitFixCommits(projectRoot, { since, limit }) {
400
400
  }
401
401
 
402
402
  // Derive a skill slug from a commit subject: drop a conventional-commit
403
- // "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.
404
404
  function commitSlug(subject) {
405
- const stripped = subject.replace(/^[a-z]+(?:\([^)]*\))?!?\s*:\s*/i, "");
406
- return skillSlug(stripped.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""));
407
- }
408
-
409
- function skillSlug(id) {
410
- // Reuse the change id as the skill slug, trimmed to a sane length.
411
- const slug = id.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
412
- return slug.length > 40 ? slug.slice(0, 40).replace(/-+$/g, "") : (slug || "lesson");
405
+ return skillSlug(subject.replace(/^[a-z]+(?:\([^)]*\))?!?\s*:\s*/i, ""));
413
406
  }
414
407
 
415
408
  // Token-overlap similarity between two kebab slugs (Jaccard over meaningful
@@ -482,7 +475,7 @@ function skillNew(args, flags) {
482
475
  idx.save(projectRoot, index);
483
476
  console.log(c.green("indexed") + ` skill "${name}"`);
484
477
  console.log("");
485
- 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");
486
479
  return 0;
487
480
  }
488
481
 
@@ -155,8 +155,12 @@ function specSet(args, flags) {
155
155
  let text = read(specPath);
156
156
  const applied = [];
157
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 });
158
162
  const apply = (res) => {
159
- if (res.error) errors.push(res.error);
163
+ if (res.error) errors.push({ msg: res.error, argument: res.cause === "argument" });
160
164
  else { text = res.text; applied.push(res.summary); }
161
165
  };
162
166
  // Order: headers, then version, then criterion — independent edits, all or
@@ -173,7 +177,7 @@ function specSet(args, flags) {
173
177
  const derivedValue = derivedImplementations(projectRoot, { only: new Set([capability]) })
174
178
  .get(capability)?.derived;
175
179
  if (!derivedValue) {
176
- errors.push(`--implementation auto needs acceptance criteria to derive from; ${capability} declares none`);
180
+ errors.push({ msg: `--implementation auto needs acceptance criteria to derive from; ${capability} declares none`, argument: false });
177
181
  } else {
178
182
  apply(setHeader(text, "Implementation", derivedValue));
179
183
  }
@@ -182,23 +186,25 @@ function specSet(args, flags) {
182
186
  }
183
187
  }
184
188
  if (bump !== undefined) {
185
- 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}")`);
186
190
  else apply(bumpVersion(text, bump));
187
191
  }
188
192
  if (versionExplicit !== undefined) {
189
- 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}")`);
190
194
  else apply(setHeader(text, "Version", versionExplicit));
191
195
  }
192
196
  if (criterion !== undefined) {
193
197
  const m = String(criterion).match(/^(\d+)\s*:\s*(.+)$/);
194
- if (!m) errors.push(`--criterion needs "<n>:<mark>" (got "${criterion}")`);
198
+ if (!m) refused(`--criterion needs "<n>:<mark>" (got "${criterion}")`);
195
199
  else apply(setCriterionMark(text, Number(m[1]), m[2].trim()));
196
200
  }
197
201
 
198
202
  if (errors.length > 0) {
199
203
  console.error(c.red("error:") + ` ${errors.length} operation error${errors.length === 1 ? "" : "s"} — spec left untouched:`);
200
- for (const e of errors) console.error(` - ${e}`);
201
- 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;
202
208
  }
203
209
 
204
210
  // Stamp Last updated when the header exists (best-effort, never an error).