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
package/src/lib/scan.js CHANGED
@@ -1,14 +1,16 @@
1
1
  // @ts-check
2
2
  import { getHeader, getSection, parseChangeTitle } from "./doc-model.js";
3
+ import { projectName } from "./project.js";
3
4
  import path from "node:path";
4
5
  import { readdirSync } from "node:fs";
5
6
  import { isDir, isFile, read, walk } from "./fs-ops.js";
6
7
  import { today } from "./dates.js";
7
8
  import { cliVersion, newestVersion } from "./version.js";
8
9
  import { load } from "./index-json.js";
9
- import { parseFrontmatter } from "./doc-model.js";
10
+ import { parseFrontmatter, isScaffoldValue } from "./doc-model.js";
10
11
  import { parseCapabilityFromDelta } from "./doc-model.js";
11
12
  import { parseOperation } from "./doc-model.js";
13
+ import { git, gitLines, isRepo, GIT_STATE } from "./git.js";
12
14
 
13
15
  // Header reading lives in ONE place now (lib/doc-model.js, audit item M3).
14
16
  // These two names survive because dozens of call sites use them and the
@@ -168,6 +170,23 @@ export function changeEntry(proposal, id, prev, date) {
168
170
  }
169
171
 
170
172
 
173
+ // An archived change, as an index entry — the one constructor both the
174
+ // rebuild and `change archive` use. They each built their own, and neither
175
+ // carried the lane: an archived change lost the lane it was born in, so the
176
+ // report's "Lanes" section counted the whole history as unknown (change
177
+ // 0192), which is the question change 0042 recorded the lane to answer.
178
+ export function archivedChangeEntry(proposal, { id, archiveName, applied, specsAffected, prev = null, title = undefined }) {
179
+ return {
180
+ id,
181
+ title: (title === undefined ? parseChangeTitle(proposal) : title) ?? prev?.title ?? id,
182
+ path: `.doctrina/changes/archive/${archiveName}`,
183
+ status: "applied",
184
+ applied,
185
+ specs_affected: specsAffected,
186
+ ...laneOf(proposal, prev),
187
+ };
188
+ }
189
+
171
190
  // Regenerate the index object from the artifacts on disk. The files are
172
191
  // the source of truth; fields with no on-disk source (project name,
173
192
  // framework_version, product metadata) are carried over from `current`.
@@ -178,7 +197,7 @@ export function deriveIndex(projectRoot, current) {
178
197
 
179
198
  const out = {
180
199
  $schema_version: current?.$schema_version ?? "0.1.0",
181
- project: current?.project ?? path.basename(projectRoot),
200
+ project: projectName(projectRoot, current),
182
201
  framework_version: current?.framework_version ?? "0.0.0",
183
202
  last_updated: current?.last_updated ?? date,
184
203
  // Project settings, carried over verbatim. `deriveIndex` rebuilds the
@@ -284,14 +303,9 @@ export function deriveIndex(projectRoot, current) {
284
303
  specsAffected.push({ capability, operation: parseOperation(text) ?? "MODIFIED" });
285
304
  }
286
305
  }
287
- out.artifacts.changes_archive.push({
288
- id: m[2],
289
- title: title ?? prev?.title ?? m[2],
290
- path: `.doctrina/changes/archive/${name}`,
291
- status: "applied",
292
- applied: m[1],
293
- specs_affected: specsAffected,
294
- });
306
+ out.artifacts.changes_archive.push(archivedChangeEntry(proposal, {
307
+ id: m[2], archiveName: name, applied: m[1], specsAffected, prev, title,
308
+ }));
295
309
  }
296
310
 
297
311
  // Contracts — the integration/runtime surface (ports, env, interfaces)
@@ -310,18 +324,26 @@ export function deriveIndex(projectRoot, current) {
310
324
  });
311
325
  }
312
326
 
313
- // Skills — frontmatter description wins.
327
+ // Skills — a WRITTEN frontmatter description wins. The rebuild is the one
328
+ // reconciliation (change 0177 retired `skill sync`, which did a subset of
329
+ // this), so it carries sync's rule: a description still in the scaffold's
330
+ // `<...>` form never overwrites one somebody wrote (change 0111), and the
331
+ // entry's date moves when its description does.
314
332
  const skillsDir = path.join(dot, "skills");
