doctrina-cli 0.17.0 → 0.20.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 (57) hide show
  1. package/README.md +8 -4
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +74 -0
  4. package/src/commands/analyze.js +14 -3
  5. package/src/commands/change.js +131 -7
  6. package/src/commands/close.js +92 -28
  7. package/src/commands/context.js +49 -8
  8. package/src/commands/delivery.js +238 -0
  9. package/src/commands/index-rebuild.js +23 -0
  10. package/src/commands/init.js +15 -0
  11. package/src/commands/intake.js +47 -1
  12. package/src/commands/metrics.js +28 -3
  13. package/src/commands/next.js +1 -0
  14. package/src/commands/prime.js +76 -7
  15. package/src/commands/review.js +47 -8
  16. package/src/commands/skill.js +3 -127
  17. package/src/commands/upgrade.js +15 -0
  18. package/src/commands/verify.js +59 -6
  19. package/src/commands/work.js +139 -5
  20. package/src/commands/workspace.js +409 -0
  21. package/src/index.js +5 -7
  22. package/src/lib/actions.js +9 -5
  23. package/src/lib/adapters.js +80 -0
  24. package/src/lib/change-ops.js +92 -0
  25. package/src/lib/code-inventory.js +297 -0
  26. package/src/lib/commands.js +52 -5
  27. package/src/lib/context-slice.js +118 -0
  28. package/src/lib/delivery.js +208 -0
  29. package/src/lib/git.js +59 -2
  30. package/src/lib/metrics-model.js +23 -1
  31. package/src/lib/playbook.js +1 -1
  32. package/src/lib/runtime.js +6 -0
  33. package/src/lib/scan.js +13 -0
  34. package/src/lib/signoff.js +24 -0
  35. package/src/lib/skill-candidates.js +162 -0
  36. package/src/lib/skill-export.js +95 -0
  37. package/src/lib/snapshot.js +76 -2
  38. package/src/lib/spec-ops.js +117 -1
  39. package/src/lib/task-card.js +102 -0
  40. package/src/lib/validation-model.js +1 -1
  41. package/src/lib/verify-cache.js +76 -0
  42. package/src/lib/views.js +56 -2
  43. package/src/lib/work-model.js +90 -12
  44. package/src/lib/workspace-agents.js +259 -0
  45. package/src/lib/workspace-tools.js +113 -0
  46. package/src/lib/workspace.js +371 -0
  47. package/templates/AGENTS.md.template +17 -14
  48. package/templates/README.md +11 -0
  49. package/templates/adapters/claude/.claude/settings.json.template +18 -0
  50. package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
  51. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
  52. package/templates/change/spec-delta.md.template +2 -0
  53. package/templates/delivery.md.template +39 -0
  54. package/templates/playbooks/absorb.md.template +40 -0
  55. package/templates/playbooks/consolidate.md.template +28 -0
  56. package/templates/playbooks/delivery.md.template +38 -0
  57. package/templates/playbooks/work.md.template +4 -1
