doctrina-cli 0.16.0 → 0.18.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 +31 -73
  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 +86 -80
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +136 -34
  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 +2 -1
  19. package/src/commands/prime.js +4 -5
  20. package/src/commands/report.js +9 -20
  21. package/src/commands/review.js +47 -8
  22. package/src/commands/skill.js +8 -139
  23. package/src/commands/spec.js +13 -7
  24. package/src/commands/status.js +39 -20
  25. package/src/commands/templates.js +4 -10
  26. package/src/commands/upgrade.js +31 -6
  27. package/src/commands/validate.js +32 -8
  28. package/src/commands/verify.js +59 -6
  29. package/src/commands/work.js +63 -8
  30. package/src/index.js +34 -6
  31. package/src/lib/actions.js +38 -28
  32. package/src/lib/args.js +14 -0
  33. package/src/lib/change-ops.js +95 -1
  34. package/src/lib/commands.js +96 -15
  35. package/src/lib/coverage-model.js +40 -1
  36. package/src/lib/doc-model.js +47 -6
  37. package/src/lib/docs-impact.js +74 -0
  38. package/src/lib/ears.js +24 -1
  39. package/src/lib/gates.js +53 -9
  40. package/src/lib/git.js +59 -2
  41. package/src/lib/index-json.js +40 -5
  42. package/src/lib/intake-model.js +71 -2
  43. package/src/lib/lexicon.js +38 -1
  44. package/src/lib/metrics-model.js +30 -0
  45. package/src/lib/playbook.js +1 -1
  46. package/src/lib/project.js +49 -1
  47. package/src/lib/runtime.js +6 -0
  48. package/src/lib/scan.js +139 -14
  49. package/src/lib/signoff.js +24 -0
  50. package/src/lib/skill-candidates.js +162 -0
  51. package/src/lib/snapshot.js +61 -2
  52. package/src/lib/spec-ops.js +141 -6
  53. package/src/lib/templates-model.js +35 -8
  54. package/src/lib/validation-model.js +117 -35
  55. package/src/lib/verify-cache.js +76 -0
  56. package/src/lib/views.js +10 -2
  57. package/src/lib/work-model.js +57 -12
  58. package/templates/AGENTS.md.template +3 -4
  59. package/templates/README.md +2 -0
  60. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  61. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  62. package/templates/change/proposal.md.template +15 -0
  63. package/templates/change/spec-delta.md.template +2 -0
  64. package/templates/change/tasks.md.template +3 -8
  65. package/templates/contract.md.template +2 -2
  66. package/templates/hooks/pre-commit.sample +10 -0
  67. package/templates/playbooks/bootstrap.md.template +6 -2
  68. package/templates/playbooks/chore.md.template +9 -4
  69. package/templates/playbooks/consolidate.md.template +28 -0
  70. package/templates/playbooks/work.md.template +13 -3
  71. package/src/commands/constitution.js +0 -68
@@ -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,9 +79,10 @@ 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
+ close: () => import("./close.js"),
85
86
  "index rebuild": () => import("./index-rebuild.js"),
86
87
  };
87
88
 
@@ -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
@@ -8,7 +8,8 @@ import { c } from "../lib/colors.js";
8
8
  import { rankCapabilitiesByDiff } from "../lib/work-model.js";
9
9
  import { readLedger, churnByCapability } from "../lib/ledger.js";
10
10
  import { changedFiles, windowCutoff, historyState, refExists } from "../lib/git.js";
11
- import { dependentsOf } from "../lib/scan.js";
11
+ import { dependentsOf, deltaCapabilities } from "../lib/scan.js";
12
+ import { refuseChangeRef } from "../lib/project.js";
12
13
  import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
13
14
  import { summarize as coverageSummary } from "../lib/coverage-model.js";
14
15
  import { summarize as traceSummary } from "../lib/trace-model.js";
@@ -37,7 +38,7 @@ const CHURN_NOTABLE = 3;
37
38
  // bury every other finding under a file listing.
38
39
  const ORPHAN_LIST_LIMIT = 5;
39
40
 
40
- export const flags = { boolean: ["json", "strict"], string: ["diff"] };
41
+ export const flags = { boolean: ["json", "strict"], string: ["diff", "change"] };
41
42
 
