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.
- package/README.md +8 -4
- package/package.json +1 -1
- package/src/commands/adapter.js +74 -0
- package/src/commands/analyze.js +14 -3
- package/src/commands/change.js +131 -7
- package/src/commands/close.js +92 -28
- package/src/commands/context.js +49 -8
- package/src/commands/delivery.js +238 -0
- package/src/commands/index-rebuild.js +23 -0
- package/src/commands/init.js +15 -0
- package/src/commands/intake.js +47 -1
- package/src/commands/metrics.js +28 -3
- package/src/commands/next.js +1 -0
- package/src/commands/prime.js +76 -7
- package/src/commands/review.js +47 -8
- package/src/commands/skill.js +3 -127
- package/src/commands/upgrade.js +15 -0
- package/src/commands/verify.js +59 -6
- package/src/commands/work.js +139 -5
- package/src/commands/workspace.js +409 -0
- package/src/index.js +5 -7
- package/src/lib/actions.js +9 -5
- package/src/lib/adapters.js +80 -0
- package/src/lib/change-ops.js +92 -0
- package/src/lib/code-inventory.js +297 -0
- package/src/lib/commands.js +52 -5
- package/src/lib/context-slice.js +118 -0
- package/src/lib/delivery.js +208 -0
- package/src/lib/git.js +59 -2
- package/src/lib/metrics-model.js +23 -1
- package/src/lib/playbook.js +1 -1
- package/src/lib/runtime.js +6 -0
- package/src/lib/scan.js +13 -0
- package/src/lib/signoff.js +24 -0
- package/src/lib/skill-candidates.js +162 -0
- package/src/lib/skill-export.js +95 -0
- package/src/lib/snapshot.js +76 -2
- package/src/lib/spec-ops.js +117 -1
- package/src/lib/task-card.js +102 -0
- package/src/lib/validation-model.js +1 -1
- package/src/lib/verify-cache.js +76 -0
- package/src/lib/views.js +56 -2
- package/src/lib/work-model.js +90 -12
- package/src/lib/workspace-agents.js +259 -0
- package/src/lib/workspace-tools.js +113 -0
- package/src/lib/workspace.js +371 -0
- package/templates/AGENTS.md.template +17 -14
- package/templates/README.md +11 -0
- package/templates/adapters/claude/.claude/settings.json.template +18 -0
- package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
- package/templates/change/spec-delta.md.template +2 -0
- package/templates/delivery.md.template +39 -0
- package/templates/playbooks/absorb.md.template +40 -0
- package/templates/playbooks/consolidate.md.template +28 -0
- package/templates/playbooks/delivery.md.template +38 -0
- 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
|
+
}
|
package/src/lib/snapshot.js
CHANGED
|
@@ -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
|
-
|
|
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 = {};
|
package/src/lib/spec-ops.js
CHANGED
|
@@ -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
|
-
|
|
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);
|