315
333
  for (const f of walk(skillsDir)) {
316
334
  if (!f.endsWith(".md")) continue;
317
335
  const id = path.basename(f, ".md");
318
336
  const prev = (cur.skills ?? []).find((s) => s.id === id);
337
+ const written = parseFrontmatter(read(f), "description");
338
+ const kept = prev?.description && !isScaffoldValue(prev.description) ? prev.description : null;
339
+ const description = written && !isScaffoldValue(written)
340
+ ? written
341
+ : kept ?? written ?? "<edit me — one-sentence summary>";
319
342
  out.artifacts.skills.push({
320
343
  id,
321
344
  path: `.doctrina/skills/${id}.md`,
322
- description: parseFrontmatter(read(f), "description")
323
- ?? prev?.description ?? "<edit me — one-sentence summary>",
324
- last_updated: prev?.last_updated ?? date,
345
+ description,
346
+ last_updated: prev && prev.description === description ? prev.last_updated ?? date : date,
325
347
  });
326
348
  }
327
349
 
@@ -487,3 +509,106 @@ export function dependentsOf(projectRoot, caps) {
487
509
  }
488
510
  return out;
489
511
  }
512
+
513
+ // ---------------------------------------------------------------- staged
514
+
515
+ /**
516
+ * The same question as `collectIndexDrift`, asked of the COMMIT instead of
517
+ * the disk.
518
+ *
519
+ * `index.json` is derived from the whole tree, so it cannot describe a
520
+ * subset of it. Stage some of `.doctrina/` and the index that rides along
521
+ * names artifacts the commit does not carry — and every gate that would
522
+ * catch it reads the working tree, where those artifacts are still present.
523
+ * Four commits of this repository shipped that way, each green locally and
524
+ * each red on checkout: `index.json references missing artifact`.
525
+ *
526
+ * Read with git plumbing, never from disk: `:<path>` is the staged blob and
527
+ * `ls-files --cached` is the file list the commit will have.
528
+ *
529
+ * @param {string} projectRoot
530
+ * @returns {{ok: boolean, applicable: boolean, missing: string[], unindexed: string[], reason: string|null}}
531
+ */
532
+ export function collectStagedIndexDrift(projectRoot) {
533
+ const nothing = { ok: true, applicable: false, missing: [], unindexed: [], reason: null };
534
+ if (!isRepo(projectRoot)) return { ...nothing, reason: "not a git repository" };
535
+
536
+ const blob = git(projectRoot, ["show", ":.doctrina/index.json"]);
537
+ if (blob.state !== GIT_STATE.OK || !blob.stdout.trim()) {
538
+ // Not staged at all — this commit does not carry an index, so it cannot
539
+ // carry one that disagrees with itself.
540
+ return { ...nothing, reason: "no staged .doctrina/index.json" };
541
+ }
542
+
543
+ let staged;
544
+ try {
545
+ staged = JSON.parse(blob.stdout);
546
+ } catch {
547
+ return { ok: false, applicable: true, missing: [], unindexed: [],
548
+ reason: "the staged .doctrina/index.json is not valid JSON" };
549
+ }
550
+
551
+ const files = new Set(gitLines(projectRoot, ["ls-files", "--cached", "--", ".doctrina"]));
552
+ // An entry's path is a file for specs, ADRs, skills and contracts, and a
553
+ // DIRECTORY for changes — a directory is in the commit when any file under
554
+ // it is.
555
+ const inCommit = (p) => files.has(p) || [...files].some((f) => f.startsWith(`${p}/`));
556
+
557
+ const artifacts = staged.artifacts ?? {};
558
+ const missing = [];
559
+ for (const [kind, entries] of Object.entries(artifacts)) {
560
+ if (!Array.isArray(entries)) continue;
561
+ for (const entry of entries) {
562
+ const p = String(entry?.path ?? "");
563
+ if (p && !inCommit(p)) missing.push(`${kind}: ${p}`);
564
+ }
565
+ }
566
+
567
+ // The mirror case: an artifact staged into the commit that the staged index
568
+ // never heard of. Same shapes `index rebuild` derives entries from.
569
+ const indexed = new Set();
570
+ for (const entries of Object.values(artifacts)) {
571
+ if (Array.isArray(entries)) for (const e of entries) indexed.add(String(e?.path ?? ""));
572
+ }
573
+ const unindexed = [];
574
+ for (const f of [...files].sort()) {
575
+ const owner = artifactPathOf(f);
576
+ if (owner && !indexed.has(owner)) unindexed.push(owner);
577
+ }
578
+
579
+ const unique = [...new Set(unindexed)];
580
+ return {
581
+ ok: missing.length === 0 && unique.length === 0,
582
+ applicable: true,
583
+ missing,
584
+ unindexed: unique,
585
+ reason: null,
586
+ };
587
+ }
588
+
589
+ // The index entry a staged file belongs to, or null when the file is not an
590
+ // artifact (templates, config.json, product.md, the index itself).
591
+ function artifactPathOf(file) {
592
+ let m = /^\.doctrina\/specs\/[^/]+\/spec\.md$/.exec(file);
593
+ if (m) return file;
594
+ m = /^\.doctrina\/(?:decisions|skills|contracts)\/[^/]+\.md$/.exec(file);
595
+ if (m) return file;
596
+ m = /^(\.doctrina\/changes\/archive\/[^/]+)\/proposal\.md$/.exec(file);
597
+ if (m) return m[1];
598
+ m = /^(\.doctrina\/changes\/(?!archive\/)[^/]+)\/proposal\.md$/.exec(file);
599
+ if (m) return m[1];
600
+ return null;
601
+ }
602
+
603
+ // The capabilities a change's deltas target, sorted — the change's own scope.
604
+ // `close` gates coverage on it and `review --change` reads it as "these specs
605
+ // are being updated by this change", because the delta merges at apply.
606
+ export function deltaCapabilities(changeDir) {
607
+ const caps = new Set();
608
+ for (const deltaPath of walk(path.join(changeDir, "specs"))) {
609
+ if (!deltaPath.endsWith("delta.md")) continue;
610
+ const cap = parseCapabilityFromDelta(read(deltaPath), deltaPath);
611
+ if (cap) caps.add(cap);
612
+ }
613
+ return [...caps].sort();
614
+ }
@@ -96,6 +96,30 @@ export function changedSince(projectRoot, sha, paths) {
96
96
  return [...out].sort();
97
97
  }