42
43
  export async function run(_positional, flags) {
43
44
  const projectRoot = process.cwd();
@@ -60,8 +61,26 @@ export async function run(_positional, flags) {
60
61
  return EXIT.USAGE;
61
62
  }
62
63
 
64
+ // --change <id>: review the tree AS that change's close sees it (change
65
+ // 0200). Its deltas are the spec updates — merged one step later, by
66
+ // `apply` — so the capabilities they target count as updated.
67
+ const changeId = flagString(flags, "change");
68
+ /** @type {Set<string> | null} */
69
+ let changeCaps = null;
70
+ if (changeId !== undefined) {
71
+ const refused = refuseChangeRef(changeId);
72
+ if (refused !== null) return refused;
73
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", changeId);
74
+ if (!isDir(changeDir)) {
75
+ console.error(c.red("error:") + ` change "${changeId}" not found at .doctrina/changes/${changeId}`);
76
+ return EXIT.USAGE;
77
+ }
78
+ changeCaps = new Set(deltaCapabilities(changeDir));
79
+ }
80
+
63
81
  const { all, sourceFiles } = reviewScope(projectRoot, against);
64
- console.log(c.bold("Review") + c.gray(against ? ` — vs ${against}` : " — working-tree changes"));
82
+ console.log(c.bold("Review") + c.gray(against ? ` — vs ${against}` : " — working-tree changes") +
83
+ (changeId ? c.gray(` · change ${changeId}`) : ""));
65
84
  console.log("");
66
85
 
67
86
  if (all.length === 0) {
@@ -85,10 +104,18 @@ export async function run(_positional, flags) {
85
104
  );
86
105
  const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity });
87
106
  const touched = new Set([...specChanged, ...ranked.map((m) => m.id)]);
