doctrina-cli 0.4.0 → 0.8.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.
@@ -0,0 +1,178 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { readdirSync } from "node:fs";
4
+ import { exists, isDir, isFile, read, walk } from "../lib/fs-ops.js";
5
+ import * as idx from "../lib/index-json.js";
6
+ import { deriveIndex, indexesMatch, specHeader, listHeader } from "../lib/scan.js";
7
+ import { cliVersion } from "../lib/version.js";
8
+ import { summarize as coverageSummary } from "./coverage.js";
9
+ import { summarize as traceSummary } from "./trace.js";
10
+ import { c } from "../lib/colors.js";
11
+
12
+ // One-glance project health (review 2026-06-27 passive-user feature #1): a
13
+ // dashboard that answers "where do things stand?" in a single read, so neither
14
+ // the human nor the agent has to run validate + coverage + trace + next
15
+ // separately. Strictly read-only; never mutates and always exits 0. It reports
16
+ // the gate signals it can compute cheaply (index drift, the framework stamp,
17
+ // coverage/trace ratios, whether verify is configured) and the artifact counts;
18
+ // `doctrina validate` / `verify` remain the full, authoritative gates.
19
+
20
+ const CONFIG_REL = ".doctrina/verify.json";
21
+
22
+ export async function run(_positional, _flags) {
23
+ const projectRoot = process.cwd();
24
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
25
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
26
+ }
27
+
28
+ let index = null;
29
+ try {
30
+ index = idx.load(projectRoot);
31
+ } catch {
32
+ index = null;
33
+ }
34
+
35
+ const project = index?.project ?? path.basename(projectRoot);
36
+ const stamp = index?.framework_version ?? "—";
37
+ const cli = cliVersion();
38
+ console.log(c.bold("Doctrina status") + c.gray(` — ${project} (framework ${stamp} / CLI ${cli})`));
39
+ console.log("");
40
+
41
+ // --- Gates ---
42
+ console.log(c.bold(" Gates"));
43
+ // Index drift (the silent rot validate now errors on).
44
+ let driftLine;
45
+ if (!index) {
46
+ driftLine = c.red("missing/unreadable") + c.gray(" — run `doctrina index rebuild`");
47
+ } else if (indexesMatch(deriveIndex(projectRoot, index), index)) {
48
+ driftLine = c.green("in sync");
49
+ } else {
50
+ driftLine = c.yellow("drifted") + c.gray(" — run `doctrina validate --fix`");
51
+ }
52
+ console.log(` ${"index".padEnd(11)} ${driftLine}`);
53
+
54
+ // Framework stamp divergence.
55
+ const stampLine = stamp === cli
56
+ ? c.green("current")
57
+ : c.yellow(`${stamp} (CLI ${cli})`) + c.gray(" — `doctrina index rebuild`");
58
+ console.log(` ${"stamp".padEnd(11)} ${stampLine}`);
59
+
60
+ // Coverage and trace ratios.
61
+ const cov = coverageSummary(projectRoot);
62
+ const covExtra = cov.totalDangling + cov.totalConditional > 0
63
+ ? c.yellow(` ${cov.totalDangling} dangling, ${cov.totalConditional} conditional`)
64
+ : "";
65
+ const covColor = cov.pct === 100 ? c.green : cov.pct >= 50 ? c.yellow : c.red;
66
+ console.log(` ${"coverage".padEnd(11)} ${covColor(`${cov.pct}%`)} ${c.gray(`(${cov.totalCovered}/${cov.totalCriteria} criteria)`)}${covExtra}`);
67
+
68
+ const tr = traceSummary(projectRoot);
69
+ const trExtra = tr.untraceable + tr.dropped + tr.dangling > 0
70
+ ? c.yellow(` ${tr.dropped} dropped, ${tr.untraceable} untraceable`)
71
+ : "";
72
+ const trColor = tr.anchors === 0 ? c.gray : (tr.realized === tr.anchors && tr.untraceable === 0 ? c.green : c.yellow);
73
+ const trText = tr.anchors === 0 ? "no anchors" : `${tr.realized}/${tr.anchors} anchors`;
74
+ console.log(` ${"trace".padEnd(11)} ${trColor(trText)}${trExtra}`);
75
+
76
+ // Verify config presence (running it is the slow, authoritative gate).
77
+ const verifyCfg = path.join(projectRoot, CONFIG_REL);
78
+ let verifyLine = c.gray("not configured") + c.gray(" — `doctrina verify --init`");
79
+ if (isFile(verifyCfg)) {
80
+ try {
81
+ const cfg = JSON.parse(read(verifyCfg));
82
+ const n = Array.isArray(cfg?.checks) ? cfg.checks.length : 0;
83
+ verifyLine = c.cyan(`${n} check${n === 1 ? "" : "s"}`) + c.gray(" — run `doctrina verify`");
84
+ } catch {
85
+ verifyLine = c.red("invalid JSON");
86
+ }
87
+ }
88
+ console.log(` ${"verify".padEnd(11)} ${verifyLine}`);
89
+
90
+ // --- Work ---
91
+ console.log("");
92
+ console.log(c.bold(" Work"));
93
+ const specs = countSpecs(projectRoot);
94
+ const implBreak = Object.entries(specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
95
+ console.log(` ${"specs".padEnd(11)} ${specs.total}${implBreak ? c.gray(` (${implBreak})`) : ""}`);
96
+ console.log(` ${"changes".padEnd(11)} ${specs.openChanges} open`);
97
+ const adr = countDecisions(projectRoot);
98
+ const adrNotes = [];
99
+ if (adr.proposed > 0) adrNotes.push(c.yellow(`${adr.proposed} proposed`));
100
+ if (adr.bare > 0) adrNotes.push(c.yellow(`${adr.bare} unproven`));
101
+ console.log(` ${"decisions".padEnd(11)} ${adr.total}${adrNotes.length ? c.gray(" (") + adrNotes.join(c.gray(", ")) + c.gray(")") : ""}`);
102
+ const skills = countSkills(projectRoot);
103
+ console.log(` ${"skills".padEnd(11)} ${skills}`);
104
+
105
+ console.log("");
106
+ console.log(c.gray(" Full gates: `doctrina validate` · `doctrina verify`. Next step: `doctrina next`."));
107
+ return 0;
108
+ }
109
+
110
+ function countSpecs(projectRoot) {
111
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
112
+ const impl = {};
113
+ let total = 0;
114
+ if (isDir(specsDir)) {
115
+ for (const cap of readdirSync(specsDir).sort()) {
116
+ const p = path.join(specsDir, cap, "spec.md");
117
+ if (!isFile(p)) continue;
118
+ total += 1;
119
+ const implRaw = specHeader(read(p), "Implementation");
120
+ if (implRaw) {
121
+ const word = implRaw.trim().split(/[\s—-]+/)[0].toLowerCase();
122
+ impl[word] = (impl[word] ?? 0) + 1;
123
+ }
124
+ }
125
+ }
126
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
127
+ let openChanges = 0;
128
+ if (isDir(changesDir)) {
129
+ for (const e of readdirSync(changesDir)) {
130
+ if (e === "archive" || e.startsWith(".")) continue;
131
+ if (isDir(path.join(changesDir, e))) openChanges += 1;
132
+ }
133
+ }
134
+ return { total, impl, openChanges };
135
+ }
136
+
137
+ function countDecisions(projectRoot) {
138
+ const adrDir = path.join(projectRoot, ".doctrina", "decisions");
139
+ let total = 0, proposed = 0, bare = 0;
140
+ const isBare = (v) => {
141
+ const t = (v ?? "").trim();
142
+ return t === "" || t === "—" || t === "-";
143
+ };
144
+ if (isDir(adrDir)) {
145
+ for (const f of walk(adrDir)) {
146
+ if (!/^\d{4}-.+\.md$/.test(path.basename(f))) continue;
147
+ total += 1;
148
+ const text = read(f);
149
+ const status = (listHeader(text, "Status") ?? "").toLowerCase();
150
+ if (status === "proposed") proposed += 1;
151
+ if (status === "accepted") {
152
+ const evidence = listHeader(text, "Evidence");
153
+ const landed = listHeader(text, "Landed");
154
+ if (evidence !== null && isBare(evidence) && isBare(landed)) bare += 1;
155
+ }
156
+ }
157
+ }
158
+ return { total, proposed, bare };
159
+ }
160
+
161
+ function countSkills(projectRoot) {
162
+ const skillsDir = path.join(projectRoot, ".doctrina", "skills");
163
+ if (!isDir(skillsDir)) return 0;
164
+ return walk(skillsDir).filter((f) => f.endsWith(".md")).length;
165
+ }
166
+
167
+ export const help = `
168
+ Usage: doctrina status
169
+
170
+ Print a one-glance health dashboard for the .doctrina/ project: the gate
171
+ signals (index drift, framework stamp, coverage %, trace anchors, whether
172
+ verify is configured) and the artifact counts (specs by implementation
173
+ state, open changes, decisions, skills). Read-only; always exits 0.
174
+
175
+ It is a fast summary, not the authoritative gate: run \`doctrina validate\`
176
+ and \`doctrina verify\` for the full structural and build checks, and
177
+ \`doctrina next\` for the recommended next action.
178
+ `;
@@ -106,6 +106,37 @@ export async function run(_positional, flags) {
106
106
  return strict ? 1 : 0;
107
107
  }