98
98
 
99
+ /**
100
+ * Which of `paths` the uncommitted work touches — edits against HEAD plus
101
+ * untracked files, `.gitignore` respected (change 0204). The same pathspec
102
+ * reading a sign-off's "paths" gets, so "what this check covers" means one
103
+ * thing in verify.json. Null when git cannot answer: a caller then runs the
104
+ * check rather than reading "nothing changed" out of a question it never
105
+ * got to ask.
106
+ *
107
+ * @param {string} projectRoot
108
+ * @param {string[]} paths
109
+ * @returns {string[] | null}
110
+ */
111
+ export function workingChanges(projectRoot, paths) {
112
+ const tracked = git(projectRoot, ["diff", "--name-only", "HEAD", "--", ...paths]);
113
+ // No commit yet: every file is new, so what is staged counts as changed.
114
+ const staged = tracked.state === GIT_STATE.EMPTY
115
+ ? git(projectRoot, ["ls-files", "--cached", "--", ...paths])
116
+ : tracked;
117
+ if (staged.state !== GIT_STATE.OK) return null;
118
+ const untracked = git(projectRoot, ["ls-files", "--others", "--exclude-standard", "--", ...paths]);
119
+ if (untracked.state !== GIT_STATE.OK) return null;
120
+ return [...new Set([...staged.lines, ...untracked.lines].filter(Boolean))].sort();
121
+ }
122
+
99
123
  /** The commit a signature is anchored to, or null outside a repository. */
