doctrina-cli 0.15.0 → 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 (85) 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 -199
  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 +7 -75
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +121 -160
  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 +109 -6
  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 +210 -24
  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 +238 -11
  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/contract.md.template +7 -0
  82. package/templates/hooks/pre-commit.sample +6 -2
  83. package/templates/playbooks/bootstrap.md.template +43 -0
  84. package/templates/playbooks/chore.md.template +19 -0
  85. package/templates/playbooks/work.md.template +76 -0
package/src/lib/scan.js CHANGED
@@ -1,11 +1,14 @@
1
1
  // @ts-check
2
- import { getHeader, getSection } from "./doc-model.js";
2
+ import { getHeader, getSection, parseChangeTitle } from "./doc-model.js";
3
3
  import path from "node:path";
4
4
  import { readdirSync } from "node:fs";
5
5
  import { isDir, isFile, read, walk } from "./fs-ops.js";
6
6
  import { today } from "./dates.js";
7
- import { parseFrontmatter } from "../commands/skill.js";
8
- import { parseOperation, parseCapabilityFromDelta } from "../commands/change.js";
7
+ import { cliVersion, newestVersion } from "./version.js";
8
+ import { load } from "./index-json.js";
9
+ import { parseFrontmatter } from "./doc-model.js";
10
+ import { parseCapabilityFromDelta } from "./doc-model.js";
11
+ import { parseOperation } from "./doc-model.js";
9
12
 
10
13
  // Header reading lives in ONE place now (lib/doc-model.js, audit item M3).
11
14
  // These two names survive because dozens of call sites use them and the
@@ -25,6 +28,44 @@ export function parseDependsOn(text) {
25
28
  return raw.match(/[a-z][a-z0-9][a-z0-9-]*/g) ?? [];
26
29
  }
27
30
 
