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.
- package/README.md +7 -4
- package/package.json +1 -1
- package/src/commands/change.js +59 -4
- package/src/commands/close.js +74 -27
- package/src/commands/next.js +1 -0
- package/src/commands/review.js +47 -8
- package/src/commands/skill.js +3 -127
- package/src/commands/verify.js +59 -6
- package/src/commands/work.js +54 -4
- package/src/lib/actions.js +8 -4
- package/src/lib/change-ops.js +92 -0
- package/src/lib/commands.js +8 -0
- package/src/lib/git.js +59 -2
- 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/snapshot.js +38 -1
- package/src/lib/spec-ops.js +117 -1
- package/src/lib/validation-model.js +1 -1
- package/src/lib/verify-cache.js +76 -0
- package/src/lib/views.js +6 -1
- package/src/lib/work-model.js +32 -12
- package/templates/AGENTS.md.template +1 -1
- package/templates/README.md +2 -0
- package/templates/change/spec-delta.md.template +2 -0
- package/templates/playbooks/consolidate.md.template +28 -0
- package/templates/playbooks/work.md.template +3 -0
|
@@ -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
|
+
|
package/src/lib/snapshot.js
CHANGED
|
@@ -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
|
-
|
|
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 = {};
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
package/src/lib/work-model.js
CHANGED
|
@@ -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
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
package/templates/README.md
CHANGED
|
@@ -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
|