doctrina-cli 0.17.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.
@@ -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
+
@@ -15,6 +15,7 @@ import { parseChangeTitle, checklistProgress } from "./doc-model.js";
15
15
  import { summarizeSignoffs } from "./signoff.js";
16
16
  import { gitWindow, historyState, windowCutoff } from "./git.js";
17
17
  import { collectMetrics } from "./metrics-model.js";
18
+ import { today } from "./dates.js";
18
19
 
19
20
  // ONE collector, several views (audit finding F7).
20
21
  //
@@ -149,11 +150,47 @@ export function openChanges(projectRoot) {
149
150
  const tasksTotal = progress.total;
150
151
  const tasksDone = progress.done;
151
152
  const unchecked = progress.open.map((t) => t || "(unwritten task — the scaffold's placeholder)");
152
- 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) });
153
155
  }
154
156
  return out;
155
157
  }
156
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
+
157
194
  function countSpecs(projectRoot) {
158
195
  const specsDir = path.join(projectRoot, ".doctrina", "specs");
159
196
  const impl = {};
@@ -117,6 +117,40 @@ function matchOpsBlock(text) {
117
117
  return null;
118
118
  }
119
119
 
120
+ /** One op line, parsed by the grammar `apply` reads: `{ verb, ... }` or `{ verb, error }`. */
121
+ export function parseOp(line) {
122
+ return parseOpLine(String(line).trim());
123
+ }
124
+
125
+ /**
126
+ * Append op lines to a delta's live `ops` block — creating the block at the
127
+ * end when there is none (change 0205). A `bump-version` line replaces the
128
+ * block's existing one instead of stacking a second bump on it. Pure.
129
+ *
130
+ * @param {string} deltaText
131
+ * @param {string[]} lines already validated with `parseOp`
132
+ * @returns {string}
133
+ */
134
+ export function insertOps(deltaText, lines) {
135
+ const ranges = commentRanges(deltaText);
136
+ const fenceRe = /^([ \t]*```[ \t]*ops[ \t]*\r?\n)([\s\S]*?)(^[ \t]*```[ \t]*$)/gm;
137
+ const bumps = lines.some((l) => parseOp(l).verb === "bump-version");
138
+ let m;
139
+ while ((m = fenceRe.exec(deltaText))) {
140
+ if (isInsideComment(ranges, m.index)) continue;
141
+ const kept = m[2].split(/\r?\n/).filter((l) => l.trim() !== "")
142
+ .filter((l) => !(bumps && parseOp(l).verb === "bump-version"));
143
+ // The bump goes last, where a reader looks for it.
144
+ const ordered = [...kept, ...lines].sort((a, b) =>
145
+ Number(parseOp(a).verb === "bump-version") - Number(parseOp(b).verb === "bump-version"));
146
+ const block = `${m[1]}${ordered.join("\n")}\n${m[3]}`;
147
+ return deltaText.slice(0, m.index) + block + deltaText.slice(m.index + m[0].length);
148
+ }
149
+ const ordered = [...lines].sort((a, b) =>
150
+ Number(parseOp(a).verb === "bump-version") - Number(parseOp(b).verb === "bump-version"));
151
+ return `${deltaText.replace(/\s*$/, "")}\n\n\`\`\`ops\n${ordered.join("\n")}\n\`\`\`\n`;
152
+ }
153
+
120
154
  function parseOpLine(line) {
121
155
  // `<verb> <rest>` — the verb is the first whitespace-delimited token.
122
156
  const sp = line.indexOf(" ");
@@ -178,6 +212,24 @@ function parseOpLine(line) {
178
212
  if (!value) return { verb, error: `replace-requirement ${n} is missing the requirement text` };
179
213
  return { verb, section, n, value };
180
214
  }
215
+ case "remove-requirement": {
216
+ // remove-requirement <section> <n>[: <why>] — the reason is for the
217
+ // reader of the delta; the spec loses the bullet either way.
218
+ const colon = rest.indexOf(":");
219
+ const head = (colon < 0 ? rest : rest.slice(0, colon)).trim().split(/\s+/);
220
+ const n = Number(head[head.length - 1]);
221
+ const section = normalizeSection(head.slice(0, -1).join(" "));
222
+ if (!section) return { verb, error: `remove-requirement: unknown section "${head.slice(0, -1).join(" ")}" (use ${SECTION_TOKENS})` };
223
+ if (!Number.isInteger(n) || n < 1) return { verb, error: `remove-requirement needs "<section> <n>" with a positive bullet number (got "${rest}")` };
224
+ return { verb, section, n };
225
+ }
226
+ case "remove-criterion": {
227
+ // remove-criterion <n>[: <why>]
228
+ const colon = rest.indexOf(":");
229
+ const n = Number((colon < 0 ? rest : rest.slice(0, colon)).trim());
230
+ if (!Number.isInteger(n) || n < 1) return { verb, error: `remove-criterion needs a positive criterion number (got "${rest}")` };
231
+ return { verb, n };
232
+ }
181
233
  default:
182
234
  return { verb: null, error: `unknown operation "${verb}"` };
183
235
  }
@@ -238,6 +290,10 @@ function applyOne(text, op) {
238
290
  return appendRequirement(text, op.section, op.value);
239
291
  case "replace-requirement":
240
292
  return replaceRequirement(text, op.section, op.n, op.value);
293
+ case "remove-requirement":
294
+ return removeRequirement(text, op.section, op.n);
295
+ case "remove-criterion":
296
+ return removeCriterion(text, op.n);
241
297
  default:
242
298
  return { error: `unknown operation "${op.verb}"` };
243
299
  }
@@ -334,10 +390,33 @@ export function replaceCriterion(text, n, value) {
334
390
  if (!loc) return { error: "replace-criterion: spec has no '## Acceptance criteria' section" };
335
391
  const item = loc.items.find((it) => it.n === n);
336
392
  if (!item) return { error: `replace-criterion: no criterion #${n} found` };
337
- loc.lines[item.lineIndex] = `${item.indent}${n}. ${value}`;
393
+ // The whole item, continuation lines included (change 0206) — the lesson
394
+ // `replace-requirement` learned, which this verb never picked up: a
395
+ // criterion wrapped over three lines kept its old second and third lines
396
+ // under the new first one, so the spec stated a proof and then the
397
+ // previous proof's tail.
398
+ loc.lines.splice(item.lineIndex, endOfItem(loc.lines, item.lineIndex) - item.lineIndex + 1, `${item.indent}${n}. ${value}`);
338
399
  return { text: loc.lines.join("\n"), summary: `replace-criterion ${n}` };
339
400
  }
340
401
 
402
+ // remove-criterion <n> — delete criterion n, continuation lines included,
403
+ // and renumber the ones after it so the list stays 1..N (change 0206). A
404
+ // spec only ever grew: there was no verb to take a criterion out, so a
405
+ // superseded one stayed beside its replacement for good.
406
+ export function removeCriterion(text, n) {
407
+ const loc = locateCriteria(text);
408
+ if (!loc) return { error: "remove-criterion: spec has no '## Acceptance criteria' section" };
409
+ const item = loc.items.find((it) => it.n === n);
410
+ if (!item) return { error: `remove-criterion: no criterion #${n} found` };
411
+ const end = endOfItem(loc.lines, item.lineIndex);
412
+ const lines = loc.lines;
413
+ for (const it of loc.items) {
414
+ if (it.n > n) lines[it.lineIndex] = lines[it.lineIndex].replace(/^(\s*)\d+\./, `$1${it.n - 1}.`);
415
+ }
416
+ lines.splice(item.lineIndex, end - item.lineIndex + 1);
417
+ return { text: lines.join("\n"), summary: `remove-criterion ${n} (${loc.items.length - n} renumbered)` };
418
+ }
419
+
341
420
  // The `### <Section>` blocks under `## Requirements (EARS)`. Returns
342
421
  // { lines, sections: { <token>: { headingIndex, endIndex, bullets: [lineIndex] } } }
343
422
  // or null when the Requirements section is absent. A section's token is the
@@ -505,6 +584,23 @@ export function replaceRequirement(text, section, n, value) {
505
584
  return { text: loc.lines.join("\n"), summary: `replace-requirement ${section} ${n}` };
506
585
  }
507
586
 
587
+ // remove-requirement <section> <n> — delete the nth bullet (1-based) of the
588
+ // EARS subsection, continuation lines included (change 0206). The heading
589
+ // stays even when it empties: `append-requirement` can fill it again.
590
+ export function removeRequirement(text, section, n) {
591
+ const loc = locateRequirements(text);
592
+ if (!loc) return { error: "remove-requirement: spec has no '## Requirements (EARS)' section" };
593
+ const sec = loc.sections[section];
594
+ if (!sec) return { error: `remove-requirement: no '### ${section}...' subsection found under Requirements` };
595
+ const real = sec.bullets.filter((i) => !/^\s*-\s*$/.test(loc.lines[i]));
596
+ if (n > real.length) {
597
+ return { error: `remove-requirement: section "${section}" has ${real.length} requirement${real.length === 1 ? "" : "s"}, no #${n}` };
598
+ }
599
+ const start = real[n - 1];
600
+ loc.lines.splice(start, endOfItem(loc.lines, start) - start + 1);
601
+ return { text: loc.lines.join("\n"), summary: `remove-requirement ${section} ${n}` };
602
+ }
603
+
508
604
  // append-criterion <text> — add a new numbered criterion after the last one
509
605
  // in the section.
510
606
  export function appendCriterion(text, value) {
@@ -523,3 +619,23 @@ export function appendCriterion(text, value) {
523
619
  loc.lines.splice(endOfItem(loc.lines, last.lineIndex) + 1, 0, `${last.indent}${nextN}. ${value}`);
524
620
  return { text: loc.lines.join("\n"), summary: `append-criterion ${nextN}` };
525
621
  }
622
+
623
+ /**
624
+ * The size of a spec, as `work --consolidate` reports it (change 0206):
625
+ * lines, real requirement bullets per EARS subsection, and criteria.
626
+ * @param {string} text
627
+ */
628
+ export function specShape(text) {
629
+ const req = locateRequirements(text);
630
+ const sections = {};
631
+ let requirements = 0;
632
+ for (const token of EARS_ORDER) {
633
+ const sec = req?.sections[token];
634
+ if (!sec) continue;
635
+ const n = sec.bullets.filter((i) => !/^\s*-\s*$/.test(req.lines[i])).length;
636
+ sections[token] = n;
637
+ requirements += n;
638
+ }
639
+ const crit = locateCriteria(text);
640
+ return { lines: text.split(/\r?\n/).length, requirements, sections, criteria: crit ? crit.items.length : 0 };
641
+ }
@@ -509,7 +509,7 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
509
509
  const specPath = path.join(specsDir, cap, "spec.md");
510
510
  if (isFile(specPath)) {
511
511
  const lines = lineCount(specPath);
512
- if (lines > 400) warnings.push(`${relPath(projectRoot, specPath)} is ${lines} lines (>400 soft cap)`);
512
+ if (lines > 400) warnings.push(`${relPath(projectRoot, specPath)} is ${lines} lines (>400 soft cap) — consolidate it: \`doctrina work --consolidate ${path.basename(path.dirname(specPath))}\``);
513
513
 
514
514
  // 8b. EARS grammar shape — section-appropriate When/While/Where/shall.
515
515
  const text = read(specPath);
@@ -0,0 +1,76 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { git, GIT_STATE, worktreeFingerprint } from "./git.js";
6
+ import { cliVersion } from "./version.js";
7
+
8
+ // A green verify, remembered for the exact code it ran on (change 0203).
9
+ //
10
+ // A field review of 0.17 put the fixed cost of a small change at the close
11
+ // running the whole suite — 2.5 to 4 minutes — and an agent typically runs
12
+ // `doctrina verify` itself right before `doctrina close`, so the same suite
13
+ // ran twice on the same code. In a batch close it ran once per id, on a tree
14
+ // whose only difference between ids was the artifacts the previous close
15
+ // archived.
16
+ //
17
+ // The fingerprint is the CODE: the working tree (tracked, untracked, edits,
18
+ // .gitignore respected) minus `.doctrina/`, plus the verify.json that
19
+ // defines the checks and the CLI version that runs them. `.doctrina/` is
20
+ // left out on purpose — the close's apply and archive move it between the
21
+ // verify a person ran and the one the close would run — and the artifact
22
+ // tree is proven by the close's own steps (coverage, trace, index drift,
23
+ // validate), which always run.
24
+ //
25
+ // The record lives in the git directory, never in the tree: it is a fact
26
+ // about this checkout, not an artifact to commit or review.
27
+
28
+ const RECORD = "doctrina-verify-green.json";
29
+
30
+ /**
31
+ * @param {string} projectRoot
32
+ * @param {string} configText the verify.json the checks come from
33
+ * @returns {string | null} null when git cannot fingerprint the tree
34
+ */
35
+ export function verifyFingerprint(projectRoot, configText) {
36
+ const tree = worktreeFingerprint(projectRoot, { exclude: [".doctrina"] });
37
+ if (tree === null) return null;
38
+ return createHash("sha256").update(`${tree}\n${configText}\n${cliVersion()}`).digest("hex");
39
+ }
40
+
41
+ function recordPath(projectRoot) {
42
+ const dir = git(projectRoot, ["rev-parse", "--absolute-git-dir"]);
43
+ return dir.state === GIT_STATE.OK ? path.join(dir.stdout.trim(), RECORD) : null;
44
+ }
45
+
46
+ /**
47
+ * Remember that every check passed on this fingerprint.
48
+ * @param {string} projectRoot
49
+ * @param {string} fingerprint
50
+ * @param {string[]} checks
51
+ */
52
+ export function recordGreen(projectRoot, fingerprint, checks) {
53
+ const file = recordPath(projectRoot);
54
+ if (!file) return;
55
+ try {
56
+ mkdirSync(path.dirname(file), { recursive: true });
57
+ writeFileSync(file, JSON.stringify({ fingerprint, checks, at: new Date().toISOString() }, null, 2) + "\n");
58
+ } catch { /* a cache that cannot be written is a cache miss, never an error */ }
59
+ }
60
+
61
+ /**
62
+ * The last green run, when it covered exactly this fingerprint.
63
+ * @param {string} projectRoot
64
+ * @param {string} fingerprint
65
+ * @returns {{ fingerprint: string, checks: string[], at: string } | null}
66
+ */
67
+ export function reusableGreen(projectRoot, fingerprint) {
68
+ const file = recordPath(projectRoot);
69
+ if (!file) return null;
70
+ try {
71
+ const rec = JSON.parse(readFileSync(file, "utf8"));
72
+ return rec && rec.fingerprint === fingerprint ? rec : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
package/src/lib/views.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { c } from "./colors.js";
3
3
  import { today } from "./dates.js";
4
4
  import { churnByCapability } from "./ledger.js";
5
+ import { STALE_CHANGE_DAYS } from "./snapshot.js";
5
6
 
6
7
  // Executed proof and signed proof are different claims (change 0039). One
7
8
  // phrasing, used by every view, so none of them can quietly add the two
@@ -182,7 +183,11 @@ export function prime(s) {
182
183
  } else {
183
184
  for (const ch of s.openChanges) {
184
185
  const tasks = ch.tasksTotal > 0 ? ` · tasks ${ch.tasksDone}/${ch.tasksTotal}` : "";
185
- out.push(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.status}${tasks})`)}`);
186
+ // The age, once it is news (change 0201): a change opened today and
187
+ // one forgotten for three weeks used to read identically.
188
+ const age = ch.ageDays !== null && ch.ageDays !== undefined && ch.ageDays >= STALE_CHANGE_DAYS
189
+ ? ` · open ${ch.ageDays}d` : "";
190
+ out.push(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.status}${tasks}${age})`)}`);
186
191
  }
187
192
  }
188
193
 
@@ -21,23 +21,43 @@ export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3 } = {}) {
21
21
  if (!files || files.length === 0 || !isDir(specsDir)) return [];
22
22
  const norm = files.map((f) => f.replace(/\\/g, "/"));
23
23
 
24
- const ranked = [];
24
+ const specs = [];
25
25
  for (const cap of readdirSync(specsDir).sort()) {
26
26
  const specPath = path.join(specsDir, cap, "spec.md");
27
27
  if (!isFile(specPath)) continue;
28
- const specText = read(specPath);
29
- // A DECLARED glob outranks every inference: the spec said this code is
30
- // its own, and a declaration is not a guess (change 0077).
31
- const declared = parseSourceGlobs(specText).map((g) => globToRegExp(g));
32
- let score = 0;
33
- for (const f of norm) {
34
- if (declared.some((re) => re.test(f))) score += 10; // the spec claims it
35
- if (f.split("/").includes(cap)) score += 3; // src/<cap>/... etc.
36
- if (specText.includes(f)) score += 5; // file cited in the spec
37
- else if (specText.includes(path.basename(f))) score += 2; // filename cited
28
+ const text = read(specPath);
29
+ specs.push({ cap, text, declared: parseSourceGlobs(text).map((g) => globToRegExp(g)) });
30
+ }
31
+
32
+ // Per file, the strongest answer the tree holds wins, and a weaker one is
33
+ // never added on top of it (change 0200, ADR 0027):
34
+ // 1. the specs whose `**Source:**` claims the file — a declaration;
35
+ // 2. else the specs citing the exact path (a proof or a pointer);
36
+ // 3. else the path/basename inferences, and only from specs that declare
37
+ // NO Source — a spec that stated what it owns has said all it owns.
38
+ // The old sum added every signal from every spec, so a file claimed by
39
+ // `health` still "belonged" to `cloud` because cloud's spec mentioned an
40
+ // `index.ts`, and every close warned about a spec the change never touched.
41
+ const scores = new Map();
42
+ const add = (cap, n) => scores.set(cap, (scores.get(cap) ?? 0) + n);
43
+ for (const f of norm) {
44
+ const claimed = specs.filter((s) => s.declared.some((re) => re.test(f)));
45
+ if (claimed.length > 0) {
46
+ for (const s of claimed) add(s.cap, 10);
47
+ continue;
48
+ }
49
+ const citing = specs.filter((s) => s.text.includes(f));
50
+ if (citing.length > 0) {
51
+ for (const s of citing) add(s.cap, 5);
52
+ continue;
53
+ }
54
+ for (const s of specs) {
55
+ if (s.declared.length > 0) continue;
56
+ if (f.split("/").includes(s.cap)) add(s.cap, 3); // src/<cap>/... etc.
57
+ if (s.text.includes(path.basename(f))) add(s.cap, 2); // filename cited
38
58
  }
39
- if (score > 0) ranked.push({ id: cap, score, path: `.doctrina/specs/${cap}/spec.md` });
40
59
  }
60
+ const ranked = [...scores].map(([id, score]) => ({ id, score, path: `.doctrina/specs/${id}/spec.md` }));
41
61
  // `limit` keeps the work-playbook hint short (top 3); review passes
42
62
  // Infinity — truncating there is how it missed 5 of 8 touched capabilities
43
63
  // in the 0.11.0 field session.
@@ -46,7 +46,7 @@ canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.
46
46
  - `doctrina triage` — classify the lane (product/runtime/chore) and check the declared runtime surface. *When:* a request arrives — BEFORE scaffolding, especially if it smells like an incident.
47
47
  - `doctrina work "<prompt>" (--capability · --chore · --from-diff · --quiet)` — scaffold a change and print the playbook to execute. *When:* a request arrives that changes behaviour (triage says PRODUCT).
48
48
  - `doctrina spec new|list|set` — create, list, and edit capability specs. *When:* a capability needs creating or its headers advancing.
49
- - `doctrina change new|apply|archive|check|tick|abandon` — drive a change by hand — check --verbose previews every delta. *When:* driving a change through its lifecycle by hand.
49
+ - `doctrina change new|apply|archive|check|tick|abandon|delta` — drive a change by hand — check --verbose previews every delta. *When:* driving a change through its lifecycle by hand.
50
50
  - `doctrina contract new|list|check` — own and verify the integration surface. *When:* the change touches ports, env vars, or public endpoints.
51
51
  - `doctrina decision new|accept|land|supersede|list|scope` — record, accept, land, scope, and supersede ADRs. *When:* the change decides something a later session must not relitigate.
52
52
  - `doctrina skill new|list|suggest` — capture on-demand procedural memory. *When:* a lesson is worth not relearning.
@@ -45,6 +45,7 @@ loops in it.
45
45
  | `{{CAPABILITY_BLOCK}}` | The pinned capability, the ranked hints, or "no match" | required in `work` |
46
46
  | `{{DIFF_MATCHES}}` | Capabilities the working tree touched | empty |
47
47
  | `{{STEP3_INTRO}}` | The delta step's opening, pinned or not | required in `work` |
48
+ | `{{SHAPE}}` | The spec's measured size — lines, requirements per EARS subsection, criteria | required in `consolidate` |
48
49
  | `{{FROM_DIFF_DELTA_NOTE}}` | The backfill caveat on writing the delta | empty |
49
50
  | `{{MISSING_SPECS_WARNING}}` | Warning that `.doctrina/specs/` is absent | empty |
50
51
 
@@ -68,6 +69,7 @@ README.md this file
68
69
  playbooks/work.md.template the work / backfill playbook
69
70
  playbooks/chore.md.template the spec-less chore playbook
70
71
  playbooks/bootstrap.md.template the intake -> specs playbook
72
+ playbooks/consolidate.md.template the spec consolidation playbook
71
73
  AGENTS.md.template root rules for the target repo
72
74
  doctrina/ the .doctrina/ skeleton
73
75
  product.md.template
@@ -24,6 +24,8 @@ typical delta applies mechanically end to end; only free-prose rewrites
24
24
  ```
25
25
 
26
26
  Requirement sections: ubiquitous | event | state | unwanted | optional.
27
+ To take one out: `remove-requirement <section> <n>` / `remove-criterion <n>`
28
+ (the criteria after it renumber; write removals highest number first).
27
29
  append-* ops resolve numbering/position at APPLY time, so several open
28
30
  changes appending to the same spec never collide on numbers — order of
29
31
  application decides.
@@ -0,0 +1,28 @@
1
+ [[b]]Consolidation playbook — change {{CHANGE_ID}}[[/b]][[g]] — agent-executed (ADR 0005); the spec shrinks, the behaviour does not change.[[/g]]
2
+
3
+ Capability: [[c]]{{CAPABILITY}}[[/c]] — {{SHAPE}}
4
+
5
+ Execute in order, in a single linear pass:
6
+
7
+ 1. Read the spec and its history:
8
+ [[c]]doctrina context {{CAPABILITY}} --concat[[/c]]
9
+ [[c]]doctrina why {{CAPABILITY}}[[/c]]
10
+ 2. Find what the spec says more than once: a requirement a later one
11
+ superseded, two requirements stating the same rule, a criterion proven
12
+ by another one, a bullet describing behaviour that no longer exists.
13
+ You decide; the CLI only measures. Keep every rule the code still
14
+ honours — consolidating removes words, never guarantees.
15
+ 3. Write the delta with the ops (the scaffold is at
16
+ .doctrina/changes/{{CHANGE_ID}}/specs/{{CAPABILITY}}/delta.md):
17
+ [[c]]doctrina change delta {{CHANGE_ID}} {{CAPABILITY}} --op "remove-requirement <section> <n>: <why>"[[/c]]
18
+ [[c]]doctrina change delta {{CHANGE_ID}} {{CAPABILITY}} --op "replace-requirement <section> <n>: <merged text>"[[/c]]
19
+ [[c]]doctrina change delta {{CHANGE_ID}} {{CAPABILITY}} --op "remove-criterion <n>: <why>"[[/c]]
20
+ [[c]]doctrina change delta {{CHANGE_ID}} {{CAPABILITY}} --bump minor[[/c]]
21
+ [[g]] Ops run in order and a removal renumbers what follows it: write the[[/g]]
22
+ [[g]] removals of one section highest number first. Every kept criterion[[/g]]
23
+ [[g]] must still cite proof that exists — coverage gates the close.[[/g]]
24
+ [[g]] Preview the merged spec: [[/g]][[c]]doctrina change check {{CHANGE_ID}} --verbose[[/c]]
25
+ 4. Fill the proposal's What (what was merged or removed, and why each) and
26
+ replace the placeholder tasks; no code changes.
27
+ 5. Close in one attested pass:
28
+ [[c]]doctrina close {{CHANGE_ID}}[[/c]]
@@ -40,6 +40,9 @@ Execute in order, in a single linear pass:
40
40
  [[g]] append-* ops number/position at APPLY time, so concurrent changes[[/g]]
41
41
  [[g]] appending to the same spec never collide. Only free-prose rewrites[[/g]]
42
42
  [[g]] (Purpose, Maturity, ...) stay a by-hand merge.[[/g]]
43
+ [[g]] Or let the CLI write the lines and dry-run them against the spec:[[/g]]
44
+ [[g]] doctrina change delta {{CHANGE_ID}} <capability> --requirement "event: ..." \[[/g]]
45
+ [[g]] --criterion "[unverified] ... — verified by `...`" --bump minor[[/g]]
43
46
  {{FROM_DIFF_DELTA_NOTE}}
44
47
 
45
48
  4. Replace the placeholder tasks in .doctrina/changes/{{CHANGE_ID}}/tasks.md