doctrina-cli 0.15.1 → 0.16.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 (84) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -212
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +4 -82
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +60 -138
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +56 -4
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +181 -23
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +191 -5
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/hooks/pre-commit.sample +6 -2
  82. package/templates/playbooks/bootstrap.md.template +43 -0
  83. package/templates/playbooks/chore.md.template +19 -0
  84. package/templates/playbooks/work.md.template +76 -0
@@ -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 } 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.
@@ -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 = [];
@@ -449,8 +444,9 @@ function firstWhyLine(proposalText) {
449
444
 
450
445
  function skillNew(args, flags) {
451
446
  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\")");
447
+ const nameError = artifactNameError(name, "skill name");
448
+ if (nameError) {
449
+ console.error(c.red("error:") + ` ${nameError} (e.g. "db-migration")`);
454
450
  return 2;
455
451
  }
456
452
  const force = flagBool(flags, "force", false);
@@ -512,6 +508,13 @@ function skillSync() {
512
508
  console.log(c.yellow("skip ") + ` ${id} (no description frontmatter)`);
513
509
  continue;
514
510
  }
511
+ // A description still in the template's `<...>` form is not "up to
512
+ // date" (change 0111): it is a file nobody wrote yet, and mirroring it
513
+ // into the index is what put the placeholder in every context pack.
514
+ if (/^<[^<>]*>$/.test(desc.trim())) {
515
+ console.log(c.yellow("scaffold") + ` ${id} — description is still the scaffold's placeholder; write description/when, then sync`);
516
+ continue;
517
+ }
515
518
  const entry = (index.artifacts.skills ?? []).find((s) => s.id === id);
516
519
  if (!entry) {
517
520
  idx.addSkill(index, {
@@ -568,15 +571,6 @@ function skillList() {
568
571
  return 0;
569
572
  }
570
573
 
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
574
 
581
575
  function ensureDoctrinaProject(projectRoot) {
582
576
  if (!exists(path.join(projectRoot, ".doctrina"))) {
@@ -618,6 +612,3 @@ Skills are written by humans, not generated. See docs/en/skills.md
618
612
  for the design rationale and the distinction from specs / AGENTS.md /
619
613
  the rejected memory/ folder.
620
614
  `;
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");
@@ -159,7 +162,25 @@ function specSet(args, flags) {
159
162
  // Order: headers, then version, then criterion — independent edits, all or
160
163
  // none (a failed op leaves the spec untouched, like `change apply`).
161
164
  if (status !== undefined) apply(setHeader(text, "Status", status));
162
- if (impl !== undefined) apply(setHeader(text, "Implementation", impl));
165
+ if (impl !== undefined) {
166
+ // `--implementation auto` reads the value off the coverage arithmetic
167
+ // instead of the author's memory (audit finding F10): coverage already
168
+ // knows how many of this spec's criteria cite proof that resolves, and
169
+ // that is the definition of "verified". A spec with no acceptance
170
+ // criteria has nothing to derive from, so `auto` refuses rather than
171
+ // guessing — the spec is left untouched, like any other failed op.
172
+ if (String(impl).trim().toLowerCase() === "auto") {
173
+ const derivedValue = derivedImplementations(projectRoot, { only: new Set([capability]) })
174
+ .get(capability)?.derived;
175
+ if (!derivedValue) {
176
+ errors.push(`--implementation auto needs acceptance criteria to derive from; ${capability} declares none`);
177
+ } else {
178
+ apply(setHeader(text, "Implementation", derivedValue));
179
+ }
180
+ } else {
181
+ apply(setHeader(text, "Implementation", impl));
182
+ }
183
+ }
163
184
  if (bump !== undefined) {
164
185
  if (!["major", "minor", "patch"].includes(bump)) errors.push(`--bump needs major|minor|patch (got "${bump}")`);
165
186
  else apply(bumpVersion(text, bump));
@@ -1,27 +1,27 @@
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
- import { exists, isDir, isFile, read, walk } from "../lib/fs-ops.js";
6
- import * as idx from "../lib/index-json.js";
7
- import { deriveIndex, indexesMatch, specHeader, listHeader } from "../lib/scan.js";
8
- import { cliVersion } from "../lib/version.js";
9
- import { summarize as coverageSummary } from "./coverage.js";
10
- import { summarize as traceSummary } from "./trace.js";
11
- import { flagBool } from "../lib/args.js";
4
+ import { exists } from "../lib/fs-ops.js";
5
+ import { flagBool, flagString } from "../lib/args.js";
12
6
  import { c } from "../lib/colors.js";
13
7
  import { emitJson } from "../lib/json-out.js";
14
8
  import { notADoctrinaProject } from "../lib/exit-codes.js";
9
+ import { collectSnapshot, collectStatus } from "../lib/snapshot.js";
10
+ import { renderView, VIEWS } from "../lib/views.js";
11
+ import { gitWindow, historyState, windowCutoff } from "../lib/git.js";
12
+ import { suggest } from "../lib/suggest.js";
15
13
 
16
14
  // One-glance project health (review 2026-06-27 passive-user feature #1): a
17
15
  // dashboard that answers "where do things stand?" in a single read, so neither
18
16
  // the human nor the agent has to run validate + coverage + trace + next
19
- // separately. Strictly read-only; never mutates and always exits 0. It reports
20
- // the gate signals it can compute cheaply (index drift, the framework stamp,
21
- // coverage/trace ratios, whether verify is configured) and the artifact counts;
22
- // `doctrina validate` / `verify` remain the full, authoritative gates.
23
-
24
- const CONFIG_REL = ".doctrina/verify.json";
17
+ // separately. Strictly read-only; never mutates and always exits 0.
18
+ //
19
+ // Since change 0037 this is also the HOME of the other read-only views:
20
+ // `--view prime|handoff|report` renders the same snapshot in a different
21
+ // shape. `prime`, `handoff` and `report` remain as their own commands (they
22
+ // are what AGENTS.md tells an agent to run) and delegate straight here, so
23
+ // there is one collector, one set of formatters, and no way for the four to
24
+ // report different numbers (audit finding F7).
25
25
 
26
26
  // Flags this command accepts. Declared HERE, with the command, so
27
27
  // adding a command never requires editing the entrypoint — the gap that
@@ -30,7 +30,7 @@ const CONFIG_REL = ".doctrina/verify.json";
30
30
  // wrap it in the generic envelope.
31
31
  export const jsonNative = true;
32
32
 
33
- export const flags = { boolean: ["json"], string: [] };
33
+ export const flags = { boolean: ["json"], string: ["view", "since"] };
34
34
 
35
35
  export async function run(_positional, flags) {
36
36
  const projectRoot = process.cwd();
@@ -38,173 +38,42 @@ export async function run(_positional, flags) {
38
38
  throw notADoctrinaProject();
39
39
  }
40
40
 
41
- const s = collectStatus(projectRoot);
41
+ const view = flagString(flags, "view") ?? "dashboard";
42
+ if (!VIEWS.includes(view)) {
43
+ 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(", ")}`));
46
+ return 2;
47
+ }
42
48
 
49
+ // --json always answers with the status shape, whatever the view: the
50
+ // envelope is a machine contract and must not change with a formatting flag.
43
51
  if (flagBool(flags, "json", false)) {
44
- emitJson("status", s);
52
+ emitJson("status", collectStatus(projectRoot));
45
53
  return 0;
46
54
  }
47
55
 
48
- console.log(c.bold("Doctrina status") + c.gray(` — ${s.project} (framework ${s.stamp ?? "—"} / CLI ${s.cli})`));
49
- console.log("");
50
-
51
- // --- Gates ---
52
- console.log(c.bold(" Gates"));
53
- let driftLine;
54
- if (s.indexState === "missing") {
55
- driftLine = c.red("missing/unreadable") + c.gray(" — run `doctrina index rebuild`");
56
- } else if (s.indexState === "in-sync") {
57
- driftLine = c.green("in sync");
58
- } else {
59
- driftLine = c.yellow("drifted") + c.gray(" — run `doctrina validate --fix`");
60
- }
61
- console.log(` ${"index".padEnd(11)} ${driftLine}`);
62
-
63
- const stampLine = s.stamp === s.cli
64
- ? c.green("current")
65
- : c.yellow(`${s.stamp ?? "—"} (CLI ${s.cli})`) + c.gray(" — `doctrina index rebuild`");
66
- console.log(` ${"stamp".padEnd(11)} ${stampLine}`);
67
-
68
- const cov = s.coverage;
69
- const covExtra = cov.totalDangling + cov.totalConditional > 0
70
- ? c.yellow(` ${cov.totalDangling} dangling, ${cov.totalConditional} conditional`)
71
- : "";
72
- const covColor = cov.pct === 100 ? c.green : cov.pct >= 50 ? c.yellow : c.red;
73
- console.log(` ${"coverage".padEnd(11)} ${covColor(`${cov.pct}%`)} ${c.gray(`(${cov.totalCovered}/${cov.totalCriteria} criteria)`)}${covExtra}`);
74
-
75
- const tr = s.trace;
76
- const trExtra = tr.untraceable + tr.dropped + tr.dangling > 0
77
- ? c.yellow(` ${tr.dropped} dropped, ${tr.untraceable} untraceable`)
78
- : "";
79
- const trColor = tr.anchors === 0 ? c.gray : (tr.realized === tr.anchors && tr.untraceable === 0 ? c.green : c.yellow);
80
- const trText = tr.anchors === 0 ? "no anchors" : `${tr.realized}/${tr.anchors} anchors`;
81
- console.log(` ${"trace".padEnd(11)} ${trColor(trText)}${trExtra}`);
82
-
83
- let verifyLine = c.gray("not configured") + c.gray(" — `doctrina verify --init`");
84
- if (s.verify.invalid) {
85
- verifyLine = c.red("invalid JSON");
86
- } else if (s.verify.configured) {
87
- const n = s.verify.checks;
88
- verifyLine = c.cyan(`${n} check${n === 1 ? "" : "s"}`) + c.gray(" — run `doctrina verify`");
89
- }
90
- console.log(` ${"verify".padEnd(11)} ${verifyLine}`);
91
-
92
- // --- Work ---
93
- console.log("");
94
- console.log(c.bold(" Work"));
95
- const implBreak = Object.entries(s.specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
96
- console.log(` ${"specs".padEnd(11)} ${s.specs.total}${implBreak ? c.gray(` (${implBreak})`) : ""}`);
97
- console.log(` ${"changes".padEnd(11)} ${s.specs.openChanges} open`);
98
- const adrNotes = [];
99
- if (s.decisions.proposed > 0) adrNotes.push(c.yellow(`${s.decisions.proposed} proposed`));
100
- if (s.decisions.bare > 0) adrNotes.push(c.yellow(`${s.decisions.bare} unproven`));
101
- console.log(` ${"decisions".padEnd(11)} ${s.decisions.total}${adrNotes.length ? c.gray(" (") + adrNotes.join(c.gray(", ")) + c.gray(")") : ""}`);
102
- console.log(` ${"skills".padEnd(11)} ${s.skills}`);
103
-
104
- console.log("");
105
- console.log(c.gray(" Full gates: `doctrina validate` · `doctrina verify`. Next step: `doctrina next`."));
106
- return 0;
107
- }
108
-
109
- // Pure, render-free status snapshot — the data behind the dashboard, shared
110
- // with `prime`, `handoff`, `doctor`, and the `--json` output so every consumer
111
- // reads the same numbers.
112
- export function collectStatus(projectRoot) {
113
- let index = null;
114
- try {
115
- index = idx.load(projectRoot);
116
- } catch {
117
- index = null;
118
- }
119
- let indexState = "missing";
120
- if (index) {
121
- indexState = indexesMatch(deriveIndex(projectRoot, index), index) ? "in-sync" : "drifted";
122
- }
123
-
124
- const verifyCfg = path.join(projectRoot, CONFIG_REL);
125
- let verify = { configured: false, checks: 0, invalid: false };
126
- if (isFile(verifyCfg)) {
127
- try {
128
- const cfg = JSON.parse(read(verifyCfg));
129
- verify = { configured: true, checks: Array.isArray(cfg?.checks) ? cfg.checks.length : 0, invalid: false };
130
- } catch {
131
- verify = { configured: true, checks: 0, invalid: true };
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) {
61
+ console.error(c.red("error:") + ` --since expects a positive day count, got "${sinceRaw}"`);
62
+ return 2;
132
63
  }
64
+ options.days = days;
65
+ options.cutoffIso = windowCutoff(days);
66
+ options.git = gitWindow(projectRoot, days);
67
+ options.gitState = historyState(projectRoot);
133
68
  }
134
69
 
135
- return {
136
- project: index?.project ?? path.basename(projectRoot),
137
- stamp: index?.framework_version ?? null,
138
- cli: cliVersion(),
139
- indexState,
140
- coverage: coverageSummary(projectRoot),
141
- trace: traceSummary(projectRoot),
142
- verify,
143
- specs: countSpecs(projectRoot),
144
- decisions: countDecisions(projectRoot),
145
- skills: countSkills(projectRoot),
146
- };
147
- }
148
-
149
- function countSpecs(projectRoot) {
150
- const specsDir = path.join(projectRoot, ".doctrina", "specs");
151
- const impl = {};
152
- let total = 0;
153
- if (isDir(specsDir)) {
154
- for (const cap of readdirSync(specsDir).sort()) {
155
- const p = path.join(specsDir, cap, "spec.md");
156
- if (!isFile(p)) continue;
157
- total += 1;
158
- const implRaw = specHeader(read(p), "Implementation");
159
- if (implRaw) {
160
- const word = implRaw.trim().split(/[\s—-]+/)[0].toLowerCase();
161
- impl[word] = (impl[word] ?? 0) + 1;
162
- }
163
- }
164
- }
165
- const changesDir = path.join(projectRoot, ".doctrina", "changes");
166
- let openChanges = 0;
167
- if (isDir(changesDir)) {
168
- for (const e of readdirSync(changesDir)) {
169
- if (e === "archive" || e.startsWith(".")) continue;
170
- if (isDir(path.join(changesDir, e))) openChanges += 1;
171
- }
172
- }
173
- return { total, impl, openChanges };
174
- }
175
-
176
- function countDecisions(projectRoot) {
177
- const adrDir = path.join(projectRoot, ".doctrina", "decisions");
178
- let total = 0, proposed = 0, bare = 0;
179
- const isBare = (v) => {
180
- const t = (v ?? "").trim();
181
- return t === "" || t === "—" || t === "-";
182
- };
183
- if (isDir(adrDir)) {
184
- for (const f of walk(adrDir)) {
185
- if (!/^\d{4}-.+\.md$/.test(path.basename(f))) continue;
186
- total += 1;
187
- const text = read(f);
188
- const status = (listHeader(text, "Status") ?? "").toLowerCase();
189
- if (status === "proposed") proposed += 1;
190
- if (status === "accepted") {
191
- const evidence = listHeader(text, "Evidence");
192
- const landed = listHeader(text, "Landed");
193
- if (evidence !== null && isBare(evidence) && isBare(landed)) bare += 1;
194
- }
195
- }
196
- }
197
- return { total, proposed, bare };
198
- }
199
-
200
- function countSkills(projectRoot) {
201
- const skillsDir = path.join(projectRoot, ".doctrina", "skills");
202
- if (!isDir(skillsDir)) return 0;
203
- return walk(skillsDir).filter((f) => f.endsWith(".md")).length;
70
+ const snapshot = collectSnapshot(projectRoot);
71
+ for (const line of renderView(view, snapshot, options)) console.log(line);
72
+ return 0;
204
73
  }
205
74
 
206
75
  export const help = `
207
- Usage: doctrina status [--json]
76
+ Usage: doctrina status [--view <name>] [--since <days>] [--json]
208
77
 
209
78
  Print a one-glance health dashboard for the .doctrina/ project: the gate
210
79
  signals (index drift, framework stamp, coverage %, trace anchors, whether
@@ -212,7 +81,17 @@ verify is configured) and the artifact counts (specs by implementation
212
81
  state, open changes, decisions, skills). Read-only; always exits 0.
213
82
 
214
83
  Flags:
215
- --json Emit the snapshot as JSON (stable shape for agents and CI).
84
+ --view <name> Render a different shape of the same snapshot:
85
+ dashboard the default one-glance health board
86
+ prime the session primer (\`doctrina prime\`)
87
+ handoff the Markdown resume note (\`doctrina handoff\`)
88
+ report the period digest (\`doctrina report\`)
89
+ --since <days> With --view report: the window (default 7).
90
+ --json Emit the snapshot as JSON (stable shape for agents and CI).
91
+
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.
216
95
 
217
96
  It is a fast summary, not the authoritative gate: run \`doctrina validate\`
218
97
  and \`doctrina verify\` for the full structural and build checks, and
@@ -13,28 +13,15 @@ import { cliVersion } from "../lib/version.js";
13
13
  import { ARTIFACT_CATEGORIES } from "../lib/index-json.js";
14
14
  import { flagBool } from "../lib/args.js";
15
15
  import { c } from "../lib/colors.js";
16
+ import { collectFindings, agentsSectionCost, AGENTS_SECTIONS, PRODUCT_SECTIONS, normalizeBlock, hasHeading } from "../lib/templates-model.js";
17
+
18
+ // The findings are collected in lib/templates-model.js, which `doctor` reads
19
+ // too (audit finding F7); this command renders and fixes them.
20
+ export { collectFindings } from "../lib/templates-model.js";
16
21
  import { suggest } from "../lib/suggest.js";
17
22
 
18
23
  const SUBCOMMANDS = ["list", "check", "update"];
19
24
 
20
- // Recommended sections per file kind. Adopters whose files lack these
21
- // headings get a warning from `templates check`; they are recommendations,
22
- // not hard requirements (validate handles the hard requirements).
23
- const AGENTS_SECTIONS = [
24
- "## Stack",
25
- "## Commands",
26
- "## Repository structure",
27
- "## Conventions and boundaries",
28
- "## How to read context",
29
- ];
30
- const PRODUCT_SECTIONS = [
31
- "## Vision",
32
- "## Problem",
33
- "## Target users",
34
- "## Scope",
35
- "## Success criteria",
36
- ];
37
- const INDEX_FIELDS = ["$schema_version", "project", "artifacts"];
38
25
 
39
26
  // Flags this command accepts. Declared HERE, with the command, so
40
27
  // adding a command never requires editing the entrypoint — the gap that
@@ -69,6 +56,9 @@ function updateTemplates(flags) {
69
56
  }
70
57
 
71
58
  const plan = [];
59
+ // Updates this run declines to make, each with the reason and the number.
60
+ // Standing down is a RESULT, not a silence (change 0078).
61
+ const held = [];
72
62
 
73
63
  // AGENTS.md command-surface block: the one CLI-OWNED span of the file
74
64
  // (marker-delimited, generated from the command catalog). Three cases:
@@ -165,6 +155,20 @@ function updateTemplates(flags) {
165
155
  if (!isFile(filePath)) continue;
166
156
  const text = read(filePath);
167
157
  const missing = sections.filter((h) => !hasHeading(text, h));
158
+ // AGENTS.md has a declared line ceiling, and `analyze` refuses a change
159
+ // that resolves an overflow by raising it — so appending stubs that do
160
+ // not fit would trade one gate's recommendation for another's refusal,
161
+ // silently. The plan says what it would cost and stands down instead
162
+ // (change 0078); the check's remedy already names the cut to make first.
163
+ if (missing.length > 0 && rel === "AGENTS.md") {
164
+ const budget = agentsSectionCost(projectRoot, missing);
165
+ if (!budget.fits) {
166
+ held.push(`AGENTS.md: ${missing.length} stub section(s) not appended — they cost ` +
167
+ `${budget.cost} lines and only ${budget.slack} remain of the declared ceiling; ` +
168
+ `cut ${budget.over} line(s) of prose first`);
169
+ continue;
170
+ }
171
+ }
168
172
  if (missing.length > 0) {
169
173
  plan.push({
170
174
  describe: missing.map((h) => `${rel}: append stub section "${h}"`),
@@ -215,7 +219,16 @@ function updateTemplates(flags) {
215
219
  }
216
220
 
217
221
  const steps = plan.flatMap((p) => p.describe);
222
+ for (const reason of held) {
223
+ console.log(c.yellow("held ") + reason);
224
+ }
218
225
  if (steps.length === 0) {
226
+ if (held.length > 0) {
227
+ console.log("");
228
+ console.log(c.yellow("hold") + ` ${held.length} update${held.length === 1 ? "" : "s"} not applied — ` +
229
+ "make room first, then re-run");
230
+ return 0;
231
+ }
219
232
  console.log(c.green("ok") + " project already follows the current template shape");
220
233
  return 0;
221
234
  }
@@ -284,117 +297,6 @@ function checkTemplates() {
284
297
  return report(findings, ok);
285
298
  }
286
299
 
287
- // The findings `templates check` reports, as structured records with an
288
- // executable remedy. Exported so a test can seed each finding, run the
289
- // remedy it names, and assert the finding clears — a remedy that cannot
290
- // resolve its own finding is not a remedy (C2).
291
- export function collectFindings(projectRoot) {
292
- const findings = [];
293
- const ok = [];
294
-
295
- // AGENTS.md sections
296
- const agentsPath = path.join(projectRoot, "AGENTS.md");
297
- if (isFile(agentsPath)) {
298
- const text = read(agentsPath);
299
- for (const heading of AGENTS_SECTIONS) {
300
- if (hasHeading(text, heading)) ok.push(`AGENTS.md: ${heading}`);
301
- else findings.push({ message: `AGENTS.md missing recommended section "${heading}"`, remedy: "doctrina templates update --write" });
302
- }
303
- // Command-surface block: present and current vs the installed catalog.
304
- const block = findSurfaceBlock(text);
305
- if (!block) {
306
- findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina templates update --write" });
307
- } else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock())) {
308
- findings.push({ message: "AGENTS.md doctrina:surface block is stale vs the installed CLI", remedy: "doctrina templates update --write" });
309
- } else {
310
- ok.push("AGENTS.md: doctrina:surface block current");
311
- }
312
- } else {
313
- findings.push({ message: "AGENTS.md missing at project root", remedy: "doctrina init --force" });
314
- }
315
-
316
- // product.md sections
317
- const productPath = path.join(projectRoot, ".doctrina", "product.md");
318
- if (isFile(productPath)) {
319
- const text = read(productPath);
320
- for (const heading of PRODUCT_SECTIONS) {
321
- if (hasHeading(text, heading)) ok.push(`product.md: ${heading}`);
322
- else findings.push({ message: `.doctrina/product.md missing recommended section "${heading}"`, remedy: "doctrina templates update --write" });
323
- }
324
- } else {
325
- findings.push({ message: ".doctrina/product.md missing", remedy: "doctrina init --force" });
326
- }
327
-
328
- // Installed agent adapters: a HUB POINTER file must still reference
329
- // AGENTS.md (that is why one `upgrade --write` refresh of the hub reaches
330
- // every installed agent). Only files whose template declares the
331
- // {{AGENTS_MD_PATH}} token are pointers; a slash-command shim reaches the
332
- // hub through its parent pointer file and is not checked (C2).
333
- for (const name of listAdapterNames(projectRoot)) {
334
- const spec = adapterFiles(projectRoot, name);
335
- if (!spec) continue;
336
- for (const file of spec.files) {
337
- const installed = path.join(projectRoot, file.relativePath);
338
- if (!isFile(installed)) continue; // not installed — nothing to check
339
- if (!isHubPointer(file)) continue; // command shim, not a pointer
340
- if (read(installed).includes("AGENTS.md")) {
341
- ok.push(`adapter ${file.relativePath}: points at AGENTS.md`);
342
- } else {
343
- findings.push({
344
- message: `adapter ${file.relativePath} no longer references AGENTS.md — agents loading it will miss the hub`,
345
- // Executable and verified: `adapter add --force` rewrites exactly
346
- // this file from its template. A remedy that cannot resolve the
347
- // finding it is attached to is not a remedy (C2).
348
- remedy: `doctrina adapter add ${name} --force`,
349
- });
350
- }
351
- }
352
- }
353
-
354
- // M2: a command that cannot state its trigger has not earned a place on
355
- // the surface. The block is an agent's only discovery surface, so both
356
- // fields are required and the block has a declared size budget.
357
- for (const name of COMMAND_NAMES) {
358
- const meta = COMMAND_META[name];
359
- if (!meta || !meta.purpose || !meta.when) {
360
- findings.push({
361
- message: `command "${name}" declares no ${!meta ? "purpose or when" : (!meta.purpose ? "purpose" : "when")} — the surface block cannot say when to reach for it`,
362
- remedy: null,
363
- });
364
- }
365
- }
366
- const surfaceLines = surfaceMarkdown().split("\n").length;
367
- if (surfaceLines > SURFACE_LINE_BUDGET) {
368
- findings.push({
369
- message: `the generated surface block is ${surfaceLines} lines (budget ${SURFACE_LINE_BUDGET}) — cut commands rather than raising the budget`,
370
- remedy: null,
371
- });
372
- } else {
373
- ok.push(`surface block within budget (${surfaceLines}/${SURFACE_LINE_BUDGET} lines)`);
374
- }
375
-
376
- // index.json schema fields
377
- const indexPath = path.join(projectRoot, ".doctrina", "index.json");
378
- if (isFile(indexPath)) {
379
- try {
380
- const idx = JSON.parse(read(indexPath));
381
- for (const field of INDEX_FIELDS) {
382
- if (idx[field] !== undefined) ok.push(`index.json: ${field}`);
383
- else findings.push({ message: `.doctrina/index.json missing field "${field}"`, remedy: "doctrina templates update --write" });
384
- }
385
- if (idx.$schema_version && idx.$schema_version !== "0.1.0") {
386
- findings.push({ message: `.doctrina/index.json $schema_version is "${idx.$schema_version}" (expected "0.1.0")`, remedy: null });
387
- }
388
- } catch (err) {
389
- findings.push({ message: `.doctrina/index.json failed to parse: ${err.message}`, remedy: null });
390
- }
391
- } else {
392
- findings.push({ message: ".doctrina/index.json missing", remedy: "doctrina index rebuild" });
393
- }
394
-
395
- // Output
396
- return { findings, ok };
397
- }
398
300
 
399
301
  function report(findings, ok) {
400
302
  for (const o of ok) console.log(c.green("✓ ") + o);
@@ -443,9 +345,6 @@ function templateAnchors() {
443
345
  }
444
346
  }
445
347
 
446
- function normalizeBlock(s) {
447
- return s.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trim();
448
- }
449
348
 
450
349
  // A pre-marker AGENTS.md's hand-written surface section: from the
451
350
  // "## Doctrina command surface" heading up to (excluding) the next "## "
@@ -459,12 +358,6 @@ function findLegacySurfaceSection(text) {
459
358
  return { start: head.index, end: next ? head.index + head[0].length + next.index : text.length };
460
359
  }
461
360
 
462
- function hasHeading(text, heading) {
463
- // Match an exact h2 heading at line start (case-insensitive for the body
464
- // after "## ", so "## Vision" matches "## VISION" or "## vision" too).
465
- const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
466
- return new RegExp(`^${escaped}\\b`, "im").test(text);
467
- }
468
361
 
469
362
  export const help = `
470
363
  Usage: doctrina templates <subcommand>