88
- for (const m of ranked) {
89
- if (!specChanged.has(m.id)) {
90
- breaks.push(`code under capability "${m.id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${m.id}/spec.md\` still describes the code (update it, or note why not)`);
107
+ const stale = ranked.map((m) => m.id).filter((id) => !specChanged.has(id) && !changeCaps?.has(id));
108
+ if (changeCaps === null) {
109
+ for (const id of stale) {
110
+ breaks.push(`code under capability "${id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${id}/spec.md\` still describes the code (update it, or note why not)`);
91
111
  }
112
+ } else if (stale.length > 0) {
113
+ // Reviewed as a change: code under capabilities it carries no delta for
114
+ // is either this change forgetting a delta or other work lying in the
115
+ // tree — the review cannot tell which, so it says both, ONCE. One break
116
+ // per capability, repeated on every close, is how the warning became
117
+ // noise nobody read (field review, 0.17).
118
+ breaks.push(`code changed under ${stale.length === 1 ? "a capability" : `${stale.length} capabilities`} this change carries no delta for: ${stale.map((id) => `"${id}"`).join(", ")} — if this change made those edits, add a delta (or note why the spec still holds); if other work did, it is not this change's (commit or stash it)`);
92
119
  }
93
120
 
94
121
  // 1b. Dependents of touched capabilities (the machine-readable
@@ -107,7 +134,10 @@ export async function run(_positional, flags) {
107
134
  if (touched.size > 0) {
108
135
  const since = windowCutoff(CHURN_WINDOW_DAYS);
109
136
  const churn = churnByCapability(readLedger(projectRoot).entries, { since })
110
- .filter((row) => touched.has(row.capability) && row.changes >= CHURN_NOTABLE);
137
+ // Reviewed as a change, only the capabilities it updates: churn is
138
+ // context for the spec being changed, and four lines of it about
139
+ // ground the change merely stands near was more noise per close.
140
+ .filter((row) => (changeCaps ?? touched).has(row.capability) && row.changes >= CHURN_NOTABLE);
111
141
  for (const row of churn) {
112
142
  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"`);
113
143
  }
@@ -219,7 +249,7 @@ function runContractCheck(projectRoot) {
219
249
  }
220
250
 
221
251
  export const help = `
222
- Usage: doctrina review [--diff <ref>] [--strict]
252
+ Usage: doctrina review [--diff <ref>] [--change <id>] [--strict]
223
253
 
224
254
  Deterministic conformance review of your changes against the spec / ADR /
225
255
  contract tree. By default it reviews the working-tree changes (tracked +
@@ -232,6 +262,15 @@ citing missing proof, product intent realized by no spec, and contract
232
262
  port/reference collisions. It checks conformance shape, NOT semantic
233
263
  fidelity (whether the code does what the spec says stays a human/LLM call).
234
264
 
265
+ --change <id> reviews the tree as that change's close does: the capabilities
266
+ its deltas target count as updated (the close merges them one step later),
267
+ and code under capabilities it carries no delta for is reported in one line —
268
+ this change forgot a delta, or other work is lying in the tree.
269
+
270
+ A file's owner is the spec whose \`**Source:**\` claims it; failing that, the
271
+ spec citing its exact path; failing both, the path and basename inference,
272
+ from specs that declare no Source.
273
+
235
274
  Read-only. Exits 0 as a report; with --strict, exits 1 when any hard break
236
275
  exists (CI gate). Notes (advisory) never fail the gate.
237
276
  `;
@@ -1,7 +1,6 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { readdirSync } from "node:fs";
5
4
  import { exists, isDir, isFile, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
6
5
  import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
7
6
  import * as idx from "../lib/index-json.js";
@@ -9,10 +8,10 @@ import { today } from "../lib/dates.js";
9
8
  import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
10
9
  import { c } from "../lib/colors.js";
11
10
  import { suggest } from "../lib/suggest.js";
11
+ import { skillCandidates } from "../lib/skill-candidates.js";
12
12
  import { notADoctrinaProject } from "../lib/exit-codes.js";
13
13
  import { parseFrontmatter } from "../lib/doc-model.js";
14
- import { git, GIT_STATE } from "../lib/git.js";
15
- import { FIX_SHAPED, FIX_SHAPED_SUBJECT } from "../lib/lexicon.js";
14
+ import { fold, skillSlug } from "../lib/lexicon.js";
16
15
  import { artifactNameError } from "../lib/names.js";
17
16
 
18
17
  const SUBCOMMANDS = ["new", "list", "sync", "suggest"];
@@ -104,7 +103,7 @@ export function draftFromError(text) {
104
103
  // Distinctive words, most frequent first — the fallback when an error
105
104
  // carries no structure at all.
106
105
  const counts = new Map();
107
- for (const w of raw.toLowerCase().match(/[a-z][a-z0-9_-]{3,}/g) ?? []) {
106
+ for (const w of fold(raw).match(/[a-z][a-z0-9_-]{3,}/g) ?? []) {
108
107
  if (ERROR_STOP.has(w)) continue;
109
108
  counts.set(w, (counts.get(w) ?? 0) + 1);
110
109
  }
@@ -125,8 +124,8 @@ export function draftFromError(text) {
125
124
  // also mentions do not both land in the name.
126
125
  const seedTokens = [];
127
126
  const seenToken = new Set();
128
- for (const token of [...identifiers.map((i) => i.toLowerCase()), ...keywords]) {
129
- const norm = token.replace(/[^a-z0-9]/g, "");
127
+ for (const token of [...identifiers.map((i) => fold(i)), ...keywords]) {
128
+ const norm = fold(token).replace(/[^a-z0-9]/g, "");
130
129
  if (norm === "" || seenToken.has(norm)) continue;
131
130
  seenToken.add(norm);
132
131
  seedTokens.push(token);
@@ -237,57 +236,8 @@ function skillSuggest(args, flags) {
237
236
  return writeErrorDraft(projectRoot, draft, doWrite);
238
237
  }
239
238
 
239
+ const candidates = skillCandidates(projectRoot, { since, limit: GIT_LOG_LIMIT });
240
240
  const skillsDir = path.join(projectRoot, ".doctrina", "skills");
241
- const existingFiles = isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")) : [];
242
- const existing = new Set(existingFiles.map((f) => path.basename(f, ".md")));
243
- // A lesson may already be captured under a DIFFERENT slug (0.11.0 field
244
- // review item 11: the human names the skill after the lesson, not after the
245
- // change id — "tela-preta-crash-de-render..." for change 0049). Two extra
246
- // dedup signals: an existing skill whose body cites the candidate's change
247
- // id / commit hash, and token-overlap similarity between the slugs.
248
- const existingBodies = existingFiles.map((f) => ({ slug: path.basename(f, ".md"), body: read(f) }));
249
- const captured = (cand) => {
250
- if (existing.has(cand.slug)) return true;
251
- for (const sk of existingBodies) {
252
- if (cand.id && sk.body.includes(cand.id)) return true;
253
- if (cand.from && sk.body.includes(cand.from)) return true;
254
- if (slugSimilarity(cand.slug, sk.slug) >= 0.5) return true;
255
- }
256
- return false;
257
- };
258
-
259
- // Two deterministic sources, deduped by slug. `seen` guards against a lesson
260
- // appearing twice (e.g. a fix committed and later archived as a change).
261
- /** @type {SkillCandidate[]} */
262
- const candidates = [];
263
- const seen = new Set();
264
-
265
- // Source 1 — archived change proposals with a fix-shaped folder name.
266
- const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
267
- if (isDir(archiveDir)) {
268
- for (const name of readdirSync(archiveDir).sort()) {
269
- if (!isDir(path.join(archiveDir, name))) continue;
270
- const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
271
- if (!FIX_SHAPED.test(id)) continue;
272
- const slug = skillSlug(id);
273
- if (seen.has(slug)) continue;
274
- /** @type {SkillCandidate} */
275
- const cand = { id, slug, source: "change", from: name };
276
- if (captured(cand)) continue; // captured (any slug) or cited by an existing skill
277
- const proposal = path.join(archiveDir, name, "proposal.md");
278
- cand.why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
279
- seen.add(slug);
280
- candidates.push(cand);
281
- }
282
- }
283
-
284
- // Source 2 — fix-shaped commits in the git history (ADR 0013). Archive wins a
285
- // slug collision: a `## Why` paragraph is a richer seed than a subject line.
286
- for (const cand of gitFixCommits(projectRoot, { since, limit: GIT_LOG_LIMIT })) {
287
- if (seen.has(cand.slug) || captured(cand)) continue;
288
- seen.add(cand.slug);
289
- candidates.push(cand);
290
- }
291
241
 
292
242
  if (candidates.length === 0) {
293
243
  console.log(c.gray("no uncaptured fix-shaped lessons found in .doctrina/changes/archive/ or the git history"));
@@ -344,7 +294,7 @@ function skillSuggest(args, flags) {
344
294
  idx.save(projectRoot, index);
345
295
  }
346
296
  console.log("");
347
- console.log(c.green("ok") + ` scaffolded ${created} skill stub${created === 1 ? "" : "s"} — fill the description/when/body, then \`doctrina skill sync\``);
297
+ console.log(c.green("ok") + ` scaffolded ${created} skill stub${created === 1 ? "" : "s"} — fill the description/when/body, then \`doctrina index rebuild\``);
348
298
  return 0;
349
299
  }
350
300
 
@@ -361,87 +311,6 @@ function seedLabel(cand) {
361
311
  : `Candidate from change "${cand.id}"`;
362
312
  }
363
313
 
364
- // Source 2 for `skill suggest`: fix-shaped commits in the git history (ADR
365
- // 0013). Deterministic — a regex on the subject, never a read of the diff
366
- // (ADR 0005 semantic-fidelity ceiling). Returns [] silently when this is not a
367
- // git repo, git is absent, or the --since ref is unknown, so the command
368
- // degrades to archive-only rather than erroring.
369
- function gitFixCommits(projectRoot, { since, limit }) {
370
- // US (0x1f) between fields, RS (0x1e) between records — robust against any
371
- // newline or punctuation inside a subject or body.
372
- const format = "%H%x1f%s%x1f%b%x1e";
373
- const args = ["log", "--no-merges", `--format=${format}`];
374
- if (since) args.push(`${since}..HEAD`);
375
- else args.push("-n", String(limit));
376
-
377
- const r = git(projectRoot, args);
378
- if (r.state !== GIT_STATE.OK || !r.stdout) return [];
379
-
380
- /** @type {SkillCandidate[]} */
381
- const out = [];
382
- const seen = new Set();
383
- for (const record of r.stdout.split("\x1e")) {
384
- const [sha, subject = "", body = ""] = record.replace(/^\r?\n/, "").split("\x1f");
385
- if (!sha || !sha.trim()) continue;
386
- if (!FIX_SHAPED_SUBJECT.test(subject)) continue;
387
- const slug = commitSlug(subject);
388
- if (!slug || seen.has(slug)) continue; // first commit wins a duplicate lesson
389
- seen.add(slug);
390
- const firstBodyLine = body.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
391
- out.push({
392
- id: slug,
393
- slug,
394
- source: "commit",
395
- ref: sha.trim().slice(0, 9),
396
- why: subject.trim() + (firstBodyLine ? ` — ${firstBodyLine}` : ""),
397
- });
398
- }
399
- return out;
400
- }
401
-
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.
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");
413
- }
414
-
415
- // Token-overlap similarity between two kebab slugs (Jaccard over meaningful
416
- // tokens). "0049-fix-tela-preta-render" vs "tela-preta-crash-de-render-sem-
417
- // error-boundary" share tela/preta/render → high overlap even though the
418
- // human renamed the skill after the lesson. Deterministic; never semantic.
419
- function slugSimilarity(a, b) {
420
- const toks = (s) => new Set(
421
- String(s).split("-").filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !SLUG_NOISE.has(t)),
422
- );
423
- const A = toks(a);
424
- const B = toks(b);
425
- if (A.size === 0 || B.size === 0) return 0;
426
- let inter = 0;
427
- for (const t of A) if (B.has(t)) inter += 1;
428
- return inter / (A.size + B.size - inter);
429
- }
430
-
431
- // Fix-shaped verbs and connectives carry no lesson identity — without this,
432
- // every "fix-..." candidate looks 30% similar to every "fix-..." skill.
433
- const SLUG_NOISE = new Set([
434
- "fix", "bug", "hotfix", "patch", "the", "and", "for", "with", "sem", "com",
435
- "que", "nao", "por", "para", "una", "uma", "dos", "das",
436
- ]);
437
-
438
- function firstWhyLine(proposalText) {
439
- const m = proposalText.match(/##\s*Why\s*\r?\n+([^\r\n][^\r\n]*)/i);
440
- if (!m) return "";
441
- const line = m[1].trim();
442
- return /^<!--/.test(line) ? "" : line;
443
- }
444
-
445
314
  function skillNew(args, flags) {
446
315
  const name = args[0];
447
316
  const nameError = artifactNameError(name, "skill name");
@@ -482,7 +351,7 @@ function skillNew(args, flags) {
482
351
  idx.save(projectRoot, index);
483
352
  console.log(c.green("indexed") + ` skill "${name}"`);
484
353
  console.log("");
485
- console.log(c.gray("hint: ") + "fill the description and when frontmatter fields, then run `doctrina skill sync` to update index.json");
354
+ console.log(c.gray("hint: ") + "fill the description and when frontmatter fields, then run `doctrina index rebuild` to update index.json");
486
355
  return 0;
487
356
  }
488
357
 
@@ -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).
@@ -2,13 +2,14 @@
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 { emitJson } from "../lib/json-out.js";
8
8
  import { notADoctrinaProject } from "../lib/exit-codes.js";
9
- import { collectSnapshot, collectStatus } from "../lib/snapshot.js";
9
+ import { collectSnapshot, collectStatus, collectReportWindow } from "../lib/snapshot.js";
10
10
  import { renderView, VIEWS } from "../lib/views.js";
11
- import { gitWindow, historyState, windowCutoff } from "../lib/git.js";
11
+ import { draftAgentChangelog, renderDraft } from "../lib/agent-changelog.js";
12
+ import { cliVersion } from "../lib/version.js";
12
13
  import { suggest } from "../lib/suggest.js";
13
14
 
14
15
  // One-glance project health (review 2026-06-27 passive-user feature #1): a
@@ -32,6 +33,9 @@ export const jsonNative = true;
32
33
 
33
34
  export const flags = { boolean: ["json"], string: ["view", "since"] };
34
35
 
36
+ // The snapshot's views, plus the one draft that is not a view of it.
37
+ const STATUS_VIEWS = [...VIEWS, "agent-changelog"];
38
+
35
39
  export async function run(_positional, flags) {
36
40
  const projectRoot = process.cwd();
37
41
  if (!exists(path.join(projectRoot, ".doctrina"))) {
@@ -39,10 +43,10 @@ export async function run(_positional, flags) {
39
43
  }
40
44
 
41
45
  const view = flagString(flags, "view") ?? "dashboard";
42
- if (!VIEWS.includes(view)) {
46
+ if (!STATUS_VIEWS.includes(view)) {
43
47
  console.error(c.red("error:") + ` unknown view "${view}"`);
44
- const guess = suggest(view, VIEWS);
45
- console.error(c.gray("hint: ") + (guess ? `did you mean --view ${guess}?` : `available: ${VIEWS.join(", ")}`));
48
+ const guess = suggest(view, STATUS_VIEWS);
49
+ console.error(c.gray("hint: ") + (guess ? `did you mean --view ${guess}?` : `available: ${STATUS_VIEWS.join(", ")}`));
46
50
  return 2;
47
51
  }
48
52
 
@@ -53,18 +57,24 @@ export async function run(_positional, flags) {
53
57
  return 0;
54
58
  }
55
59
 
56
- const options = {};
57
- if (view === "report") {
58
- const sinceRaw = flagString(flags, "since") ?? "7";
59
- const days = Number.parseInt(sinceRaw, 10);
60
- if (!Number.isFinite(days) || days <= 0) {
60
+ let options = {};
61
+ if (view === "report" || view === "agent-changelog") {
62
+ const sinceRaw = flagString(flags, "since");
63
+ const days = parsePositiveInt(sinceRaw ?? "7");
64
+ if (days === null) {
61
65
  console.error(c.red("error:") + ` --since expects a positive day count, got "${sinceRaw}"`);
62
66
  return 2;
63
67
  }
64
- options.days = days;
65
- options.cutoffIso = windowCutoff(days);
66
- options.git = gitWindow(projectRoot, days);
67
- options.gitState = historyState(projectRoot);
68
+ // A DRAFT of the block `upgrade --write` writes into AGENTS.md, proposed
69
+ // from what the archived changes said they touched. Not a view of the
70
+ // snapshot, so it renders here; authorship stays human (ADR 0005). With
71
+ // no --since the window is "since the last tag".
72
+ if (view === "agent-changelog") {
73
+ const draft = draftAgentChangelog(projectRoot, { days: sinceRaw === undefined ? null : days });
74
+ for (const line of renderDraft(draft, cliVersion())) console.log(line);
75
+ return 0;
76
+ }
77
+ options = collectReportWindow(projectRoot, days);
68
78
  }
69
79
 
70
80
  const snapshot = collectSnapshot(projectRoot);
@@ -85,13 +95,22 @@ Flags:
85
95
  dashboard the default one-glance health board
86
96
  prime the session primer (\`doctrina prime\`)
87
97
  handoff the Markdown resume note (\`doctrina handoff\`)
88
- report the period digest (\`doctrina report\`)
89
- --since <days> With --view report: the window (default 7).
98
+ report the period digest: gates, archived changes,
99
+ open work, and local git numbers
100
+ rules the standing rules (\`doctrina prime --rules\`)
101
+ agent-changelog
102
+ a DRAFT of the AGENTS.md "What changed" block:
103
+ one bullet per archived change that touched a
104
+ documented surface, newest first, at most five.
105
+ It proposes; a person cuts and rewrites.
106
+ --since <days> With --view report: the window (default 7). With
107
+ --view agent-changelog: the window (default: since the
108
+ last tag).
90
109
  --json Emit the snapshot as JSON (stable shape for agents and CI).
91
110
 
92
- All four views read ONE collection of the tree, so they can never report
93
- different numbers; \`prime\`, \`handoff\` and \`report\` are the same views
94
- under their own names.
111
+ The views read ONE collection of the tree, so they can never report
112
+ different numbers; \`prime\` and \`handoff\` are the same views under their
113
+ own names. \`doctrina report\` is deprecated: it is \`--view report\`.
95
114
 
96
115
  It is a fast summary, not the authoritative gate: run \`doctrina validate\`
97
116
  and \`doctrina verify\` for the full structural and build checks, and