108
108
 
109
+ // Pure summary of intent provenance, for other commands (`status`, `review`)
110
+ // that need the numbers without the report output.
111
+ export function summarize(projectRoot) {
112
+ const anchors = collectAnchors(projectRoot);
113
+ const specs = collectSpecs(projectRoot);
114
+ const anchorIds = new Set(anchors.map((a) => a.id));
115
+ const realizedBy = new Map();
116
+ let dangling = 0;
117
+ for (const s of specs) {
118
+ if (s.realizes === null) continue;
119
+ for (const id of s.realizes) {
120
+ if (anchorIds.has(id)) {
121
+ if (!realizedBy.has(id)) realizedBy.set(id, []);
122
+ realizedBy.get(id).push(s.cap);
123
+ } else {
124
+ dangling += 1;
125
+ }
126
+ }
127
+ }
128
+ const untraceable = specs.filter((s) => s.realizes === null && s.status === "active").length;
129
+ let realized = 0;
130
+ for (const a of anchors) if ((realizedBy.get(a.id) ?? []).length > 0) realized += 1;
131
+ return {
132
+ anchors: anchors.length,
133
+ realized,
134
+ dropped: anchors.length - realized,
135
+ dangling,
136
+ untraceable,
137
+ };
138
+ }
139
+
109
140
  // Every "[A-Z]+\d+" tag at the head of a bullet in product.md is an intent