@@ -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
+
@@ -0,0 +1,95 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { readdirSync } from "node:fs";
4
+ import { isDir, isFile, pruneEmptyDirs, read, remove, write, walk } from "./fs-ops.js";
5
+ import { parseFrontmatter } from "./doc-model.js";
6
+ import { describeAdapter, ADAPTER_STATES } from "./adapters.js";
7
+
8
+ // The project's skills, where the agent looks for skills (change 0212).
9
+ //
10
+ // A field review of an agent in Cursor: the editor's skill catalogue lists
11
+ // `~/.cursor/skills` and plugins, never `.doctrina/skills/`, so a skill that
12
+ // matched the task fired only when the request named its file. AGENTS.md
13
+ // said "skills are on demand"; nothing said the editor would never offer
14
+ // them. The agents now share a format — a folder per skill holding a
15
+ // SKILL.md with `name` and `description` — and discover it on their own:
16
+ // `.agents/skills/` (Cursor, Codex) and `.claude/skills/` (Claude Code;
17
+ // Cursor reads it too). `.doctrina/skills/` stays the one source; the
18
+ // exports are generated from it, marked as generated, and pruned with it.
19
+
20
+ export const EXPORT_MARK = "<!-- doctrina:skill-export";
21
+ const DESCRIPTION_MAX = 1024;
22
+
23
+ /** Where this project's skills are exported, as project-relative roots. */
24
+ export function exportRoots(projectRoot) {
25
+ const roots = [".agents/skills"];
26
+ if (describeAdapter(projectRoot, "claude").state === ADAPTER_STATES.INSTALLED) roots.push(".claude/skills");
27
+ return roots;
28
+ }
29
+
30
+ /** The SKILL.md a Doctrina skill becomes. */
31
+ export function renderSkillExport(name, text) {
32
+ const body = text.replace(/\r\n/g, "\n");
33
+ const description = parseFrontmatter(body, "description") ?? name;
34
+ const when = parseFrontmatter(body, "when");
35
+ // The agent decides from the description alone, so the trigger goes in it.
36
+ const full = (when ? `${description} Use when: ${when}` : description).replace(/\s+/g, " ").trim();
37
+ const clipped = full.length > DESCRIPTION_MAX ? `${full.slice(0, DESCRIPTION_MAX - 1)}…` : full;
38
+ const rest = body.replace(/^---\n[\s\S]*?\n---\n/, "").replace(/^\n+/, "");
39
+ return [
40
+ "---",
41
+ `name: ${name}`,
42
+ `description: ${JSON.stringify(clipped)}`,
43
+ "---",
44
+ `${EXPORT_MARK} — generated from .doctrina/skills/${name}.md by \`doctrina index rebuild\`; edit that file, not this one. -->`,
45
+ "",
46
+ rest,
47
+ ].join("\n").replace(/\n*$/, "\n");
48
+ }
49
+
50
+ /**
51
+ * What the export would change: files to write (missing or different) and
52
+ * generated skill folders to remove (their source is gone). Folders not
53
+ * carrying the generated mark are someone else's and never touched.
54
+ *
55
+ * @param {string} projectRoot
56
+ * @returns {{ writes: Array<{ rel: string, body: string }>, removes: string[] }}
57
+ */
58
+ export function planSkillExport(projectRoot) {
59
+ const skillsDir = path.join(projectRoot, ".doctrina", "skills");
60
+ const skills = isDir(skillsDir)
61
+ ? walk(skillsDir).filter((f) => f.endsWith(".md") && path.dirname(f) === skillsDir)
62
+ : [];
63
+ const wanted = new Map(skills.map((f) => [path.basename(f, ".md"), read(f)]));
64
+ const writes = [];
65
+ const removes = [];
66
+ if (wanted.size === 0 && !exportRoots(projectRoot).some((r) => isDir(path.join(projectRoot, r)))) {
67
+ return { writes, removes };
68
+ }
69
+ for (const root of exportRoots(projectRoot)) {
70
+ for (const [name, text] of wanted) {
71
+ const rel = `${root}/${name}/SKILL.md`;
72
+ const body = renderSkillExport(name, text);
73
+ const abs = path.join(projectRoot, rel);
74
+ if (!isFile(abs) || read(abs).replace(/\r\n/g, "\n") !== body) writes.push({ rel, body });
75
+ }
76
+ const rootAbs = path.join(projectRoot, root);
77
+ if (!isDir(rootAbs)) continue;
78
+ for (const entry of readdirSync(rootAbs)) {
79
+ const skillFile = path.join(rootAbs, entry, "SKILL.md");
80
+ if (wanted.has(entry) || !isFile(skillFile)) continue;
81
+ if (read(skillFile).includes(EXPORT_MARK)) removes.push(`${root}/${entry}/SKILL.md`);
82
+ }
83
+ }
84
+ return { writes, removes };
85
+ }
86
+
87
+ /** Apply a plan; returns it. */
88
+ export function applySkillExport(projectRoot, plan = planSkillExport(projectRoot)) {
89
+ for (const w of plan.writes) write(path.join(projectRoot, w.rel), w.body, { force: true });
90
+ for (const r of plan.removes) {
91
+ remove(path.join(projectRoot, r)); // the generated file only
92
+ pruneEmptyDirs(path.dirname(path.join(projectRoot, r)), projectRoot);
93
+ }
94
+ return plan;
95
+ }
@@ -4,7 +4,7 @@ import { readdirSync } from "node:fs";
4
4
  import { isDir, isFile, read, walk } from "./fs-ops.js";
5
5
  import { projectName } from "./project.js";
6
6
  import * as idx from "./index-json.js";
7
- import { deriveIndex, indexesMatch, specHeader, listHeader } from "./scan.js";
7
+ import { deriveIndex, indexesMatch, specHeader, listHeader, deltaCapabilities } from "./scan.js";
8
8
  import { cliVersion } from "./version.js";
9
9
  import { summarize as coverageSummary } from "./coverage-model.js";
10
10
  import { summarize as traceSummary } from "./trace-model.js";
@@ -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
  //