31
+ /**
32
+ * The source globs a spec DECLARES as its own, from the optional
33
+ * `**Source:**` header — comma-separated, `*` inside a segment and `**`
34
+ * across directories (the same minimal glob the contract's Selectors use).
35
+ *
36
+ * Doctrina infers nothing about which code belongs to which capability
37
+ * (ADR 0027, and the same principle ADR 0023 applies to the runtime
38
+ * surface). Before this header the only signals were the capability name
39
+ * appearing as a path segment and the spec happening to cite a filename,
40
+ * which left 80 of this repository's 92 source files owned by nobody — so
41
+ * `review`, the gate that asks whether the spec kept up with the code,
42
+ * could not see the code it was reviewing.
43
+ *
44
+ * Returns [] when the header is absent, "n/a", or "—".
45
+ */
46
+ export function parseSourceGlobs(text) {
47
+ const raw = specHeader(text, "Source");
48
+ if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
49
+ // Split on commas at brace depth ZERO: a comma inside `{a,b}` separates
50
+ // alternatives of ONE glob, not two globs, and splitting on it blindly
51
+ // shreds every grouped declaration into fragments that match nothing.
52
+ const out = [];
53
+ let cur = "";
54
+ let depth = 0;
55
+ for (const ch of raw) {
56
+ if (ch === "{") depth += 1;
57
+ else if (ch === "}") depth = Math.max(0, depth - 1);
58
+ if (ch === "," && depth === 0) {
59
+ out.push(cur);
60
+ cur = "";
61
+ continue;
62
+ }
63
+ cur += ch;
64
+ }
65
+ out.push(cur);
66
+ return out.map((g) => g.trim().replace(/^`|`$/g, "").trim()).filter(Boolean);
67
+ }
68
+
28
69
  function dirEntries(dir) {
29
70
  if (!isDir(dir)) return [];
30
71
  return readdirSync(dir).filter((e) => !e.startsWith(".")).sort();
@@ -95,6 +136,37 @@ export function decisionEntry(text, basename, prev, date) {
95
136
  return entry;
96
137
  }
97
138
 
139
+ /**
140
+ * The index entry for ONE open change, derived from its proposal. Same
141
+ * discipline as `decisionEntry` above and for the same reason: `deriveIndex`
142
+ * builds the whole list from it, and `doctrina work` / `change new` register
143
+ * a new change THROUGH it instead of assembling a look-alike by hand.
144
+ *
145
+ * Two constructors for one record shape is how the `lane` field (change 0042)
146
+ * — added to the deriver and not to the writer — turned every freshly opened
147
+ * change into index drift the moment it was written: `doctrina work` followed
148
+ * by `doctrina validate` errored on a tree nobody had touched.
149
+ *
150
+ * @param {string} proposal Text of the change's proposal.md ("" when absent).
151
+ * @param {string} id The change id (its directory name).
152
+ * @param {object|null} prev The entry already in the index, when there is one.
153
+ * @param {string} date Fallback for a proposal carrying no Date header.
154
+ */
155
+ export function changeEntry(proposal, id, prev, date) {
156
+ const title = parseChangeTitle(proposal);
157
+ return {
158
+ id,
159
+ title: title ?? prev?.title ?? id,
160
+ path: `.doctrina/changes/${id}`,
161
+ status: listHeader(proposal, "Status") ?? prev?.status ?? "proposed",
162
+ opened: listHeader(proposal, "Date") ?? prev?.opened ?? date,
163
+ // The lane the change was born in (change 0042). Optional: a change
164
+ // opened before the field existed simply has none, and every consumer
165
+ // treats its absence as "unknown" rather than as a lane.
166
+ ...laneOf(proposal, prev),
167
+ };
168
+ }
169
+
98
170
 
99
171
  // Regenerate the index object from the artifacts on disk. The files are
100
172
  // the source of truth; fields with no on-disk source (project name,
@@ -166,6 +238,10 @@ export function deriveIndex(projectRoot, current) {
166
238
  // dependents of a touched capability.
167
239
  const depends = specHeader(text, "Depends on") !== null ? parseDependsOn(text) : prev?.depends_on;
168
240
  if (depends && depends.length) entry.depends_on = depends;
241
+ // Source: the code this capability owns (change 0077). Declared, never
242
+ // inferred — the same discipline ADR 0023 applies to the runtime surface.
243
+ const source = specHeader(text, "Source") !== null ? parseSourceGlobs(text) : prev?.source;
244
+ if (source && source.length) entry.source = source;
169
245
  out.artifacts.specs.push(entry);
170
246
  }
171
247
 
@@ -187,14 +263,7 @@ export function deriveIndex(projectRoot, current) {
187
263
  const prev = (cur.changes ?? []).find((c) => c.id === id);
188
264
  const proposalPath = path.join(changesDir, id, "proposal.md");
189
265
  const proposal = isFile(proposalPath) ? read(proposalPath) : "";
190
- const titleMatch = proposal.match(/^#\s+Change\s+\S+\s*[—-]\s*(.+)$/m);
191
- out.artifacts.changes.push({
192
- id,
193
- title: titleMatch ? titleMatch[1].trim() : prev?.title ?? id,
194
- path: `.doctrina/changes/${id}`,
195
- status: listHeader(proposal, "Status") ?? prev?.status ?? "proposed",
196
- opened: listHeader(proposal, "Date") ?? prev?.opened ?? date,
197
- });
266
+ out.artifacts.changes.push(changeEntry(proposal, id, prev, date));
198
267
  }
199
268
 
200
269
  // Archived changes — folder name carries the applied date and id.
@@ -205,7 +274,7 @@ export function deriveIndex(projectRoot, current) {
205
274
  const prev = (cur.changes_archive ?? []).find((c) => c.path?.endsWith(name));
206
275
  const proposalPath = path.join(archiveDir, name, "proposal.md");
207
276
  const proposal = isFile(proposalPath) ? read(proposalPath) : "";
208
- const titleMatch = proposal.match(/^#\s+Change\s+\S+\s*[—-]\s*(.+)$/m);
277
+ const title = parseChangeTitle(proposal);
209
278
  const specsAffected = [];
210
279
  for (const deltaPath of walk(path.join(archiveDir, name, "specs"))) {
211
280
  if (!deltaPath.endsWith("delta.md")) continue;
@@ -217,7 +286,7 @@ export function deriveIndex(projectRoot, current) {
217
286
  }
218
287
  out.artifacts.changes_archive.push({
219
288
  id: m[2],
220
- title: titleMatch ? titleMatch[1].trim() : prev?.title ?? m[2],
289
+ title: title ?? prev?.title ?? m[2],
221
290
  path: `.doctrina/changes/archive/${name}`,
222
291
  status: "applied",
223
292
  applied: m[1],
@@ -296,3 +365,125 @@ export function indexesMatch(a, b) {
296
365
  };
297
366
  return stableStringify(normalize(a)) === stableStringify(normalize(b));
298
367
  }
368
+
369
+ // The lane recorded in a proposal header (change 0042), as an index field —
370
+ // or nothing at all. A change opened before the field existed, or one whose
371
+ // header is still the empty scaffold, has no lane, and "unknown" is the
372
+ // honest answer rather than a default that would poison the mix.
373
+ function laneOf(proposal, prev) {
374
+ const raw = (listHeader(proposal, "Lane") ?? "").trim();
375
+ if (!raw) return prev?.lane ? { lane: prev.lane } : {};
376
+ return { lane: raw };
377
+ }
378
+
379
+ /**
380
+ * Is the on-disk index still what the tree derives to, and if not, how does
381
+ * it differ?
382
+ *
383
+ * `index rebuild` renders this (and writes the derived index when asked);
384
+ * `doctor` reports it as one row. Before this it was the command's private
385
+ * business, so `doctor` answered "has the index drifted?" by spawning the
386
+ * CLI again and reading an exit code (audit finding F4).
387
+ *
388
+ * The framework stamp is migrated to the running CLI here rather than in the
389
+ * caller: deriveIndex carries the old value over so `next` does not nag on a
390
+ * version-only difference, and overriding it lets a stale stamp COUNT as
391
+ * drift, so `index rebuild` both reports and fixes it instead of
392
+ * short-circuiting on "nothing to do".
393
+ *
394
+ * @param {string} projectRoot
395
+ * @returns {{ok: boolean, drift: string[], derived: any, current: any, unreadable: string|null}}
396
+ */
397
+ export function collectIndexDrift(projectRoot) {
398
+ let current = null;
399
+ let unreadable = null;
400
+ try {
401
+ current = load(projectRoot);
402
+ } catch (err) {
403
+ unreadable = err.message;
404
+ }
405
+ const derived = deriveIndex(projectRoot, current);
406
+ // A stamp ahead of this CLI is not drift (change 0116): the tree is
407
+ // managed by a newer release, and `--check` in CI must not go red because
408
+ // one teammate's CLI is older than the one that last wrote the index.
409
+ derived.framework_version = newestVersion(current?.framework_version, cliVersion());
410
+ if (indexesMatch(derived, current)) {
411
+ return { ok: true, drift: [], derived, current, unreadable };
412
+ }
413
+ return { ok: false, drift: describeDrift(current, derived), derived, current, unreadable };
414
+ }
415
+
416
+ // Human-readable category-level drift between the on-disk index and the
417
+ // derived one: added / removed / changed entry ids.
418
+ function describeDrift(current, derived) {
419
+ const lines = [];
420
+ if (!current) return ["index.json missing or unreadable"];
421
+ if ((current.framework_version ?? null) !== (derived.framework_version ?? null)) {
422
+ lines.push(`framework_version: ${current.framework_version ?? "unset"} -> ${derived.framework_version}`);
423
+ }
424
+ const categories = ["specs", "decisions", "changes", "changes_archive", "skills"];
425
+ for (const cat of categories) {
426
+ const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
427
+ const der = new Map((derived.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
428
+ for (const id of der.keys()) {
429
+ if (!cur.has(id)) lines.push(`${cat}: "${id}" on disk but not in index`);
430
+ else if (stableStringify(cur.get(id)) !== stableStringify(der.get(id))) {
431
+ lines.push(`${cat}: "${id}" metadata differs from the files`);
432
+ }
433
+ }
434
+ for (const id of cur.keys()) {
435
+ if (!der.has(id)) lines.push(`${cat}: "${id}" in index but not on disk`);
436
+ }
437
+ }
438
+ if (stableStringify(current.artifacts?.product ?? null) !== stableStringify(derived.artifacts.product)) {
439
+ lines.push("product: metadata differs");
440
+ }
441
+ if (lines.length === 0) lines.push("structural difference (key order or missing category)");
442
+ return lines;
443
+ }
444
+
445
+ /**
446
+ * The capabilities that actually have a spec on disk.
447
+ *
448
+ * The one list every command checks a named capability against. `coverage
449
+ * --only` refuses a name that is not in it (change 0090); `context <cap>`
450
+ * refuses the same way, because a scope filter that matches nothing is the
451
+ * same defect wherever it appears. Kept here rather than in either command
452
+ * so the two cannot disagree about which capabilities exist.
453
+ *
454
+ * @param {string} projectRoot
455
+ * @returns {string[]} directory names, in readdir order
456
+ */
457
+ export function knownCapabilities(projectRoot) {
458
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
459
+ if (!isDir(specsDir)) return [];
460
+ return readdirSync(specsDir).filter((e) => isFile(path.join(specsDir, e, "spec.md")));
461
+ }
462
+
463
+ /**
464
+ * Which capabilities declare a dependency on any of `caps`.
465
+ *
466
+ * The `**Depends on:**` header is the tree's only machine-readable statement
467
+ * that one capability builds on another, and two commands ask the same
468
+ * question of it: `review` notes a dependent whose ground moved, and `close`
469
+ * reports their coverage alongside the change's own. One definition, so they
470
+ * cannot disagree about who depends on what.
471
+ *
472
+ * @param {string} projectRoot
473
+ * @param {Iterable<string>} caps
474
+ * @returns {{capability: string, dependsOn: string[]}[]} sorted by capability
475
+ */
476
+ export function dependentsOf(projectRoot, caps) {
477
+ const wanted = new Set(caps);
478
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
479
+ if (wanted.size === 0 || !isDir(specsDir)) return [];
480
+ const out = [];
481
+ for (const cap of readdirSync(specsDir).sort()) {
482
+ if (wanted.has(cap)) continue;
483
+ const specPath = path.join(specsDir, cap, "spec.md");
484
+ if (!isFile(specPath)) continue;
485
+ const deps = parseDependsOn(read(specPath)).filter((d) => wanted.has(d));
486
+ if (deps.length > 0) out.push({ capability: cap, dependsOn: deps });
487
+ }
488
+ return out;
489
+ }
@@ -0,0 +1,147 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { isFile, read, write } from "./fs-ops.js";
4
+ import { today } from "./dates.js";
5
+ import { git, GIT_STATE, isRepo } from "./git.js";
6
+
7
+ // A manual sign-off has an EXPIRY (audit finding F12).
8
+ //
9
+ // `verify --signoff` recorded `{ date, note }`, that file was read by nothing
10
+ // but `verify` itself, and the signature held forever. So the one deliberate
11
+ // escape hatch from the build gate was also the one place a green gate could
12
+ // lie indefinitely: sign off "the error copy reads well", rewrite every
13
+ // message the next morning, and the gate still says yes.
14
+ //
15
+ // That is honest gates (ADR 0008) turned on the escape hatch itself. A
16
+ // signature is a statement about code at a moment; when that code moves, the
17
+ // statement is no longer evidence — it is history.
18
+ //
19
+ // The mechanism stays: a qualitative check is legitimate, and nothing here
20
+ // removes it. What changes is that the record now carries WHAT was signed
21
+ // (the covered paths, declared in verify.json like everything else — never
22
+ // inferred) and WHEN (the commit). `verify` compares the two against the
23
+ // working tree and reports one of four states.
24
+
25
+ export const SIGNOFF_REL = ".doctrina/verify.signoffs.json";
26
+
27
+ /**
28
+ * The state of a manual check's evidence.
29
+ *
30
+ * pending never signed off.
31
+ * fresh signed, and nothing it covers has moved since.
32
+ * expired signed, but a covered path changed after the signature.
33
+ * unverifiable signed in the pre-expiry format (no commit recorded), or
34
+ * signed outside a repository — so "has it moved?" has no
35
+ * answer. Not a pass: silence about whether evidence still
36
+ * holds is not evidence that it does.
37
+ *
38
+ * Only `fresh` passes. The other three are warnings by default and failures
39
+ * under --strict, which is exactly the rule `pending` already followed — a
40
+ * manual check has always been non-blocking locally and required in CI, and
41
+ * this keeps one rule rather than inventing a second.
42
+ */
43
+ export function signoffState(projectRoot, check, record) {
44
+ if (!record || !record.date) return { state: "pending" };
45
+ const paths = declaredPaths(check);
46
+
47
+ if (!record.sha) {
48
+ return {
49
+ state: "unverifiable",
50
+ why: "signed before sign-offs recorded a commit, so what it covered cannot be checked",
51
+ record,
52
+ };
53
+ }
54
+ if (!isRepo(projectRoot)) {
55
+ return { state: "unverifiable", why: "not a git repository, so a change since the signature cannot be detected", record };
56
+ }
57
+ if (paths.length === 0) {
58
+ return {
59
+ state: "unverifiable",
60
+ why: `the check declares no "paths", so there is nothing to hold the signature to`,
61
+ record,
62
+ };
63
+ }
64
+
65
+ const changed = changedSince(projectRoot, record.sha, paths);
66
+ if (changed === null) {
67
+ return { state: "unverifiable", why: `commit ${short(record.sha)} is not in this repository's history`, record };
68
+ }
69
+ if (changed.length > 0) return { state: "expired", changed, record };
70
+ return { state: "fresh", record };
71
+ }
72
+
73
+ /** The paths a check declares it covers. Declared, never inferred. */
74
+ export function declaredPaths(check) {
75
+ const raw = check?.paths;
76
+ if (Array.isArray(raw)) return raw.filter((p) => typeof p === "string" && p.trim() !== "");
77
+ if (typeof raw === "string" && raw.trim() !== "") return [raw];
78
+ return [];
79
+ }
80
+
81
+ /**
82
+ * Which of `paths` changed between `sha` and the working tree.
83
+ * Returns null when `sha` is not a commit this repository knows — an
84
+ * unanswerable question, reported as such rather than as "nothing changed".
85
+ */
86
+ export function changedSince(projectRoot, sha, paths) {
87
+ const known = git(projectRoot, ["cat-file", "-e", `${sha}^{commit}`]);
88
+ if (known.state !== GIT_STATE.OK) return null;
89
+ // Committed changes and the working tree both count: a signature is about
90
+ // the code as it stands, not as it was last committed.
91
+ const committed = git(projectRoot, ["diff", "--name-only", sha, "--", ...paths]);
92
+ const working = git(projectRoot, ["diff", "--name-only", "HEAD", "--", ...paths]);
93
+ if (committed.state !== GIT_STATE.OK) return null;
94
+ const out = new Set(committed.lines.filter(Boolean));
95
+ if (working.state === GIT_STATE.OK) for (const f of working.lines.filter(Boolean)) out.add(f);
96
+ return [...out].sort();
97
+ }
98
+
99
+ /** The commit a signature is anchored to, or null outside a repository. */
100
+ export function currentSha(projectRoot) {
101
+ const r = git(projectRoot, ["rev-parse", "HEAD"]);
102
+ return r.state === GIT_STATE.OK ? r.stdout.trim() : null;
103
+ }
104
+
105
+ export function short(sha) {
106
+ return String(sha ?? "").slice(0, 8);
107
+ }
108
+
109
+ export function loadSignoffs(projectRoot) {
110
+ const p = path.join(projectRoot, SIGNOFF_REL);
111
+ if (!isFile(p)) return {};
112
+ try {
113
+ return JSON.parse(read(p)) ?? {};
114
+ } catch {
115
+ return {};
116
+ }
117
+ }
118
+
119
+ export function saveSignoffs(projectRoot, signoffs) {
120
+ write(path.join(projectRoot, SIGNOFF_REL), JSON.stringify(signoffs, null, 2) + "\n", { force: true });
121
+ }
122
+
123
+ /** Record a signature against today's date, the current commit, and what it covers. */
124
+ export function recordSignoff(projectRoot, check, note) {
125
+ const entry = { date: today(), note };
126
+ const sha = currentSha(projectRoot);
127
+ if (sha) entry.sha = sha;
128
+ const paths = declaredPaths(check);
129
+ if (paths.length > 0) entry.paths = paths;
130
+ return entry;
131
+ }
132
+
133
+ /**
134
+ * How many of a project's manual checks are signed and still fresh — the
135
+ * number that lets a report say "executed proof" and "signed proof" apart
136
+ * instead of counting both as green.
137
+ */
138
+ export function summarizeSignoffs(projectRoot, checks) {
139
+ const signoffs = loadSignoffs(projectRoot);
140
+ const counts = { manual: 0, fresh: 0, expired: 0, unverifiable: 0, pending: 0 };
141
+ for (const ch of checks) {
142
+ if (ch?.type !== "manual") continue;
143
+ counts.manual += 1;
144
+ counts[signoffState(projectRoot, ch, signoffs[ch.name ?? "check"]).state] += 1;
145
+ }
146
+ return counts;
147
+ }
@@ -0,0 +1,209 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { readdirSync } from "node:fs";
4
+ import { isDir, isFile, read, walk } from "./fs-ops.js";
5
+ import * as idx from "./index-json.js";
6
+ import { deriveIndex, indexesMatch, specHeader, listHeader } from "./scan.js";
7
+ import { cliVersion } from "./version.js";
8
+ import { summarize as coverageSummary } from "./coverage-model.js";
9
+ import { summarize as traceSummary } from "./trace-model.js";
10
+ import { acceptedDecisions, productSection, hasProductSection } from "./constitution-model.js";
11
+ import { computeActions } from "./actions.js";
12
+ import { readLedger } from "./ledger.js";
13
+ import { parseChangeTitle, checklistProgress } from "./doc-model.js";
14
+ import { summarizeSignoffs } from "./signoff.js";
15
+
16
+ // ONE collector, several views (audit finding F7).
17
+ //
18
+ // `status`, `prime`, `handoff` and `report` were four commands rendering the
19
+ // same three collections, and they got at them by importing functions out of
20
+ // each other's modules: `prime` reached into `status` for `collectStatus`,
21
+ // `handoff` and `report` reached into `prime` for `openChanges`, and all
22
+ // three reached into `coverage` and `trace` for their summaries. Every one of
23
+ // those is a command module — a RENDERER — being used as a data source, which
24
+ // is how four surfaces end up able to disagree about the same number.
25
+ //
26
+ // So the project's state is collected ONCE, here, into a plain object. The
27
+ // four commands become formatters over it: same numbers, four shapes, and a
28
+ // new view costs a function rather than a fifth traversal of the tree.
29
+ //
30
+ // Nothing here prints and nothing here writes.
31
+
32
+ const VERIFY_CONFIG_REL = ".doctrina/verify.json";
33
+
34
+ /**
35
+ * Everything the read-only views report, collected in one pass.
36
+ *
37
+ * `actions` is included because three of the four views end with "what next",
38
+ * and computing it separately in each was the same duplication one level up.
39
+ */
40
+ export function collectSnapshot(projectRoot, { actions = true } = {}) {
41
+ let index = null;
42
+ try {
43
+ index = idx.load(projectRoot);
44
+ } catch {
45
+ index = null;
46
+ }
47
+ let indexState = "missing";
48
+ if (index) {
49
+ indexState = indexesMatch(deriveIndex(projectRoot, index), index) ? "in-sync" : "drifted";
50
+ }
51
+
52
+ // Collected once, then shared: the views render it and `computeActions`
53
+ // recommends from it, so a recommendation can never disagree with the row
54
+ // it comes from (change 0037, extended to `next` by change 0064).
55
+ const coverage = coverageSummary(projectRoot);
56
+ const trace = traceSummary(projectRoot);
57
+ const verify = readVerifyConfig(projectRoot);
58
+
59
+ return {
60
+ project: index?.project ?? path.basename(projectRoot),
61
+ stamp: index?.framework_version ?? null,
62
+ cli: cliVersion(),
63
+ indexState,
64
+ coverage,
65
+ trace,
66
+ verify,
67
+ specs: countSpecs(projectRoot),
68
+ decisions: countDecisions(projectRoot),
69
+ skills: countSkills(projectRoot),
70
+ openChanges: openChanges(projectRoot),
71
+ adrs: acceptedDecisions(projectRoot),
72
+ nonGoals: productSection(projectRoot, "Non-goals"),
73
+ hasNonGoalsSection: hasProductSection(projectRoot, "Non-goals"),
74
+ // The gate state is already collected above; hand it over so the whole
75
+ // snapshot stays one read of the tree (change 0037).
76
+ actions: actions ? computeActions(projectRoot, { coverage, trace, verify }) : [],
77
+ archive: index?.artifacts?.changes_archive ?? [],
78
+ // The lane each open change was born in, for the report's mix. Read from
79
+ // the index rather than re-parsed, so one derivation owns the field.
80
+ openLanes: (index?.artifacts?.changes ?? []).map((c) => c.lane ?? null),
81
+ // The archive ledger, parsed (change 0046). The index records WHICH
82
+ // changes landed; the ledger records what each one touched and when, in
83
+ // one append-only file — which is what a question like "how often has
84
+ // this capability moved?" is actually asking about.
85
+ ledger: readLedger(projectRoot).entries,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * The subset `status --json`, `doctor` and the dashboard have always
91
+ * published. Kept as its own shape so the JSON envelope does not silently
92
+ * grow the day a view needs one more field.
93
+ */
94
+ export function collectStatus(projectRoot) {
95
+ const s = collectSnapshot(projectRoot, { actions: false });
96
+ return {
97
+ project: s.project,
98
+ stamp: s.stamp,
99
+ cli: s.cli,
100
+ indexState: s.indexState,
101
+ coverage: s.coverage,
102
+ trace: s.trace,
103
+ verify: s.verify,
104
+ specs: s.specs,
105
+ decisions: s.decisions,
106
+ skills: s.skills,
107
+ };
108
+ }
109
+
110
+ function readVerifyConfig(projectRoot) {
111
+ const p = path.join(projectRoot, VERIFY_CONFIG_REL);
112
+ if (!isFile(p)) return { configured: false, checks: 0, invalid: false, signoffs: null };
113
+ try {
114
+ const cfg = JSON.parse(read(p));
115
+ const checks = Array.isArray(cfg?.checks) ? cfg.checks : [];
116
+ // Executed proof and SIGNED proof are different claims (change 0039), and
117
+ // a report that adds them together hides which is which. The counts ride
118
+ // along so every view can say so without loading the sign-off store
119
+ // itself.
120
+ const signoffs = summarizeSignoffs(projectRoot, checks);
121
+ return { configured: true, checks: checks.length, invalid: false, signoffs };
122
+ } catch {
123
+ return { configured: true, checks: 0, invalid: true, signoffs: null };
124
+ }
125
+ }
126
+
127
+ /** Open changes with their proposal status/title and task progress. */
128
+ export function openChanges(projectRoot) {
129
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
130
+ const out = [];
131
+ if (!isDir(changesDir)) return out;
132
+ for (const id of readdirSync(changesDir).sort()) {
133
+ if (id === "archive" || id.startsWith(".")) continue;
134
+ if (!isDir(path.join(changesDir, id))) continue;
135
+ const proposalPath = path.join(changesDir, id, "proposal.md");
136
+ const proposal = isFile(proposalPath) ? read(proposalPath) : null;
137
+ const title = proposal ? parseChangeTitle(proposal) : null;
138
+ const status = proposal ? (listHeader(proposal, "Status") ?? "proposed") : "no proposal.md";
139
+ const tasksPath = path.join(changesDir, id, "tasks.md");
140
+ // One counter for every surface (change 0067). This one used to require
141
+ // TEXT after the box, so the scaffold's empty placeholders were invisible
142
+ // and `prime` reported "tasks 0/3" for a change with six open boxes.
143
+ const progress = isFile(tasksPath)
144
+ ? checklistProgress(read(tasksPath))
145
+ : { total: 0, done: 0, open: [], placeholders: 0 };
146
+ const tasksTotal = progress.total;
147
+ const tasksDone = progress.done;
148
+ const unchecked = progress.open.map((t) => t || "(unwritten task — the scaffold's placeholder)");
149
+ out.push({ id, title, status, tasksDone, tasksTotal, unchecked });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ function countSpecs(projectRoot) {
155
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
156
+ const impl = {};
157
+ let total = 0;
158
+ if (isDir(specsDir)) {
159
+ for (const cap of readdirSync(specsDir).sort()) {
160
+ const p = path.join(specsDir, cap, "spec.md");
161
+ if (!isFile(p)) continue;
162
+ total += 1;
163
+ const implRaw = specHeader(read(p), "Implementation");
164
+ if (implRaw) {
165
+ const word = implRaw.trim().split(/[\s—-]+/)[0].toLowerCase();
166
+ impl[word] = (impl[word] ?? 0) + 1;
167
+ }
168
+ }
169
+ }
170
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
171
+ let open = 0;
172
+ if (isDir(changesDir)) {
173
+ for (const e of readdirSync(changesDir)) {
174
+ if (e === "archive" || e.startsWith(".")) continue;
175
+ if (isDir(path.join(changesDir, e))) open += 1;
176
+ }
177
+ }
178
+ return { total, impl, openChanges: open };
179
+ }
180
+
181
+ function countDecisions(projectRoot) {
182
+ const adrDir = path.join(projectRoot, ".doctrina", "decisions");
183
+ let total = 0, proposed = 0, bare = 0;
184
+ const isBare = (v) => {
185
+ const t = (v ?? "").trim();
186
+ return t === "" || t === "—" || t === "-";
187
+ };
188
+ if (isDir(adrDir)) {
189
+ for (const f of walk(adrDir)) {
190
+ if (!/^\d{4}-.+\.md$/.test(path.basename(f))) continue;
191
+ total += 1;
192
+ const text = read(f);
193
+ const status = (listHeader(text, "Status") ?? "").toLowerCase();
194
+ if (status === "proposed") proposed += 1;
195
+ if (status === "accepted") {
196
+ const evidence = listHeader(text, "Evidence");
197
+ const landed = listHeader(text, "Landed");
198
+ if (evidence !== null && isBare(evidence) && isBare(landed)) bare += 1;
199
+ }
200
+ }
201
+ }
202
+ return { total, proposed, bare };
203
+ }
204
+
205
+ function countSkills(projectRoot) {
206
+ const skillsDir = path.join(projectRoot, ".doctrina", "skills");
207
+ if (!isDir(skillsDir)) return 0;
208
+ return walk(skillsDir).filter((f) => f.endsWith(".md")).length;
209
+ }