110
141
  // anchor. Section-agnostic so Success-criteria and In-scope bullets both work.
111
142
  function collectAnchors(projectRoot) {
@@ -5,12 +5,14 @@ import { exists, isDir, isFile, lineCount, read, relPath, walk } from "../lib/fs
5
5
  import * as idx from "../lib/index-json.js";
6
6
  import { SCHEMA_VERSION } from "../lib/index-json.js";
7
7
  import { cliVersion } from "../lib/version.js";
8
+ import { today } from "../lib/dates.js";
9
+ import { flagBool } from "../lib/args.js";
8
10
  import { c } from "../lib/colors.js";
9
11
  import { parseFrontmatter } from "./skill.js";
10
12
  import { checkEars, isEarsSpec } from "../lib/ears.js";
11
- import { specHeader, listHeader } from "../lib/scan.js";
13
+ import { specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
12
14
 
13
- export async function run(_positional, _flags) {
15
+ export async function run(_positional, flags) {
14
16
  const projectRoot = process.cwd();
15
17
  const errors = [];
16
18
  const warnings = [];
@@ -47,6 +49,22 @@ export async function run(_positional, _flags) {
47
49
  }
48
50
 
49
51
  if (index) {
52
+ // --fix (F5): regenerate the index from the tree before validating, so a
53
+ // drifted index is repaired rather than reported. A full rebuild — it also
54
+ // absorbs orphans and migrates the framework stamp, matching
55
+ // `index rebuild`. Subsequent checks then see the repaired index.
56
+ if (flagBool(flags, "fix", false)) {
57
+ const derived = deriveIndex(projectRoot, index);
58
+ const staleStamp = (index.framework_version ?? null) !== cliVersion();
59
+ if (!indexesMatch(derived, index) || staleStamp) {
60
+ derived.framework_version = cliVersion();
61
+ derived.last_updated = today();
62
+ idx.save(projectRoot, derived);
63
+ console.log(c.green("fixed") + " rebuilt .doctrina/index.json from the tree");
64
+ index = derived;
65
+ }
66
+ }
67
+
50
68
  if (index.$schema_version !== SCHEMA_VERSION) {
51
69
  warnings.push(`index.json $schema_version is "${index.$schema_version}" (expected "${SCHEMA_VERSION}")`);
52
70
  }
@@ -78,15 +96,18 @@ export async function run(_positional, _flags) {
78
96
  if (!exists(full)) errors.push(`index.json references missing artifact: ${rel}`);
79
97
  }
80
98
 
81
- // 4b. Spec version drift — the Version: header inside each spec
82
- // must match the version recorded in index.json.
83
- for (const s of index.artifacts.specs ?? []) {
84
- const full = path.join(projectRoot, s.path);
85
- if (!isFile(full)) continue;
86
- const m = read(full).match(/^(?:-\s+)?\*\*Version:\*\*\s+(\S+)/m);
87
- if (m && s.version && m[1] !== s.version) {
88
- warnings.push(`${s.path} declares version ${m[1]} but index.json records ${s.version} (sync the index)`);
89
- }
99
+ // 4b. Index metadata drift (F5 / review G5 / G7). validate is the single
100
+ // source of truth about whether the project is OK, so an indexed
101
+ // artifact whose recorded metadata (version, implementation, status,
102
+ // ...) no longer matches its file is an ERROR here — not a
103
+ // pass-with-warning that only `index rebuild --check` would catch
104
+ // (the "validate says OK while next says drift" footgun). Presence
105
+ // drift (orphan on disk / missing file) stays the orphan warning and
106
+ // the missing-artifact error above; this targets the metadata that
107
+ // silently lies. `validate --fix` (above) regenerates instead.
108
+ const derived = deriveIndex(projectRoot, index);
109
+ for (const line of metadataDrift(index, derived)) {
110
+ errors.push(`${line} — run \`doctrina validate --fix\` or \`doctrina index rebuild\``);
90
111
  }
91
112
  }
92
113
  }
@@ -206,6 +227,7 @@ export async function run(_positional, _flags) {
206
227
  const specsDir = path.join(projectRoot, ".doctrina", "specs");
207
228
  if (isDir(specsDir)) {
208
229
  const { readdirSync } = await import("node:fs");
230
+ const KNOWN_SPEC_HEADERS = ["Capability", "Status", "Implementation", "Version", "Last updated", "Realizes"];
209
231
  for (const cap of readdirSync(specsDir)) {
210
232
  const specPath = path.join(specsDir, cap, "spec.md");
211
233
  if (isFile(specPath)) {
@@ -243,6 +265,47 @@ export async function run(_positional, _flags) {
243
265
  );
244
266
  }
245
267
  }
268
+
269
+ // 8e. Provenance adoption (review 2026-06-27, §5/§9). `trace` is the
270
+ // intent→capability link, but it stays inert when nothing forces
271
+ // the tag, so an active capability with no Realizes: header is an
272
+ // untraced promise — warn here (validate is always run) rather than
273
+ // only in the opt-in `trace`. Advisory, with the same escape hatch
274
+ // pattern as Implementation: any value silences it, including a
275
+ // deliberate "n/a — <why>" for an internal capability that maps to
276
+ // no product anchor. Bug specs (off the implementation axis) and
277
+ // draft/deprecated documents are exempt — only active capability
278
+ // specs make the promise.
279
+ const docStatusForRealizes = (specHeader(text, "Status") ?? "active").toLowerCase();
280
+ const onImplAxis = specHeader(text, "Implementation") !== null;
281
+ if (docStatusForRealizes === "active" && onImplAxis && specHeader(text, "Realizes") === null) {
282
+ warnings.push(
283
+ `${relPath(projectRoot, specPath)}: active spec has no Realizes: header — ` +
284
+ `it traces to no product intent (\`doctrina trace\`). Tag a product.md ` +
285
+ `success-criteria bullet "- [SC1] ..." and add "**Realizes:** SC1", or ` +
286
+ `record "**Realizes:** n/a — <why>" if it maps to no product anchor`,
287
+ );
288
+ }
289
+
290
+ // 8d. Metadata-header shape (review G11). In the header block (before
291
+ // the first `## ` section or `<!--` comment), a known key written
292
+ // without the canonical `**Key:** value` form silently fails to
293
+ // parse — the index then falls back to a default and drifts. Warn
294
+ // with the fix rather than letting the mis-typed header vanish.
295
+ const headerLines = text.split(/\r?\n/);
296
+ for (let i = 0; i < headerLines.length; i++) {
297
+ if (/^\s*(##\s|<!--)/.test(headerLines[i])) break;
298
+ for (const key of KNOWN_SPEC_HEADERS) {
299
+ const attempt = new RegExp(`^\\s*-?\\s*\\**${key}\\**\\s*:`, "i");
300
+ const canonical = new RegExp(`^(?:-\\s+)?\\*\\*${key}:\\*\\*\\s+\\S`);
301
+ if (attempt.test(headerLines[i]) && !canonical.test(headerLines[i])) {
302
+ warnings.push(
303
+ `${relPath(projectRoot, specPath)}:${i + 1} metadata header "${key}" is not in ` +
304
+ `canonical "**${key}:** value" form — it will not parse (G11)`,
305
+ );
306
+ }
307
+ }
308
+ }
246
309
  }
247
310
  }
248
311
  }
@@ -386,6 +449,29 @@ export async function run(_positional, _flags) {
386
449
  return errors.length === 0 ? 0 : 1;
387
450
  }
388
451
 
452
+ // Per-entry metadata drift between the on-disk index and the tree-derived
453
+ // one, for entries present in BOTH. Presence drift (an artifact on disk but
454
+ // not indexed, or indexed but missing) is left to the orphan warning and the
455
+ // missing-artifact error; this isolates the case the review hit — an indexed
456
+ // artifact whose recorded metadata no longer matches its file.
457
+ function metadataDrift(current, derived) {
458
+ const lines = [];
459
+ // Skills are deliberately excluded: their description has a dedicated
460
+ // reconciliation command (`doctrina skill sync`) and stays an advisory
461
+ // warning (see section 15), not a hard error.
462
+ const cats = ["specs", "decisions", "changes", "changes_archive", "contracts"];
463
+ for (const cat of cats) {
464
+ const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
465
+ const der = new Map((derived.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
466
+ for (const [id, entry] of cur) {
467
+ if (der.has(id) && stableStringify(entry) !== stableStringify(der.get(id))) {
468
+ lines.push(`index.json: ${cat} "${id}" metadata differs from its file`);
469
+ }
470
+ }
471
+ }
472
+ return lines;
473
+ }
474
+
389
475
  // Find AGENTS.md files below the root (the "nearest AGENTS.md wins"
390
476
  // hierarchy). Bounded walk: dependency, build, and VCS directories are
391
477
  // skipped so validate stays fast on large repositories.