@@ -71,6 +72,7 @@ export function collectSnapshot(projectRoot, { actions = true } = {}) {
71
72
  decisions: countDecisions(projectRoot),
72
73
  skills: countSkills(projectRoot),
73
74
  openChanges: openChanges(projectRoot),
75
+ recentlyClosed: recentlyClosed(projectRoot),
74
76
  adrs: acceptedDecisions(projectRoot),
75
77
  nonGoals: productSection(projectRoot, "Non-goals"),
76
78
  hasNonGoalsSection: hasProductSection(projectRoot, "Non-goals"),
@@ -149,11 +151,83 @@ export function openChanges(projectRoot) {
149
151
  const tasksTotal = progress.total;
150
152
  const tasksDone = progress.done;
151
153
  const unchecked = progress.open.map((t) => t || "(unwritten task — the scaffold's placeholder)");
152
- out.push({ id, title, status, tasksDone, tasksTotal, unchecked });
154
+ const date = proposal ? (listHeader(proposal, "Date") ?? null) : null;
155
+ // The capabilities its deltas target — what `prime` turns into the
156
+ // exact read command for the change (change 0217).
157
+ const capabilities = deltaCapabilities(path.join(changesDir, id));
158
+ out.push({ id, title, status, tasksDone, tasksTotal, unchecked, date, ageDays: ageInDays(date), capabilities });
153
159
  }
154
160
  return out;
155
161
  }
156
162
 
