doctrina-cli 0.11.0 → 0.13.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/package.json +1 -1
- package/src/commands/analyze.js +18 -2
- package/src/commands/change.js +322 -12
- package/src/commands/clarify.js +97 -11
- package/src/commands/close.js +99 -13
- package/src/commands/context.js +10 -1
- package/src/commands/coverage.js +157 -41
- package/src/commands/init.js +15 -2
- package/src/commands/intent.js +173 -0
- package/src/commands/review.js +25 -4
- package/src/commands/skill.js +46 -7
- package/src/commands/spec.js +13 -3
- package/src/commands/templates.js +113 -10
- package/src/commands/upgrade.js +133 -0
- package/src/commands/validate.js +152 -3
- package/src/commands/why.js +11 -1
- package/src/commands/work.js +105 -21
- package/src/index.js +11 -2
- package/src/lib/adr-guard.js +46 -0
- package/src/lib/commands.js +105 -4
- package/src/lib/scan.js +16 -0
- package/src/lib/spec-ops.js +143 -3
- package/templates/.gitkeep +0 -0
- package/templates/AGENTS.md.template +25 -31
- package/templates/change/spec-delta.md.template +13 -7
- package/templates/spec.md.template +9 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
4
|
+
import { c } from "../lib/colors.js";
|
|
5
|
+
import { suggest } from "../lib/suggest.js";
|
|
6
|
+
|
|
7
|
+
// Post-intake intent evolution (0.11.0 field review item 8). The intake is
|
|
8
|
+
// converted once; capabilities born later — brainstorms, pivots, new asks —
|
|
9
|
+
// had no way to gain a product anchor, so they inevitably ended at
|
|
10
|
+
// `Realizes: n/a` and `trace` reported "14/14 ok" while blind to 40% of the
|
|
11
|
+
// system. `intent add` appends a new [SC*] anchor bullet to product.md's
|
|
12
|
+
// Success criteria (allocating the next number), so growth keeps provenance:
|
|
13
|
+
// new intent gets an anchor, the new spec Realizes it, `trace` sees it.
|
|
14
|
+
//
|
|
15
|
+
// The CLI allocates and appends; WHAT the intent says is the human/agent's
|
|
16
|
+
// words verbatim (ADR 0005).
|
|
17
|
+
|
|
18
|
+
const SUBCOMMANDS = ["add", "list"];
|
|
19
|
+
|
|
20
|
+
export async function run(positional, _flags) {
|
|
21
|
+
const sub = positional[0];
|
|
22
|
+
if (!SUBCOMMANDS.includes(sub)) {
|
|
23
|
+
console.error(c.red("error:") + ` unknown intent subcommand "${sub ?? ""}"`);
|
|
24
|
+
const guess = suggest(sub, SUBCOMMANDS);
|
|
25
|
+
console.error(c.gray("hint: ") + (guess
|
|
26
|
+
? `did you mean \`doctrina intent ${guess}\`?`
|
|
27
|
+
: `available: ${SUBCOMMANDS.join(", ")}`));
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
const projectRoot = process.cwd();
|
|
31
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
32
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
33
|
+
}
|
|
34
|
+
if (sub === "list") return intentList(projectRoot);
|
|
35
|
+
return intentAdd(projectRoot, positional.slice(1).join(" ").trim());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function productPathOf(projectRoot) {
|
|
39
|
+
return path.join(projectRoot, ".doctrina", "product.md");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Every "[A-Z]+\d+" anchor at the head of a bullet, in document order —
|
|
43
|
+
// the same convention `trace` reads.
|
|
44
|
+
function collectAnchors(text) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const lines = text.split(/\r?\n/);
|
|
48
|
+
for (let i = 0; i < lines.length; i++) {
|
|
49
|
+
const m = lines[i].match(/^\s*[-*]\s+\[([A-Z]+)(\d+)\]\s+(.*)$/);
|
|
50
|
+
if (m && !seen.has(m[1] + m[2])) {
|
|
51
|
+
seen.add(m[1] + m[2]);
|
|
52
|
+
out.push({ prefix: m[1], num: Number(m[2]), id: m[1] + m[2], text: m[3].trim(), line: i });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function intentList(projectRoot) {
|
|
59
|
+
const p = productPathOf(projectRoot);
|
|
60
|
+
if (!isFile(p)) {
|
|
61
|
+
console.error(c.red("error:") + " no .doctrina/product.md");
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
const anchors = collectAnchors(read(p));
|
|
65
|
+
if (anchors.length === 0) {
|
|
66
|
+
console.log(c.gray("no intent anchors in product.md — tag bullets with `- [SC1] ...`, or add one: doctrina intent add \"<text>\""));
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
console.log(c.bold("Intent anchors") + c.gray(" — product.md, document order:"));
|
|
70
|
+
console.log("");
|
|
71
|
+
for (const a of anchors) {
|
|
72
|
+
console.log(` ${c.cyan(a.id.padEnd(6))} ${a.text}`);
|
|
73
|
+
}
|
|
74
|
+
console.log("");
|
|
75
|
+
console.log(c.gray(`${anchors.length} anchor${anchors.length === 1 ? "" : "s"}. Provenance: doctrina trace · doctrina why <id>`));
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function intentAdd(projectRoot, textArg) {
|
|
80
|
+
if (!textArg) {
|
|
81
|
+
console.error(c.red("error:") + " intent add requires the intent text (quote it)");
|
|
82
|
+
console.error(c.gray("hint: ") + `example: doctrina intent add "Risk map answers 'what should we test next?' in one read"`);
|
|
83
|
+
return 2;
|
|
84
|
+
}
|
|
85
|
+
const p = productPathOf(projectRoot);
|
|
86
|
+
if (!isFile(p)) {
|
|
87
|
+
console.error(c.red("error:") + " no .doctrina/product.md — run `doctrina init` / `doctrina intake` first");
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
const original = read(p);
|
|
91
|
+
const anchors = collectAnchors(original);
|
|
92
|
+
|
|
93
|
+
// Accept "SC15: text" to pin an explicit id; otherwise allocate the next
|
|
94
|
+
// number for the dominant prefix (SC by default).
|
|
95
|
+
let id = null;
|
|
96
|
+
let text = textArg;
|
|
97
|
+
const pinned = textArg.match(/^([A-Z]+)(\d+)\s*:\s*(.+)$/);
|
|
98
|
+
if (pinned) {
|
|
99
|
+
id = pinned[1] + pinned[2];
|
|
100
|
+
text = pinned[3].trim();
|
|
101
|
+
if (anchors.some((a) => a.id === id)) {
|
|
102
|
+
console.error(c.red("error:") + ` anchor [${id}] already exists in product.md`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
const prefix = anchors.length > 0 ? dominantPrefix(anchors) : "SC";
|
|
107
|
+
const maxNum = anchors.filter((a) => a.prefix === prefix).reduce((m, a) => Math.max(m, a.num), 0);
|
|
108
|
+
id = `${prefix}${maxNum + 1}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const lines = original.split(/\r?\n/);
|
|
112
|
+
const bullet = `- [${id}] ${text}`;
|
|
113
|
+
|
|
114
|
+
// Insertion point: after the LAST existing anchor bullet (keeps them
|
|
115
|
+
// contiguous); else at the end of "## Success criteria"; else appended as a
|
|
116
|
+
// new Success criteria section (a product.md without one gains it).
|
|
117
|
+
const samePrefix = anchors.length > 0;
|
|
118
|
+
let inserted = false;
|
|
119
|
+
if (samePrefix) {
|
|
120
|
+
const last = anchors[anchors.length - 1];
|
|
121
|
+
lines.splice(last.line + 1, 0, bullet);
|
|
122
|
+
inserted = true;
|
|
123
|
+
} else {
|
|
124
|
+
for (let i = 0; i < lines.length; i++) {
|
|
125
|
+
if (/^##\s+Success criteria\b/i.test(lines[i])) {
|
|
126
|
+
// Insert before the next "## " heading (or at EOF).
|
|
127
|
+
let j = i + 1;
|
|
128
|
+
while (j < lines.length && !/^##\s+/.test(lines[j])) j += 1;
|
|
129
|
+
// Trim trailing blank lines inside the section for a tidy append.
|
|
130
|
+
let k = j;
|
|
131
|
+
while (k > i + 1 && lines[k - 1].trim() === "") k -= 1;
|
|
132
|
+
lines.splice(k, 0, bullet);
|
|
133
|
+
inserted = true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!inserted) {
|
|
139
|
+
lines.push("", "## Success criteria", "", bullet);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
write(p, lines.join("\n"), { force: true });
|
|
143
|
+
console.log(c.green("added") + ` [${id}] to ${relPath(projectRoot, p)}`);
|
|
144
|
+
console.log("");
|
|
145
|
+
console.log(` ${c.cyan(id.padEnd(6))} ${text}`);
|
|
146
|
+
console.log("");
|
|
147
|
+
console.log(c.gray("Next: the spec that delivers this intent declares it —"));
|
|
148
|
+
console.log(` ${c.cyan(`**Realizes:** ${id}`)} ${c.gray("(in .doctrina/specs/<capability>/spec.md; `doctrina trace` closes the loop)")}`);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function dominantPrefix(anchors) {
|
|
153
|
+
const counts = new Map();
|
|
154
|
+
for (const a of anchors) counts.set(a.prefix, (counts.get(a.prefix) ?? 0) + 1);
|
|
155
|
+
return [...counts.entries()].sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))[0][0];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const help = `
|
|
159
|
+
Usage: doctrina intent add "<text>"
|
|
160
|
+
doctrina intent add "SC15: <text>"
|
|
161
|
+
doctrina intent list
|
|
162
|
+
|
|
163
|
+
Post-intake intent evolution. \`add\` appends a new intent anchor bullet
|
|
164
|
+
(\`- [SC5] <text>\`) to product.md's Success criteria, allocating the next
|
|
165
|
+
number automatically (or pin one with the "SC15: ..." form). New
|
|
166
|
+
capabilities born after the intake — brainstorms, pivots — get an anchor
|
|
167
|
+
to Realize instead of inevitably landing at "Realizes: n/a", so
|
|
168
|
+
\`doctrina trace\` keeps seeing the whole system as it grows.
|
|
169
|
+
|
|
170
|
+
\`list\` prints every anchor in document order (the same convention trace
|
|
171
|
+
reads). The CLI allocates and appends; the intent's wording is yours,
|
|
172
|
+
verbatim (ADR 0005).
|
|
173
|
+
`;
|
package/src/commands/review.js
CHANGED
|
@@ -6,6 +6,7 @@ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
|
|
|
6
6
|
import { flagBool, flagString } from "../lib/args.js";
|
|
7
7
|
import { c } from "../lib/colors.js";
|
|
8
8
|
import { rankCapabilitiesByDiff } from "./work.js";
|
|
9
|
+
import { parseDependsOn } from "../lib/scan.js";
|
|
9
10
|
import { summarize as coverageSummary } from "./coverage.js";
|
|
10
11
|
import { summarize as traceSummary } from "./trace.js";
|
|
11
12
|
|
|
@@ -42,18 +43,38 @@ export async function run(_positional, flags) {
|
|
|
42
43
|
const notes = []; // advisory observations
|
|
43
44
|
|
|
44
45
|
// 1. Code changed under a capability whose spec was NOT touched — the spec
|
|
45
|
-
// may have drifted from the code it describes.
|
|
46
|
+
// may have drifted from the code it describes. Touched = every capability
|
|
47
|
+
// matched by the code diff (UNCAPPED — the top-3 hint truncation is how
|
|
48
|
+
// this missed 5 of 8 in the 0.11.0 field session) UNIONED with every
|
|
49
|
+
// capability whose spec.md itself changed.
|
|
46
50
|
const specChanged = new Set(
|
|
47
51
|
all.filter((f) => /(^|\/)\.doctrina\/specs\/[^/]+\/spec\.md$/.test(f.replace(/\\/g, "/")))
|
|
48
52
|
.map((f) => f.replace(/\\/g, "/").match(/\.doctrina\/specs\/([^/]+)\/spec\.md$/)[1]),
|
|
49
53
|
);
|
|
50
|
-
const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles);
|
|
54
|
+
const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity });
|
|
55
|
+
const touched = new Set([...specChanged, ...ranked.map((m) => m.id)]);
|
|
51
56
|
for (const m of ranked) {
|
|
52
57
|
if (!specChanged.has(m.id)) {
|
|
53
58
|
breaks.push(`code under capability "${m.id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${m.id}/spec.md\` still describes the code (update it, or note why not)`);
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
// 1b. Dependents of touched capabilities (the machine-readable
|
|
63
|
+
// **Depends on:** header): a spec that builds on something you changed
|
|
64
|
+
// may silently no longer hold. Advisory — a pointer, not a verdict.
|
|
65
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
66
|
+
if (isDir(specsDir) && touched.size > 0) {
|
|
67
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
68
|
+
if (touched.has(cap)) continue;
|
|
69
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
70
|
+
if (!isFile(specPath)) continue;
|
|
71
|
+
const deps = parseDependsOn(read(specPath)).filter((d) => touched.has(d));
|
|
72
|
+
if (deps.length > 0) {
|
|
73
|
+
notes.push(`capability "${cap}" depends on touched ${deps.map((d) => `"${d}"`).join(", ")} — confirm it still holds (\`doctrina why ${cap}\`)`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
57
78
|
// 2. New source files that map to no capability at all — code with no home
|
|
58
79
|
// in any spec (a likely new, unspecced capability).
|
|
59
80
|
if (ranked.length === 0 && sourceFiles.length > 0) {
|
|
@@ -83,8 +104,8 @@ export async function run(_positional, flags) {
|
|
|
83
104
|
|
|
84
105
|
// --- Output ---
|
|
85
106
|
console.log(c.gray(`${all.length} changed path(s); ${sourceFiles.length} source file(s) outside .doctrina/.`));
|
|
86
|
-
if (
|
|
87
|
-
console.log(c.gray("Capabilities touched: ") +
|
|
107
|
+
if (touched.size > 0) {
|
|
108
|
+
console.log(c.gray("Capabilities touched: ") + [...touched].sort().map((id) => c.cyan(id)).join(", "));
|
|
88
109
|
}
|
|
89
110
|
console.log("");
|
|
90
111
|
|
package/src/commands/skill.js
CHANGED
|
@@ -61,9 +61,23 @@ function skillSuggest(args, flags) {
|
|
|
61
61
|
const since = flagString(flags, "since");
|
|
62
62
|
|
|
63
63
|
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
const existingFiles = isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")) : [];
|
|
65
|
+
const existing = new Set(existingFiles.map((f) => path.basename(f, ".md")));
|
|
66
|
+
// A lesson may already be captured under a DIFFERENT slug (0.11.0 field
|
|
67
|
+
// review item 11: the human names the skill after the lesson, not after the
|
|
68
|
+
// change id — "tela-preta-crash-de-render..." for change 0049). Two extra
|
|
69
|
+
// dedup signals: an existing skill whose body cites the candidate's change
|
|
70
|
+
// id / commit hash, and token-overlap similarity between the slugs.
|
|
71
|
+
const existingBodies = existingFiles.map((f) => ({ slug: path.basename(f, ".md"), body: read(f) }));
|
|
72
|
+
const captured = (cand) => {
|
|
73
|
+
if (existing.has(cand.slug)) return true;
|
|
74
|
+
for (const sk of existingBodies) {
|
|
75
|
+
if (cand.id && sk.body.includes(cand.id)) return true;
|
|
76
|
+
if (cand.from && sk.body.includes(cand.from)) return true;
|
|
77
|
+
if (slugSimilarity(cand.slug, sk.slug) >= 0.5) return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
};
|
|
67
81
|
|
|
68
82
|
// Two deterministic sources, deduped by slug. `seen` guards against a lesson
|
|
69
83
|
// appearing twice (e.g. a fix committed and later archived as a change).
|
|
@@ -78,18 +92,20 @@ function skillSuggest(args, flags) {
|
|
|
78
92
|
const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
79
93
|
if (!FIX_SHAPED.test(id)) continue;
|
|
80
94
|
const slug = skillSlug(id);
|
|
81
|
-
if (
|
|
95
|
+
if (seen.has(slug)) continue;
|
|
96
|
+
const cand = { id, slug, source: "change", from: name };
|
|
97
|
+
if (captured(cand)) continue; // captured (any slug) or cited by an existing skill
|
|
82
98
|
const proposal = path.join(archiveDir, name, "proposal.md");
|
|
83
|
-
|
|
99
|
+
cand.why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
|
|
84
100
|
seen.add(slug);
|
|
85
|
-
candidates.push(
|
|
101
|
+
candidates.push(cand);
|
|
86
102
|
}
|
|
87
103
|
}
|
|
88
104
|
|
|
89
105
|
// Source 2 — fix-shaped commits in the git history (ADR 0013). Archive wins a
|
|
90
106
|
// slug collision: a `## Why` paragraph is a richer seed than a subject line.
|
|
91
107
|
for (const cand of gitFixCommits(projectRoot, { since, limit: GIT_LOG_LIMIT })) {
|
|
92
|
-
if (
|
|
108
|
+
if (seen.has(cand.slug) || captured(cand)) continue;
|
|
93
109
|
seen.add(cand.slug);
|
|
94
110
|
candidates.push(cand);
|
|
95
111
|
}
|
|
@@ -216,6 +232,29 @@ function skillSlug(id) {
|
|
|
216
232
|
return slug.length > 40 ? slug.slice(0, 40).replace(/-+$/g, "") : (slug || "lesson");
|
|
217
233
|
}
|
|
218
234
|
|
|
235
|
+
// Token-overlap similarity between two kebab slugs (Jaccard over meaningful
|
|
236
|
+
// tokens). "0049-fix-tela-preta-render" vs "tela-preta-crash-de-render-sem-
|
|
237
|
+
// error-boundary" share tela/preta/render → high overlap even though the
|
|
238
|
+
// human renamed the skill after the lesson. Deterministic; never semantic.
|
|
239
|
+
function slugSimilarity(a, b) {
|
|
240
|
+
const toks = (s) => new Set(
|
|
241
|
+
String(s).split("-").filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !SLUG_NOISE.has(t)),
|
|
242
|
+
);
|
|
243
|
+
const A = toks(a);
|
|
244
|
+
const B = toks(b);
|
|
245
|
+
if (A.size === 0 || B.size === 0) return 0;
|
|
246
|
+
let inter = 0;
|
|
247
|
+
for (const t of A) if (B.has(t)) inter += 1;
|
|
248
|
+
return inter / (A.size + B.size - inter);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Fix-shaped verbs and connectives carry no lesson identity — without this,
|
|
252
|
+
// every "fix-..." candidate looks 30% similar to every "fix-..." skill.
|
|
253
|
+
const SLUG_NOISE = new Set([
|
|
254
|
+
"fix", "bug", "hotfix", "patch", "the", "and", "for", "with", "sem", "com",
|
|
255
|
+
"que", "nao", "por", "para", "una", "uma", "dos", "das",
|
|
256
|
+
]);
|
|
257
|
+
|
|
219
258
|
function firstWhyLine(proposalText) {
|
|
220
259
|
const m = proposalText.match(/##\s*Why\s*\r?\n+([^\r\n][^\r\n]*)/i);
|
|
221
260
|
if (!m) return "";
|
package/src/commands/spec.js
CHANGED
|
@@ -134,9 +134,10 @@ function specSet(args, flags) {
|
|
|
134
134
|
const impl = flagString(flags, "implementation");
|
|
135
135
|
const status = flagString(flags, "status");
|
|
136
136
|
const bump = flagString(flags, "bump");
|
|
137
|
+
const versionExplicit = flagString(flags, "version");
|
|
137
138
|
const criterion = flagString(flags, "criterion");
|
|
138
|
-
if (impl === undefined && status === undefined && bump === undefined && criterion === undefined) {
|
|
139
|
-
console.error(c.red("error:") + " spec set needs at least one of --implementation, --status, --bump, --criterion");
|
|
139
|
+
if (impl === undefined && status === undefined && bump === undefined && versionExplicit === undefined && criterion === undefined) {
|
|
140
|
+
console.error(c.red("error:") + " spec set needs at least one of --implementation, --status, --bump, --version, --criterion");
|
|
140
141
|
console.error(c.gray("hint: ") + `example: doctrina spec set ${capability} --implementation "verified — \`src/x.js\`" --bump minor`);
|
|
141
142
|
return 2;
|
|
142
143
|
}
|
|
@@ -156,6 +157,10 @@ function specSet(args, flags) {
|
|
|
156
157
|
if (!["major", "minor", "patch"].includes(bump)) errors.push(`--bump needs major|minor|patch (got "${bump}")`);
|
|
157
158
|
else apply(bumpVersion(text, bump));
|
|
158
159
|
}
|
|
160
|
+
if (versionExplicit !== undefined) {
|
|
161
|
+
if (!/^\d+\.\d+\.\d+$/.test(versionExplicit)) errors.push(`--version needs semver X.Y.Z (got "${versionExplicit}")`);
|
|
162
|
+
else apply(setHeader(text, "Version", versionExplicit));
|
|
163
|
+
}
|
|
159
164
|
if (criterion !== undefined) {
|
|
160
165
|
const m = String(criterion).match(/^(\d+)\s*:\s*(.+)$/);
|
|
161
166
|
if (!m) errors.push(`--criterion needs "<n>:<mark>" (got "${criterion}")`);
|
|
@@ -183,7 +188,10 @@ function specSet(args, flags) {
|
|
|
183
188
|
const derived = deriveIndex(projectRoot, index);
|
|
184
189
|
derived.last_updated = date;
|
|
185
190
|
idx.save(projectRoot, derived);
|
|
186
|
-
|
|
191
|
+
// Echo the SPEC's resulting version, not the framework's — the two look
|
|
192
|
+
// identical in output and the ambiguity was a field-review papercut.
|
|
193
|
+
const specVersion = specHeader(text, "Version") ?? "—";
|
|
194
|
+
console.log(c.green("indexed") + ` spec "${capability}" synced (spec version ${specVersion})`);
|
|
187
195
|
return 0;
|
|
188
196
|
}
|
|
189
197
|
|
|
@@ -214,6 +222,8 @@ Options:
|
|
|
214
222
|
--implementation "..." With \`set\`: set the Implementation header.
|
|
215
223
|
--status "..." With \`set\`: set the Status header.
|
|
216
224
|
--bump major|minor|patch With \`set\`: bump the spec Version.
|
|
225
|
+
--version X.Y.Z With \`set\`: set the spec Version explicitly
|
|
226
|
+
(the output echoes the SPEC version, not the CLI's).
|
|
217
227
|
--criterion "<n>:<mark>" With \`set\`: set criterion n's [mark]
|
|
218
228
|
(e.g. --criterion "2:verified").
|
|
219
229
|
`;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "../lib/fs-ops.js";
|
|
4
|
-
import { locateTemplatesDir } from "../lib/templates.js";
|
|
4
|
+
import { locateTemplatesDir, loadTemplateTree } from "../lib/templates.js";
|
|
5
|
+
import { surfaceBlock, findSurfaceBlock } from "../lib/commands.js";
|
|
5
6
|
import { flagBool } from "../lib/args.js";
|
|
6
7
|
import { c } from "../lib/colors.js";
|
|
7
8
|
import { suggest } from "../lib/suggest.js";
|
|
@@ -56,6 +57,50 @@ function updateTemplates(flags) {
|
|
|
56
57
|
|
|
57
58
|
const plan = [];
|
|
58
59
|
|
|
60
|
+
// AGENTS.md command-surface block: the one CLI-OWNED span of the file
|
|
61
|
+
// (marker-delimited, generated from the command catalog). Three cases:
|
|
62
|
+
// markers present but stale -> regenerate the span; a legacy hand-written
|
|
63
|
+
// "## Doctrina command surface" section without markers -> replace that
|
|
64
|
+
// section with the managed block (the section was CLI-scaffolded content
|
|
65
|
+
// to begin with — this is the one sanctioned exception to additive-only,
|
|
66
|
+
// see ADR 0015); neither -> append the block. This is what makes
|
|
67
|
+
// `doctrina upgrade --write` actually refresh AGENTS.md when the CLI
|
|
68
|
+
// gains commands, instead of only bumping the stamp.
|
|
69
|
+
const agentsMdPath = path.join(projectRoot, "AGENTS.md");
|
|
70
|
+
if (isFile(agentsMdPath)) {
|
|
71
|
+
const text = read(agentsMdPath);
|
|
72
|
+
const block = findSurfaceBlock(text);
|
|
73
|
+
const fresh = surfaceBlock();
|
|
74
|
+
if (block) {
|
|
75
|
+
if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(fresh)) {
|
|
76
|
+
plan.push({
|
|
77
|
+
describe: ["AGENTS.md: regenerate the doctrina:surface block (stale vs installed CLI)"],
|
|
78
|
+
apply() {
|
|
79
|
+
const cur = read(agentsMdPath);
|
|
80
|
+
const b = findSurfaceBlock(cur);
|
|
81
|
+
if (b) write(agentsMdPath, cur.slice(0, b.start) + fresh + cur.slice(b.end), { force: true });
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
const legacy = findLegacySurfaceSection(text);
|
|
87
|
+
plan.push({
|
|
88
|
+
describe: [legacy
|
|
89
|
+
? "AGENTS.md: replace the hand-written \"## Doctrina command surface\" section with the generated doctrina:surface block"
|
|
90
|
+
: "AGENTS.md: append the generated doctrina:surface command block (agents discover commands through this file)"],
|
|
91
|
+
apply() {
|
|
92
|
+
const cur = read(agentsMdPath);
|
|
93
|
+
const l = findLegacySurfaceSection(cur);
|
|
94
|
+
if (l) {
|
|
95
|
+
write(agentsMdPath, cur.slice(0, l.start) + fresh + "\n\n" + cur.slice(l.end), { force: true });
|
|
96
|
+
} else {
|
|
97
|
+
write(agentsMdPath, cur + (cur.endsWith("\n") ? "" : "\n") + "\n" + fresh + "\n", { force: true });
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
59
104
|
// Markdown files: append stub sections for missing recommended headings.
|
|
60
105
|
for (const [rel, sections] of [
|
|
61
106
|
["AGENTS.md", AGENTS_SECTIONS],
|
|
@@ -178,6 +223,15 @@ function checkTemplates() {
|
|
|
178
223
|
if (hasHeading(text, heading)) ok.push(`AGENTS.md: ${heading}`);
|
|
179
224
|
else findings.push(`AGENTS.md missing recommended section "${heading}"`);
|
|
180
225
|
}
|
|
226
|
+
// Command-surface block: present and current vs the installed catalog.
|
|
227
|
+
const block = findSurfaceBlock(text);
|
|
228
|
+
if (!block) {
|
|
229
|
+
findings.push("AGENTS.md has no doctrina:surface block — agents discover commands through this file (`templates update --write` adds it)");
|
|
230
|
+
} else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock())) {
|
|
231
|
+
findings.push("AGENTS.md doctrina:surface block is stale vs the installed CLI (`templates update --write` or `doctrina upgrade --write` regenerates it)");
|
|
232
|
+
} else {
|
|
233
|
+
ok.push("AGENTS.md: doctrina:surface block current");
|
|
234
|
+
}
|
|
181
235
|
} else {
|
|
182
236
|
findings.push("AGENTS.md missing at project root");
|
|
183
237
|
}
|
|
@@ -194,6 +248,32 @@ function checkTemplates() {
|
|
|
194
248
|
findings.push(".doctrina/product.md missing");
|
|
195
249
|
}
|
|
196
250
|
|
|
251
|
+
// Installed agent adapters: every adapter is a thin POINTER at AGENTS.md
|
|
252
|
+
// (that is why one `upgrade --write` refresh of the hub reaches every
|
|
253
|
+
// installed agent). Verify the contract holds for each adapter file the
|
|
254
|
+
// project actually has: it must still reference AGENTS.md. The shipped
|
|
255
|
+
// adapter template tree is the inventory, so a new adapter is covered
|
|
256
|
+
// automatically.
|
|
257
|
+
try {
|
|
258
|
+
const templatesDir = locateTemplatesDir();
|
|
259
|
+
const adaptersRoot = path.join(templatesDir, "adapters");
|
|
260
|
+
if (isDir(adaptersRoot)) {
|
|
261
|
+
for (const agent of walk(adaptersRoot).map((f) => relPath(adaptersRoot, f))) {
|
|
262
|
+
const posix = agent.replace(/\\/g, "/");
|
|
263
|
+
if (posix.endsWith("README.md") || posix.endsWith(".gitkeep")) continue;
|
|
264
|
+
const rel = posix.split("/").slice(1).join("/").replace(/\.template$/, "");
|
|
265
|
+
if (!rel) continue;
|
|
266
|
+
const installed = path.join(projectRoot, rel);
|
|
267
|
+
if (!isFile(installed)) continue; // not installed — nothing to check
|
|
268
|
+
if (read(installed).includes("AGENTS.md")) {
|
|
269
|
+
ok.push(`adapter ${rel}: points at AGENTS.md`);
|
|
270
|
+
} else {
|
|
271
|
+
findings.push(`adapter ${rel} no longer references AGENTS.md — agents loading it will miss the hub (restore the pointer, e.g. re-run \`doctrina init --agent <name> --force\`)`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
} catch { /* no templates dir (unusual install) — skip the adapter pass */ }
|
|
276
|
+
|
|
197
277
|
// index.json schema fields
|
|
198
278
|
const indexPath = path.join(projectRoot, ".doctrina", "index.json");
|
|
199
279
|
if (isFile(indexPath)) {
|
|
@@ -227,6 +307,24 @@ function checkTemplates() {
|
|
|
227
307
|
return 1;
|
|
228
308
|
}
|
|
229
309
|
|
|
310
|
+
// Whitespace/EOL-insensitive comparison for the surface block, so a CRLF
|
|
311
|
+
// checkout is not eternally "stale".
|
|
312
|
+
function normalizeBlock(s) {
|
|
313
|
+
return s.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trim();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// A pre-marker AGENTS.md's hand-written surface section: from the
|
|
317
|
+
// "## Doctrina command surface" heading up to (excluding) the next "## "
|
|
318
|
+
// heading. The trailing "Continuous: ..." paragraph the old template put
|
|
319
|
+
// after the bullets belongs to the section body, so it is replaced too.
|
|
320
|
+
function findLegacySurfaceSection(text) {
|
|
321
|
+
const head = text.match(/^##\s+Doctrina command surface.*$/m);
|
|
322
|
+
if (!head) return null;
|
|
323
|
+
const after = text.slice(head.index + head[0].length);
|
|
324
|
+
const next = after.match(/^##\s+/m);
|
|
325
|
+
return { start: head.index, end: next ? head.index + head[0].length + next.index : text.length };
|
|
326
|
+
}
|
|
327
|
+
|
|
230
328
|
function hasHeading(text, heading) {
|
|
231
329
|
// Match an exact h2 heading at line start (case-insensitive for the body
|
|
232
330
|
// after "## ", so "## Vision" matches "## VISION" or "## vision" too).
|
|
@@ -245,15 +343,20 @@ Subcommands:
|
|
|
245
343
|
their relative paths and line counts.
|
|
246
344
|
check Compare the current project's AGENTS.md, product.md,
|
|
247
345
|
and index.json against the recommended sections and
|
|
248
|
-
schema fields shipped in this CLI version
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
346
|
+
schema fields shipped in this CLI version — including
|
|
347
|
+
whether the AGENTS.md doctrina:surface block (the
|
|
348
|
+
CLI-owned, generated command catalog) is present and
|
|
349
|
+
current. Read-only; never modifies any files. Exits 1
|
|
350
|
+
when recommendations exist, 0 otherwise.
|
|
351
|
+
update Fixer for what check reports: appends missing
|
|
352
|
+
recommended sections as stubs, adds missing index.json
|
|
353
|
+
fields, and regenerates the AGENTS.md doctrina:surface
|
|
354
|
+
block from the installed command catalog. Preview by
|
|
355
|
+
default (exits 1 while updates are pending); pass
|
|
356
|
+
--write to apply. Additive-only outside the surface
|
|
357
|
+
block: the marker-delimited span is the one CLI-owned
|
|
358
|
+
region it rewrites (ADR 0015); your content is never
|
|
359
|
+
touched.
|
|
257
360
|
|
|
258
361
|
Distinct from \`doctrina validate\`: validate answers "is this a
|
|
259
362
|
well-formed Doctrina tree?"; templates check answers "does this
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { exists } from "../lib/fs-ops.js";
|
|
4
|
+
import * as idx from "../lib/index-json.js";
|
|
5
|
+
import { deriveIndex, indexesMatch } from "../lib/scan.js";
|
|
6
|
+
import { cliVersion } from "../lib/version.js";
|
|
7
|
+
import { flagBool } from "../lib/args.js";
|
|
8
|
+
import { c } from "../lib/colors.js";
|
|
9
|
+
import { today } from "../lib/dates.js";
|
|
10
|
+
import * as templates from "./templates.js";
|
|
11
|
+
import * as validate from "./validate.js";
|
|
12
|
+
|
|
13
|
+
// Bring an existing project up to the installed CLI (user-reported gap: you
|
|
14
|
+
// `npm update doctrina-cli`, but the project you once `doctrina init`-ed
|
|
15
|
+
// keeps its old scaffold — stale framework stamp, an AGENTS.md that predates
|
|
16
|
+
// new commands, missing recommended sections). `upgrade` is the one command
|
|
17
|
+
// that closes that window, and it is an ORCHESTRATOR over the pieces that
|
|
18
|
+
// already exist — templates update (additive-only), index rebuild (stamp
|
|
19
|
+
// migration), validate --fix — so there is exactly one definition of each
|
|
20
|
+
// step. Preview by default; --write applies. Never rewrites user content
|
|
21
|
+
// (the same additive-only bound as `templates update`).
|
|
22
|
+
|
|
23
|
+
export async function run(_positional, flags) {
|
|
24
|
+
const projectRoot = process.cwd();
|
|
25
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
26
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
27
|
+
}
|
|
28
|
+
const writeMode = flagBool(flags, "write", false);
|
|
29
|
+
const running = cliVersion();
|
|
30
|
+
|
|
31
|
+
// 1. Version gap: what the tree was last managed by vs what is installed.
|
|
32
|
+
let index = null;
|
|
33
|
+
let stamped = null;
|
|
34
|
+
try {
|
|
35
|
+
index = idx.load(projectRoot);
|
|
36
|
+
stamped = index.framework_version ?? null;
|
|
37
|
+
} catch {
|
|
38
|
+
index = null;
|
|
39
|
+
}
|
|
40
|
+
console.log(c.bold("doctrina upgrade") + c.gray(` — project ${stamped ?? "unstamped"} → CLI ${running}${writeMode ? "" : " (preview; --write applies)"}`));
|
|
41
|
+
console.log("");
|
|
42
|
+
if (stamped === running) {
|
|
43
|
+
console.log(c.gray("The framework stamp already matches the installed CLI — checking the scaffold anyway."));
|
|
44
|
+
console.log("");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let pending = 0;
|
|
48
|
+
|
|
49
|
+
// 2. Additive template updates (missing recommended sections in AGENTS.md /
|
|
50
|
+
// product.md, missing index.json fields). Reuses `templates update`
|
|
51
|
+
// verbatim: preview without --write, apply with it.
|
|
52
|
+
console.log(c.gray("──── 1/3 scaffold shape (templates update)"));
|
|
53
|
+
const tplFlags = writeMode ? new Map([["write", true]]) : new Map();
|
|
54
|
+
const tplCode = await templates.run(["update"], tplFlags);
|
|
55
|
+
if (tplCode !== 0 && !writeMode) pending += 1; // preview exits 1 when updates are pending
|
|
56
|
+
|
|
57
|
+
// 3. Index: rebuild from the tree, which also migrates the framework stamp.
|
|
58
|
+
console.log("");
|
|
59
|
+
console.log(c.gray("──── 2/3 index + framework stamp"));
|
|
60
|
+
try {
|
|
61
|
+
const current = idx.load(projectRoot);
|
|
62
|
+
const drifted = !indexesMatch(deriveIndex(projectRoot, current), current);
|
|
63
|
+
const staleStamp = (current.framework_version ?? null) !== running;
|
|
64
|
+
if (!drifted && !staleStamp) {
|
|
65
|
+
console.log(c.green("ok") + " index.json matches the tree and the stamp is current");
|
|
66
|
+
} else if (writeMode) {
|
|
67
|
+
const derived = deriveIndex(projectRoot, current);
|
|
68
|
+
derived.last_updated = today();
|
|
69
|
+
idx.save(projectRoot, derived); // save() stamps framework_version
|
|
70
|
+
console.log(c.green("rebuilt") + ` index.json${staleStamp ? ` — stamp ${current.framework_version ?? "unstamped"} → ${running}` : ""}`);
|
|
71
|
+
} else {
|
|
72
|
+
if (drifted) console.log(c.yellow("would ") + "rebuild index.json from the tree (drifted)");
|
|
73
|
+
if (staleStamp) console.log(c.yellow("would ") + `migrate framework stamp ${current.framework_version ?? "unstamped"} → ${running}`);
|
|
74
|
+
pending += 1;
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
if (writeMode) {
|
|
78
|
+
console.log(c.red("error: ") + "index.json is missing or unreadable — run `doctrina index rebuild` manually");
|
|
79
|
+
} else {
|
|
80
|
+
console.log(c.yellow("would ") + "rebuild index.json (missing or unreadable)");
|
|
81
|
+
pending += 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 4. Full structural check — surfaces what the upgrade cannot fix by
|
|
86
|
+
// itself (new validate checks the old scaffold fails, hand-authored
|
|
87
|
+
// drift). The command surface in AGENTS.md is NOT in this bucket any
|
|
88
|
+
// more: step 1 regenerates the doctrina:surface block from the
|
|
89
|
+
// installed catalog (ADR 0015). --fix under --write so the mechanical
|
|
90
|
+
// part self-heals.
|
|
91
|
+
console.log("");
|
|
92
|
+
console.log(c.gray("──── 3/3 validate" + (writeMode ? " --fix" : "")));
|
|
93
|
+
const vFlags = writeMode ? new Map([["fix", true]]) : new Map();
|
|
94
|
+
const vCode = await validate.run([], vFlags);
|
|
95
|
+
|
|
96
|
+
console.log("");
|
|
97
|
+
if (!writeMode) {
|
|
98
|
+
if (pending > 0) {
|
|
99
|
+
console.log(c.yellow("pending") + ` upgrade steps found — apply them: ${c.cyan("doctrina upgrade --write")}`);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
console.log(c.green("ok") + " nothing to upgrade — the project matches the installed CLI");
|
|
103
|
+
return vCode;
|
|
104
|
+
}
|
|
105
|
+
if (vCode === 0) {
|
|
106
|
+
console.log(c.green(`✓ project upgraded to ${running}`) + c.gray(" — review any warnings above; new commands: `doctrina --help`."));
|
|
107
|
+
console.log(c.gray("AGENTS.md's doctrina:surface block was regenerated from the installed catalog, so agents reading the hub now discover the current commands."));
|
|
108
|
+
} else {
|
|
109
|
+
console.log(c.yellow("upgraded with findings") + " — resolve the validate errors above (they predate or exceed what an additive upgrade can fix).");
|
|
110
|
+
}
|
|
111
|
+
return vCode;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const help = `
|
|
115
|
+
Usage: doctrina upgrade [--write]
|
|
116
|
+
|
|
117
|
+
Bring an existing project up to the installed CLI after an npm update.
|
|
118
|
+
The project keeps the scaffold of the version that init-ed it; this is
|
|
119
|
+
the one command that closes the gap, orchestrating the existing pieces:
|
|
120
|
+
|
|
121
|
+
1. templates update — regenerate the AGENTS.md doctrina:surface block
|
|
122
|
+
from the installed command catalog (the block is
|
|
123
|
+
CLI-owned, ADR 0015 — this is how agents reading
|
|
124
|
+
the hub discover commands added since init), and
|
|
125
|
+
append missing recommended sections / index.json
|
|
126
|
+
fields (additive-only outside the block)
|
|
127
|
+
2. index rebuild — regenerate index.json from the tree and migrate
|
|
128
|
+
the framework_version stamp to the running CLI
|
|
129
|
+
3. validate (--fix) — surface anything the upgrade cannot fix
|
|
130
|
+
(hand-authored drift, new validate checks)
|
|
131
|
+
|
|
132
|
+
Preview by default (exits 1 when steps are pending); --write applies.
|
|
133
|
+
`;
|