100
124
  export function currentSha(projectRoot) {
101
125
  const r = git(projectRoot, ["rev-parse", "HEAD"]);
@@ -0,0 +1,162 @@
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 { git, GIT_STATE } from "./git.js";
6
+ import { FIX_SHAPED, FIX_SHAPED_SUBJECT, skillSlug } from "./lexicon.js";
7
+
8
+ // The lessons worth a skill, as DATA (change 0202). `skill suggest` listed
9
+ // them and `close` printed that whole listing at the end of every close —
10
+ // the same thirteen candidates, close after close, which a field review of
11
+ // 0.17 called noise. The close now asks for the list and renders only what
12
+ // THIS close produced, so the collection lives here, where both read it
13
+ // (ADR 0025: commands render, lib decides).
14
+
15
+ /**
16
+ * @typedef {object} SkillCandidate
17
+ * @property {string} id
18
+ * @property {string} slug
19
+ * @property {"change"|"commit"} source
20
+ * @property {string} [from] archive folder name, for a change candidate
21
+ * @property {string} [ref] short sha, for a commit candidate
22
+ * @property {string} [why]
23
+ */
24
+
25
+ /**
26
+ * Fix-shaped lessons not yet captured as a skill, from the archive and the
27
+ * git history, deduped by slug.
28
+ *
29
+ * @param {string} projectRoot
30
+ * @param {{ since?: string, limit?: number }} [opts]
31
+ * @returns {SkillCandidate[]}
32
+ */
33
+ export function skillCandidates(projectRoot, { since, limit = 200 } = {}) {
34
+ const skillsDir = path.join(projectRoot, ".doctrina", "skills");
35
+ const existingFiles = isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")) : [];
36
+ const existing = new Set(existingFiles.map((f) => path.basename(f, ".md")));
37
+ // A lesson may already be captured under a DIFFERENT slug (0.11.0 field
38
+ // review item 11: the human names the skill after the lesson, not after the
39
+ // change id — "tela-preta-crash-de-render..." for change 0049). Two extra
40
+ // dedup signals: an existing skill whose body cites the candidate's change
41
+ // id / commit hash, and token-overlap similarity between the slugs.
42
+ const existingBodies = existingFiles.map((f) => ({ slug: path.basename(f, ".md"), body: read(f) }));
43
+ const captured = (cand) => {
44
+ if (existing.has(cand.slug)) return true;
45
+ for (const sk of existingBodies) {
46
+ if (cand.id && sk.body.includes(cand.id)) return true;
47
+ if (cand.from && sk.body.includes(cand.from)) return true;
48
+ if (slugSimilarity(cand.slug, sk.slug) >= 0.5) return true;
49
+ }
50
+ return false;
51
+ };
52
+
53
+ // Two deterministic sources, deduped by slug. `seen` guards against a lesson
54
+ // appearing twice (e.g. a fix committed and later archived as a change).
55
+ /** @type {SkillCandidate[]} */
56
+ const candidates = [];
57
+ const seen = new Set();
58
+
59
+ // Source 1 — archived change proposals with a fix-shaped folder name.
60
+ const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
61
+ if (isDir(archiveDir)) {
62
+ for (const name of readdirSync(archiveDir).sort()) {
63
+ if (!isDir(path.join(archiveDir, name))) continue;
64
+ const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
65
+ if (!FIX_SHAPED.test(id)) continue;
66
+ const slug = skillSlug(id);
67
+ if (seen.has(slug)) continue;
68
+ /** @type {SkillCandidate} */
69
+ const cand = { id, slug, source: "change", from: name };
70
+ if (captured(cand)) continue; // captured (any slug) or cited by an existing skill
71
+ const proposal = path.join(archiveDir, name, "proposal.md");
72
+ cand.why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
73
+ seen.add(slug);
74
+ candidates.push(cand);
75
+ }
76
+ }
77
+
78
+ // Source 2 — fix-shaped commits in the git history (ADR 0013). Archive wins a
79
+ // slug collision: a `## Why` paragraph is a richer seed than a subject line.
80
+ for (const cand of gitFixCommits(projectRoot, { since, limit })) {
81
+ if (seen.has(cand.slug) || captured(cand)) continue;
82
+ seen.add(cand.slug);
83
+ candidates.push(cand);
84
+ }
85
+
86
+ return candidates;
87
+ }
88
+
89
+ // Source 2 for `skill suggest`: fix-shaped commits in the git history (ADR
90
+ // 0013). Deterministic — a regex on the subject, never a read of the diff
91
+ // (ADR 0005 semantic-fidelity ceiling). Returns [] silently when this is not a
92
+ // git repo, git is absent, or the --since ref is unknown, so the command
93
+ // degrades to archive-only rather than erroring.
94
+ function gitFixCommits(projectRoot, { since, limit }) {
95
+ // US (0x1f) between fields, RS (0x1e) between records — robust against any
96
+ // newline or punctuation inside a subject or body.
97
+ const format = "%H%x1f%s%x1f%b%x1e";
98
+ const args = ["log", "--no-merges", `--format=${format}`];
99
+ if (since) args.push(`${since}..HEAD`);
100
+ else args.push("-n", String(limit));
101
+
102
+ const r = git(projectRoot, args);
103
+ if (r.state !== GIT_STATE.OK || !r.stdout) return [];
104
+
105
+ /** @type {SkillCandidate[]} */
106
+ const out = [];
107
+ const seen = new Set();
108
+ for (const record of r.stdout.split("\x1e")) {
109
+ const [sha, subject = "", body = ""] = record.replace(/^\r?\n/, "").split("\x1f");
110
+ if (!sha || !sha.trim()) continue;
111
+ if (!FIX_SHAPED_SUBJECT.test(subject)) continue;
112
+ const slug = commitSlug(subject);
113
+ if (!slug || seen.has(slug)) continue; // first commit wins a duplicate lesson
114
+ seen.add(slug);
115
+ const firstBodyLine = body.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
116
+ out.push({
117
+ id: slug,
118
+ slug,
119
+ source: "commit",
120
+ ref: sha.trim().slice(0, 9),
121
+ why: subject.trim() + (firstBodyLine ? ` — ${firstBodyLine}` : ""),
122
+ });
123
+ }
124
+ return out;
125
+ }
126
+
127
+ // Derive a skill slug from a commit subject: drop a conventional-commit
128
+ // "type(scope): " prefix and hand the rest to the shared slug, which folds.
129
+ function commitSlug(subject) {
130
+ return skillSlug(subject.replace(/^[a-z]+(?:\([^)]*\))?!?\s*:\s*/i, ""));
131
+ }
132
+
133
+ // Token-overlap similarity between two kebab slugs (Jaccard over meaningful
134
+ // tokens). "0049-fix-tela-preta-render" vs "tela-preta-crash-de-render-sem-
135
+ // error-boundary" share tela/preta/render → high overlap even though the
136
+ // human renamed the skill after the lesson. Deterministic; never semantic.
137
+ function slugSimilarity(a, b) {
138
+ const toks = (s) => new Set(
139
+ String(s).split("-").filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !SLUG_NOISE.has(t)),
140
+ );
141
+ const A = toks(a);
142
+ const B = toks(b);
143
+ if (A.size === 0 || B.size === 0) return 0;
144
+ let inter = 0;
145
+ for (const t of A) if (B.has(t)) inter += 1;
146
+ return inter / (A.size + B.size - inter);
147
+ }
148
+
149
+ // Fix-shaped verbs and connectives carry no lesson identity — without this,
150
+ // every "fix-..." candidate looks 30% similar to every "fix-..." skill.
151
+ const SLUG_NOISE = new Set([
152
+ "fix", "bug", "hotfix", "patch", "the", "and", "for", "with", "sem", "com",
153
+ "que", "nao", "por", "para", "una", "uma", "dos", "das",
154
+ ]);
155
+
156
+ function firstWhyLine(proposalText) {
157
+ const m = proposalText.match(/##\s*Why\s*\r?\n+([^\r\n][^\r\n]*)/i);
158
+ if (!m) return "";
159
+ const line = m[1].trim();
160
+ return /^<!--/.test(line) ? "" : line;
161
+ }
162
+
@@ -2,6 +2,7 @@
2
2
  import path from "node:path";
3
3
  import { readdirSync } from "node:fs";
4
4
  import { isDir, isFile, read, walk } from "./fs-ops.js";
5
+ import { projectName } from "./project.js";
5
6
  import * as idx from "./index-json.js";
6
7
  import { deriveIndex, indexesMatch, specHeader, listHeader } from "./scan.js";
7
8
  import { cliVersion } from "./version.js";
@@ -12,6 +13,9 @@ import { computeActions } from "./actions.js";
12
13
  import { readLedger } from "./ledger.js";
13
14
  import { parseChangeTitle, checklistProgress } from "./doc-model.js";
14
15
  import { summarizeSignoffs } from "./signoff.js";
16
+ import { gitWindow, historyState, windowCutoff } from "./git.js";
17
+ import { collectMetrics } from "./metrics-model.js";
18
+ import { today } from "./dates.js";
15
19
 
16
20
  // ONE collector, several views (audit finding F7).
17
21
  //
@@ -57,7 +61,7 @@ export function collectSnapshot(projectRoot, { actions = true } = {}) {
57
61
  const verify = readVerifyConfig(projectRoot);
58
62
 
59
63
  return {
60
- project: index?.project ?? path.basename(projectRoot),
64
+ project: projectName(projectRoot, index),
61
65
  stamp: index?.framework_version ?? null,
62
66
  cli: cliVersion(),
63
67
  indexState,
@@ -146,11 +150,47 @@ export function openChanges(projectRoot) {
146
150
  const tasksTotal = progress.total;
147
151
  const tasksDone = progress.done;
148
152
  const unchecked = progress.open.map((t) => t || "(unwritten task — the scaffold's placeholder)");
149
- out.push({ id, title, status, tasksDone, tasksTotal, unchecked });
153
+ const date = proposal ? (listHeader(proposal, "Date") ?? null) : null;
154
+ out.push({ id, title, status, tasksDone, tasksTotal, unchecked, date, ageDays: ageInDays(date) });
150
155
  }
151
156
  return out;
152
157
  }
153
158
 
159
+ // How old a change has to be before "still open" is worth saying on its own.
160
+ export const STALE_CHANGE_DAYS = 7;
161
+
162
+ /**
163
+ * The open changes worth a line at the end of someone else's close (change
164
+ * 0201): a change that LOOKS finished — applied, or every task ticked — and
165
+ * was never closed, and a change open for a week or more. A field review of
166
+ * 0.17 found change 0078 shipped and left open, and no later close said so:
167
+ * the close is the moment someone is looking, so it is where to say it.
168
+ *
169
+ * @param {string} projectRoot
170
+ * @param {{ exclude?: string[] }} [opts]
171
+ */
172
+ export function unfinishedChanges(projectRoot, { exclude = [] } = {}) {
173
+ const skip = new Set(exclude);
174
+ const out = [];
175
+ for (const ch of openChanges(projectRoot)) {
176
+ if (skip.has(ch.id)) continue;
177
+ const finished = ch.status === "applied" ||
178
+ (ch.tasksTotal > 0 && ch.tasksDone === ch.tasksTotal);
179
+ const old = ch.ageDays !== null && ch.ageDays >= STALE_CHANGE_DAYS;
180
+ const reason = ch.status === "applied"
181
+ ? "applied but never closed"
182
+ : finished ? "every task ticked, never closed" : old ? "still open" : null;
183
+ out.push({ ...ch, finished, old, reason });
184
+ }
185
+ return out;
186
+ }
187
+
188
+ function ageInDays(date) {
189
+ if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
190
+ const ms = Date.parse(`${today()}T00:00:00Z`) - Date.parse(`${date}T00:00:00Z`);
191
+ return Number.isFinite(ms) ? Math.max(0, Math.round(ms / 86400000)) : null;
192
+ }
193
+
154
194
  function countSpecs(projectRoot) {
155
195
  const specsDir = path.join(projectRoot, ".doctrina", "specs");
156
196
  const impl = {};
@@ -207,3 +247,22 @@ function countSkills(projectRoot) {
207
247
  if (!isDir(skillsDir)) return 0;
208
248
  return walk(skillsDir).filter((f) => f.endsWith(".md")).length;
209
249
  }
250
+
251
+ /**
252
+ * Everything the period digest reads beyond the snapshot, for a window of
253
+ * `days`. ONE collector for every door that renders the digest: `report`
254
+ * passed the metrics and `status --view report` did not, so the view that
255
+ * promised "never different numbers" printed two lines fewer (change 0176).
256
+ *
257
+ * @param {string} projectRoot
258
+ * @param {number} days
259
+ */
260
+ export function collectReportWindow(projectRoot, days) {
261
+ return {
262
+ days,
263
+ cutoffIso: windowCutoff(days),
264
+ git: gitWindow(projectRoot, days),
265
+ gitState: historyState(projectRoot),
266
+ metrics: collectMetrics(projectRoot, `${days} days ago`),
267
+ };
268
+ }