163
+ // How far back `prime` and the pulse look for closed changes (change 0214).
164
+ export const RECENT_CLOSE_DAYS = 7;
165
+
166
+ /**
167
+ * Changes archived in the last RECENT_CLOSE_DAYS, newest first: what the
168
+ * human may ask for again without knowing it is done. A field review: the
169
+ * close moved the work to `changes/archive/`, out of every default read, and
170
+ * the next request was to "create the doctrina work" for it.
171
+ *
172
+ * @param {string} projectRoot
173
+ * @param {{ limit?: number }} [opts]
174
+ * @returns {Array<{ id: string, title: string|null, date: string, path: string, ageDays: number|null }>}
175
+ */
176
+ export function recentlyClosed(projectRoot, { limit = 3 } = {}) {
177
+ const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
178
+ if (!isDir(archiveDir)) return [];
179
+ const out = [];
180
+ for (const name of readdirSync(archiveDir).sort().reverse()) {
181
+ const m = name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
182
+ if (!m || !isDir(path.join(archiveDir, name))) continue;
183
+ const ageDays = ageInDays(m[1]);
184
+ if (ageDays === null || ageDays > RECENT_CLOSE_DAYS) break;
185
+ const proposal = path.join(archiveDir, name, "proposal.md");
186
+ out.push({
187
+ id: m[2], date: m[1], ageDays,
188
+ title: isFile(proposal) ? parseChangeTitle(read(proposal)) : null,
189
+ path: `.doctrina/changes/archive/${name}/`,
190
+ });
191
+ if (out.length >= limit) break;
192
+ }
193
+ return out;
194
+ }
195
+
196
+ // How old a change has to be before "still open" is worth saying on its own.
197
+ export const STALE_CHANGE_DAYS = 7;
198
+
199
+ /**
200
+ * The open changes worth a line at the end of someone else's close (change
201
+ * 0201): a change that LOOKS finished — applied, or every task ticked — and
202
+ * was never closed, and a change open for a week or more. A field review of
203
+ * 0.17 found change 0078 shipped and left open, and no later close said so:
204
+ * the close is the moment someone is looking, so it is where to say it.
205
+ *
206
+ * @param {string} projectRoot
207
+ * @param {{ exclude?: string[] }} [opts]
208
+ */
209
+ export function unfinishedChanges(projectRoot, { exclude = [] } = {}) {
210
+ const skip = new Set(exclude);
211
+ const out = [];
212
+ for (const ch of openChanges(projectRoot)) {
213
+ if (skip.has(ch.id)) continue;
214
+ const finished = ch.status === "applied" ||
215
+ (ch.tasksTotal > 0 && ch.tasksDone === ch.tasksTotal);
216
+ const old = ch.ageDays !== null && ch.ageDays >= STALE_CHANGE_DAYS;
217
+ const reason = ch.status === "applied"
218
+ ? "applied but never closed"
219
+ : finished ? "every task ticked, never closed" : old ? "still open" : null;
220
+ out.push({ ...ch, finished, old, reason });
221
+ }
222
+ return out;
223
+ }
224
+
225
+ function ageInDays(date) {
226
+ if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
227
+ const ms = Date.parse(`${today()}T00:00:00Z`) - Date.parse(`${date}T00:00:00Z`);
228
+ return Number.isFinite(ms) ? Math.max(0, Math.round(ms / 86400000)) : null;
229
+ }
230
+
157
231
  function countSpecs(projectRoot) {
158
232
  const specsDir = path.join(projectRoot, ".doctrina", "specs");
159
233
  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
+ }
@@ -0,0 +1,102 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { isFile, read } from "./fs-ops.js";
4
+ import { parseChangeTitle, parseChecklist } from "./doc-model.js";
5
+ import { listHeader, deltaCapabilities } from "./scan.js";
6
+ import { sliceSpec } from "./context-slice.js";
7
+ import { terms } from "./lexicon.js";
8
+
9
+ // A TASK CARD (change 0222, ADR 0030): one task of a change, carrying what
10
+ // an executor needs to do it ALONE — the task, the lines indented under it
11
+ // (files, requirement, verify command), why the change exists, and the spec
12
+ // cut to the task. A subagent does not see the planner's conversation, so
13
+ // the card is the whole of its context; a card that needs the conversation
14
+ // is a card the planner has not finished writing.
15
+
16
+ /**
17
+ * The tasks.md boxes, each with the indented lines under it.
18
+ * Ordinals are the same ones `change tick` uses (tasks.md comes first).
19
+ */
20
+ export function taskBoxes(tasksText) {
21
+ const lines = String(tasksText).replace(/\r\n/g, "\n").split("\n");
22
+ return parseChecklist(tasksText).map((box, i) => {
23
+ const indent = (lines[box.line].match(/^\s*/)?.[0] ?? "").length;
24
+ const details = [];
25
+ for (let j = box.line + 1; j < lines.length; j++) {
26
+ const l = lines[j];
27
+ if (l.trim() === "") {
28
+ if (details.length) break;
29
+ continue;
30
+ }
31
+ if ((l.match(/^\s*/)?.[0] ?? "").length <= indent) break;
32
+ details.push(l.trim().replace(/^[-*]\s+/, ""));
33
+ }
34
+ return { ordinal: i + 1, text: box.text, checked: box.checked, placeholder: box.placeholder, details };
35
+ });
36
+ }
37
+
38
+ /** The `Field: value` lines of a card's details, keyed by lowercased field. */
39
+ export function cardFields(details) {
40
+ const out = {};
41
+ for (const d of details) {
42
+ const m = d.match(/^([A-Za-z][A-Za-z ]{0,20}):\s*(.+)$/);
43
+ if (m) out[m[1].trim().toLowerCase()] = m[2].trim();
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function firstParagraph(text, heading) {
49
+ const m = String(text).match(new RegExp(`^## ${heading}\\s*\\n+([\\s\\S]*?)(?:\\n\\s*\\n|\\n## |$)`, "m"));
50
+ return m ? m[1].replace(/<!--[\s\S]*?-->/g, "").trim() : "";
51
+ }
52
+
53
+ /**
54
+ * The card for task `ordinal` of a change — or, with no ordinal, the first
55
+ * open one. Null `card` with a reason when there is none to give.
56
+ * @returns {{ card: null, reason: string } | { card: object, reason: null }}
57
+ */
58
+ export function taskCard(projectRoot, changeId, ordinal = null) {
59
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", changeId);
60
+ const tasksPath = path.join(changeDir, "tasks.md");
61
+ if (!isFile(tasksPath)) return { card: null, reason: "the change has no tasks.md" };
62
+ const boxes = taskBoxes(read(tasksPath));
63
+ if (boxes.length === 0) return { card: null, reason: "tasks.md has no task box" };
64
+ let box;
65
+ if (ordinal === null) {
66
+ box = boxes.find((b) => !b.checked && !b.placeholder);
67
+ if (!box) return { card: null, reason: "every task is ticked — the change is ready for review and close" };
68
+ } else {
69
+ box = boxes.find((b) => b.ordinal === ordinal);
70
+ if (!box) return { card: null, reason: `no task ${ordinal} (tasks.md has ${boxes.length})` };
71
+ }
72
+ if (box.placeholder) return { card: null, reason: `task ${box.ordinal} is a scaffold placeholder — write the task first` };
73
+ // CRLF on a Windows checkout: the Why is read paragraph by paragraph.
74
+ const proposal = isFile(path.join(changeDir, "proposal.md")) ? read(path.join(changeDir, "proposal.md")).replace(/\r\n/g, "\n") : "";
75
+ const fields = cardFields(box.details);
76
+ const query = terms([box.text, ...box.details].join(" "));
77
+ const slices = [];
78
+ for (const cap of deltaCapabilities(changeDir)) {
79
+ const rel = `.doctrina/specs/${cap}/spec.md`;
80
+ const specPath = path.join(projectRoot, rel);
81
+ if (!isFile(specPath)) continue;
82
+ const slice = sliceSpec(read(specPath), query, rel);
83
+ if (slice) slices.push({ capability: cap, ...slice });
84
+ }
85
+ return {
86
+ card: {
87
+ change: changeId,
88
+ title: parseChangeTitle(proposal) ?? changeId,
89
+ delivery: listHeader(proposal, "Delivery") ?? null,
90
+ ordinal: box.ordinal,
91
+ total: boxes.length,
92
+ done: boxes.filter((b) => b.checked).length,
93
+ checked: box.checked,
94
+ text: box.text,
95
+ details: box.details,
96
+ fields,
97
+ why: firstParagraph(proposal, "Why"),
98
+ slices,
99
+ },
100
+ reason: null,
101
+ };
102
+ }
@@ -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);