doctrina-cli 0.4.0 → 0.8.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 +32 -13
- package/package.json +1 -1
- package/src/commands/change.js +128 -38
- package/src/commands/close.js +104 -0
- package/src/commands/coverage.js +100 -20
- package/src/commands/hooks.js +9 -3
- package/src/commands/next.js +56 -3
- package/src/commands/review.js +177 -0
- package/src/commands/skill.js +112 -2
- package/src/commands/spec.js +92 -6
- package/src/commands/status.js +178 -0
- package/src/commands/trace.js +31 -0
- package/src/commands/validate.js +97 -11
- package/src/commands/verify.js +234 -13
- package/src/commands/watch.js +97 -0
- package/src/commands/why.js +182 -0
- package/src/commands/work.js +241 -20
- package/src/index.js +13 -1
- package/src/lib/spec-ops.js +236 -0
- package/templates/AGENTS.md.template +49 -22
- package/templates/change/spec-delta.md.template +13 -2
- package/templates/hooks/pre-commit.sample +21 -4
- package/templates/spec.md.template +7 -3
package/src/commands/work.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
4
5
|
import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
5
|
-
import { flagString } from "../lib/args.js";
|
|
6
|
+
import { flagString, flagBool } from "../lib/args.js";
|
|
7
|
+
import * as idx from "../lib/index-json.js";
|
|
6
8
|
import { c } from "../lib/colors.js";
|
|
7
9
|
import { assessBrief } from "../lib/clarity.js";
|
|
8
10
|
import { changeNew } from "./change.js";
|
|
@@ -17,15 +19,49 @@ import { changeNew } from "./change.js";
|
|
|
17
19
|
|
|
18
20
|
export async function run(positional, flags) {
|
|
19
21
|
const prompt = positional.join(" ").trim();
|
|
20
|
-
|
|
22
|
+
const projectRoot = process.cwd();
|
|
23
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
24
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --resume <id>: reprint the playbook for an existing open change rather than
|
|
28
|
+
// open a new one (review G1 — `work "continue"` used to slug the word and
|
|
29
|
+
// fabricate a junk `NNNN-continue` change the operator had to delete).
|
|
30
|
+
if (flags.has("resume")) {
|
|
31
|
+
return resumeChange(projectRoot, flagString(flags, "resume"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --from-diff backfills from code already written (review F8): the
|
|
35
|
+
// working-tree changes are the input, so no prompt is required.
|
|
36
|
+
const fromDiff = flagBool(flags, "from-diff", false);
|
|
37
|
+
const chore = flagBool(flags, "chore", false) || flagBool(flags, "no-spec", false);
|
|
38
|
+
let files = [];
|
|
39
|
+
if (fromDiff) {
|
|
40
|
+
files = changedFiles(projectRoot);
|
|
41
|
+
if (files.length === 0) {
|
|
42
|
+
console.error(c.red("error:") + " --from-diff found no working-tree changes to backfill from");
|
|
43
|
+
console.error(c.gray("hint: ") + "make (or stage) the code changes first, then run `doctrina work --from-diff`");
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!prompt && !fromDiff) {
|
|
21
49
|
console.error(c.red("error:") + " work requires a prompt (quote it if it contains spaces)");
|
|
22
50
|
console.error(c.gray("hint: ") + "example: doctrina work \"add login with email and password\"");
|
|
23
51
|
return 2;
|
|
24
52
|
}
|
|
25
53
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
54
|
+
// A thin, resume-shaped prompt ("continue", "prossiga", ...) while a change
|
|
55
|
+
// is open is almost certainly "finish what's open", not "start a change named
|
|
56
|
+
// after that word". Suggest resuming instead of creating noise; --force opens
|
|
57
|
+
// a new change anyway. The match is a deterministic stoplist, not language
|
|
58
|
+
// understanding (still ADR 0005).
|
|
59
|
+
if (!fromDiff && !flagBool(flags, "force", false) && isResumeIntent(prompt)) {
|
|
60
|
+
const open = openChanges(projectRoot);
|
|
61
|
+
if (open.length > 0) {
|
|
62
|
+
printResumeSuggestion(open);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
29
65
|
}
|
|
30
66
|
|
|
31
67
|
const pinned = flagString(flags, "capability");
|
|
@@ -34,38 +70,74 @@ export async function run(positional, flags) {
|
|
|
34
70
|
return 2;
|
|
35
71
|
}
|
|
36
72
|
|
|
37
|
-
const
|
|
73
|
+
const effPrompt = prompt || `backfill specs from ${files.length} changed file${files.length === 1 ? "" : "s"}`;
|
|
74
|
+
const slug = prompt ? slugify(prompt) : "backfill";
|
|
75
|
+
const id = flagString(flags, "id") ?? `${nextChangeNumber(projectRoot)}-${slug}`;
|
|
38
76
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
|
|
39
77
|
console.error(c.red("error:") + ` invalid change id "${id}" (lowercase letters, digits, hyphens)`);
|
|
40
78
|
return 2;
|
|
41
79
|
}
|
|
42
80
|
|
|
43
|
-
const code = changeNew([id,
|
|
81
|
+
const code = changeNew([id, effPrompt], flags);
|
|
44
82
|
if (code !== 0) return code;
|
|
45
83
|
|
|
46
84
|
// Record the prompt verbatim as the change's Why — the raw intent has
|
|
47
|
-
// one home, and it is the proposal, not the playbook output.
|
|
85
|
+
// one home, and it is the proposal, not the playbook output. For --from-diff,
|
|
86
|
+
// also record the changed files so the backfill's input is in the artifact.
|
|
48
87
|
const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
|
|
49
88
|
if (isFile(proposalPath)) {
|
|
50
89
|
const txt = read(proposalPath);
|
|
90
|
+
let why = effPrompt;
|
|
91
|
+
if (fromDiff) {
|
|
92
|
+
const list = files.slice(0, 30).map((f) => `- \`${f}\``).join("\n");
|
|
93
|
+
const more = files.length > 30 ? `\n- … and ${files.length - 30} more` : "";
|
|
94
|
+
why = `${effPrompt}\n\nBackfilled from working-tree changes:\n${list}${more}`;
|
|
95
|
+
}
|
|
51
96
|
// CRLF-tolerant: templates may be checked out with \r\n on Windows.
|
|
52
|
-
let updated = txt.replace(/(## Why\r?\n\r?\n)<!--[\s\S]*?-->[ \t]*\r?\n?/, `$1${
|
|
53
|
-
if (updated === txt) updated = txt.replace(/(## Why\r?\n)/, `$1\n${
|
|
97
|
+
let updated = txt.replace(/(## Why\r?\n\r?\n)<!--[\s\S]*?-->[ \t]*\r?\n?/, `$1${why}\n`);
|
|
98
|
+
if (updated === txt) updated = txt.replace(/(## Why\r?\n)/, `$1\n${why}\n`);
|
|
54
99
|
if (pinned) {
|
|
55
100
|
updated = updated.replace(/^(-\s+\*\*Affects specs:\*\*).*$/m, `$1 ${pinned}`);
|
|
56
101
|
}
|
|
57
102
|
if (updated !== txt) write(proposalPath, updated, { force: true });
|
|
58
103
|
}
|
|
59
104
|
|
|
60
|
-
|
|
105
|
+
// Capability hint: term overlap for a prompt, changed-file overlap for a diff
|
|
106
|
+
// (review F10 — rank by what the working tree touched, not just prompt words).
|
|
107
|
+
const allChanged = fromDiff ? files : changedFiles(projectRoot);
|
|
108
|
+
const diffMatches = pinned ? [] : rankCapabilitiesByDiff(projectRoot, allChanged);
|
|
109
|
+
const matches = pinned ? [] : (fromDiff ? diffMatches : rankCapabilities(projectRoot, effPrompt));
|
|
61
110
|
const capability = pinned ?? matches[0]?.id ?? null;
|
|
62
|
-
const clarity = assessBrief(
|
|
111
|
+
const clarity = assessBrief(effPrompt, { kind: "prompt" });
|
|
63
112
|
|
|
64
113
|
console.log("");
|
|
65
|
-
|
|
114
|
+
if (chore) {
|
|
115
|
+
printChorePlaybook(projectRoot, { id, prompt: effPrompt });
|
|
116
|
+
} else {
|
|
117
|
+
printPlaybook(projectRoot, {
|
|
118
|
+
id, prompt: effPrompt, pinned, matches, capability, clarity,
|
|
119
|
+
fromDiff, diffMatches: fromDiff ? [] : diffMatches,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
66
122
|
return 0;
|
|
67
123
|
}
|
|
68
124
|
|
|
125
|
+
// Changed files in the working tree (tracked + untracked), for the diff-based
|
|
126
|
+
// capability hint and `--from-diff` backfill. Empty outside a git repo or with
|
|
127
|
+
// no changes. Read-only: never invokes a mutating git command.
|
|
128
|
+
function changedFiles(projectRoot) {
|
|
129
|
+
const run = (args) => {
|
|
130
|
+
const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
|
|
131
|
+
if (r.error || r.status !== 0) return [];
|
|
132
|
+
return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
133
|
+
};
|
|
134
|
+
const set = new Set([
|
|
135
|
+
...run(["diff", "--name-only", "HEAD"]), // tracked, staged + unstaged
|
|
136
|
+
...run(["ls-files", "--others", "--exclude-standard"]), // untracked
|
|
137
|
+
]);
|
|
138
|
+
return [...set];
|
|
139
|
+
}
|
|
140
|
+
|
|
69
141
|
// Next sequential NNNN across open changes and the archive, so work-driven
|
|
70
142
|
// ids stay ordered no matter how earlier changes were named.
|
|
71
143
|
function nextChangeNumber(projectRoot) {
|
|
@@ -91,6 +163,70 @@ function nextChangeNumber(projectRoot) {
|
|
|
91
163
|
return String(max + 1).padStart(4, "0");
|
|
92
164
|
}
|
|
93
165
|
|
|
166
|
+
// Open (non-archived) changes from the index — the set `work --resume` and
|
|
167
|
+
// the thin-prompt guard offer to continue.
|
|
168
|
+
function openChanges(projectRoot) {
|
|
169
|
+
try {
|
|
170
|
+
return idx.load(projectRoot).artifacts?.changes ?? [];
|
|
171
|
+
} catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// A deterministic stoplist of "carry on" prompts (en + pt). Folded and
|
|
177
|
+
// stripped to letters so "go on" / "keep going" / "continuação" all match.
|
|
178
|
+
// Not language understanding — just the words that mean "resume", which is
|
|
179
|
+
// exactly when slugging the prompt produces a junk change (G1).
|
|
180
|
+
const RESUME_WORDS = new Set([
|
|
181
|
+
"continue", "continua", "continuar", "continuacao", "continuemos", "continuando",
|
|
182
|
+
"prossiga", "prosseguir", "prossegue", "proceed", "resume", "resumir",
|
|
183
|
+
"retomar", "retoma", "segue", "seguir", "go", "goon", "next", "keepgoing",
|
|
184
|
+
"vamos", "more", "mais",
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
function isResumeIntent(prompt) {
|
|
188
|
+
return RESUME_WORDS.has(fold(prompt).replace(/[^a-z0-9]+/g, ""));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function printResumeSuggestion(open) {
|
|
192
|
+
console.log(c.yellow(`open change${open.length === 1 ? "" : "s"} detected`) +
|
|
193
|
+
" — did you mean to continue one of these?");
|
|
194
|
+
console.log("");
|
|
195
|
+
for (const ch of open) {
|
|
196
|
+
console.log(` ${c.cyan(ch.id)} ${c.gray((ch.title ?? "").slice(0, 60))} ${c.gray(`[${ch.status ?? "open"}]`)}`);
|
|
197
|
+
}
|
|
198
|
+
console.log("");
|
|
199
|
+
console.log("Resume the existing change (reprints its playbook):");
|
|
200
|
+
console.log(` ${c.cyan(`doctrina work --resume ${open[0].id}`)}`);
|
|
201
|
+
console.log("Or open a new change anyway:");
|
|
202
|
+
console.log(` ${c.cyan(`doctrina work "<prompt>" --force`)}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Reprint the work playbook for an already-open change, ranking capabilities
|
|
206
|
+
// from its recorded title (the original prompt). Creates nothing.
|
|
207
|
+
function resumeChange(projectRoot, resumeId) {
|
|
208
|
+
const open = openChanges(projectRoot);
|
|
209
|
+
if (!resumeId || resumeId === true) {
|
|
210
|
+
console.error(c.red("error:") + " --resume needs a change id");
|
|
211
|
+
if (open.length) console.error(c.gray("open: ") + open.map((ch) => ch.id).join(", "));
|
|
212
|
+
return 2;
|
|
213
|
+
}
|
|
214
|
+
const entry = open.find((ch) => ch.id === resumeId);
|
|
215
|
+
if (!entry && !isDir(path.join(projectRoot, ".doctrina", "changes", resumeId))) {
|
|
216
|
+
console.error(c.red("error:") + ` no open change "${resumeId}"`);
|
|
217
|
+
if (open.length) console.error(c.gray("open: ") + open.map((ch) => ch.id).join(", "));
|
|
218
|
+
return 1;
|
|
219
|
+
}
|
|
220
|
+
const prompt = entry?.title ?? resumeId;
|
|
221
|
+
const matches = rankCapabilities(projectRoot, prompt);
|
|
222
|
+
const capability = matches[0]?.id ?? null;
|
|
223
|
+
const clarity = assessBrief(prompt, { kind: "prompt" });
|
|
224
|
+
console.log(c.bold(`Resuming change ${resumeId}`) + c.gray(" — agent-executed (ADR 0005)."));
|
|
225
|
+
console.log("");
|
|
226
|
+
printPlaybook(projectRoot, { id: resumeId, prompt, pinned: null, matches, capability, clarity });
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
94
230
|
// ASCII-folded kebab-case of the prompt ("Faça o login" -> "faca-o-login").
|
|
95
231
|
export function slugify(text) {
|
|
96
232
|
let slug = text
|
|
@@ -152,16 +288,69 @@ export function rankCapabilities(projectRoot, prompt) {
|
|
|
152
288
|
return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, 3);
|
|
153
289
|
}
|
|
154
290
|
|
|
155
|
-
|
|
291
|
+
// Rank capabilities by the working tree, not the prompt (review F10): a changed
|
|
292
|
+
// file scores its capability when the file sits under a path segment named for
|
|
293
|
+
// it, or when the spec cites the file as evidence. A deterministic overlap
|
|
294
|
+
// hint for the agent — never a decision (ADR 0005).
|
|
295
|
+
export function rankCapabilitiesByDiff(projectRoot, files) {
|
|
296
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
297
|
+
if (!files || files.length === 0 || !isDir(specsDir)) return [];
|
|
298
|
+
const norm = files.map((f) => f.replace(/\\/g, "/"));
|
|
299
|
+
|
|
300
|
+
const ranked = [];
|
|
301
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
302
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
303
|
+
if (!isFile(specPath)) continue;
|
|
304
|
+
const specText = read(specPath);
|
|
305
|
+
let score = 0;
|
|
306
|
+
for (const f of norm) {
|
|
307
|
+
if (f.split("/").includes(cap)) score += 3; // src/<cap>/... etc.
|
|
308
|
+
if (specText.includes(f)) score += 5; // file cited in the spec
|
|
309
|
+
else if (specText.includes(path.basename(f))) score += 2; // filename cited
|
|
310
|
+
}
|
|
311
|
+
if (score > 0) ranked.push({ id: cap, score, path: `.doctrina/specs/${cap}/spec.md` });
|
|
312
|
+
}
|
|
313
|
+
return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, 3);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// A chore is a spec-less change (review F9): the playbook drops the spec-delta
|
|
317
|
+
// steps and goes straight to implement → verify → archive → validate, so the
|
|
318
|
+
// agent is not prompted to invent a delta for infra/docs/build work.
|
|
319
|
+
function printChorePlaybook(projectRoot, { id, prompt }) {
|
|
320
|
+
console.log(c.bold(`Chore playbook — change ${id}`) + c.gray(" — agent-executed (ADR 0005); no spec deltas."));
|
|
321
|
+
console.log("");
|
|
322
|
+
console.log(`Prompt: "${prompt}"`);
|
|
323
|
+
console.log("");
|
|
324
|
+
console.log("Execute in order, in a single linear pass:");
|
|
325
|
+
console.log("");
|
|
326
|
+
console.log(`1. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
|
|
327
|
+
console.log(" with small, checkable steps; implement them, checking each box.");
|
|
328
|
+
console.log("2. Prove it (archive refuses unchecked boxes):");
|
|
329
|
+
console.log(` ${c.cyan("doctrina verify")} — the project's typecheck/test/build gate`);
|
|
330
|
+
console.log(` ${c.cyan("doctrina verify --clean")} — clean-checkout reproducibility lint`);
|
|
331
|
+
console.log(" Then check the proposal's ## Verification boxes and every task.");
|
|
332
|
+
console.log("3. This is a chore: no spec changes. If you find yourself needing a");
|
|
333
|
+
console.log(` spec delta, it is not a chore — reopen with ${c.cyan("doctrina work \"<prompt>\"")}.`);
|
|
334
|
+
console.log(`4. ${c.cyan(`doctrina change apply ${id}`)} (zero deltas → flips to applied).`);
|
|
335
|
+
console.log(`5. ${c.cyan(`doctrina change archive ${id}`)}, then ${c.cyan("doctrina validate")}.`);
|
|
336
|
+
console.log(c.gray(" If this fix taught a lesson worth not relearning, capture it: ") +
|
|
337
|
+
c.cyan("doctrina skill new <slug>") + c.gray("."));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity, fromDiff = false, diffMatches = [] }) {
|
|
156
341
|
const capToken = capability ?? "<capability>";
|
|
157
|
-
|
|
342
|
+
const title = fromDiff ? "Backfill playbook" : "Work playbook";
|
|
343
|
+
console.log(c.bold(`${title} — change ${id}`) + c.gray(" — agent-executed (ADR 0005)."));
|
|
158
344
|
console.log("");
|
|
159
345
|
console.log(`Prompt: "${prompt}"`);
|
|
346
|
+
if (fromDiff) {
|
|
347
|
+
console.log(c.gray("Code-first: write the spec that describes what the working tree already does."));
|
|
348
|
+
}
|
|
160
349
|
|
|
161
350
|
// Clarification gate (review Topic A): a thin prompt is the moment to ask
|
|
162
351
|
// the user, not to invent a spec. Advisory — the change is still scaffolded
|
|
163
352
|
// (it is a draft), but the agent is told to resolve the gaps first.
|
|
164
|
-
if (clarity?.thin) {
|
|
353
|
+
if (clarity?.thin && !fromDiff) {
|
|
165
354
|
console.log("");
|
|
166
355
|
console.log(c.yellow("⚠ thin prompt — clarify with the user before writing spec deltas:"));
|
|
167
356
|
for (const r of clarity.reasons) console.log(` - ${r}`);
|
|
@@ -172,12 +361,22 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
172
361
|
console.log(`Capability (pinned): ${c.cyan(pinned)}` +
|
|
173
362
|
(hasSpec ? "" : c.yellow(" — no spec yet; create it in step 2")));
|
|
174
363
|
} else if (matches.length > 0) {
|
|
175
|
-
|
|
364
|
+
const how = fromDiff ? "touched by your working tree" : "deterministic term match";
|
|
365
|
+
console.log(`Likely capabilities (${how} — a hint, not a decision):`);
|
|
176
366
|
for (const m of matches) {
|
|
177
367
|
console.log(` ${c.cyan(m.id.padEnd(20))} score ${String(m.score).padStart(3)} ${c.gray(m.path)}`);
|
|
178
368
|
}
|
|
179
369
|
} else {
|
|
180
|
-
console.log(c.gray(
|
|
370
|
+
console.log(c.gray(fromDiff
|
|
371
|
+
? "No existing spec matches the changed files — likely a new capability."
|
|
372
|
+
: "No existing spec matches the prompt — likely a new capability."));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Extra signal (review F10): even for a prompt-driven change, show which
|
|
376
|
+
// capabilities the working tree touched — often the truer hint.
|
|
377
|
+
if (!fromDiff && diffMatches.length > 0) {
|
|
378
|
+
console.log(c.gray("Also touched by your working tree: ") +
|
|
379
|
+
diffMatches.map((m) => c.cyan(m.id)).join(", "));
|
|
181
380
|
}
|
|
182
381
|
|
|
183
382
|
console.log("");
|
|
@@ -188,6 +387,10 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
188
387
|
console.log("");
|
|
189
388
|
console.log("2. If the capability has no spec yet:");
|
|
190
389
|
console.log(` ${c.cyan("doctrina spec new <capability>")}`);
|
|
390
|
+
console.log(c.gray(" Trace it to product intent: tag the product.md success-criteria bullet"));
|
|
391
|
+
console.log(c.gray(" it serves with an anchor (\"- [SC1] ...\") and set the spec's"));
|
|
392
|
+
console.log(c.gray(" \"**Realizes:** SC1\" header (or \"n/a — <why>\"). `doctrina validate` warns"));
|
|
393
|
+
console.log(c.gray(" on an active spec with no Realizes; `doctrina trace` reports the link."));
|
|
191
394
|
console.log("");
|
|
192
395
|
console.log("3. Write one delta per affected capability at");
|
|
193
396
|
console.log(` .doctrina/changes/${id}/specs/<capability>/delta.md:`);
|
|
@@ -198,6 +401,11 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
198
401
|
console.log(c.gray(" <EARS body. Keep the two axes honest (Status vs Implementation),"));
|
|
199
402
|
console.log(c.gray(" keep aspiration under ## Maturity → Future, and cite the proof per"));
|
|
200
403
|
console.log(c.gray(" criterion: \"1. [unverified] <signal> — verified by `path/to/test`\">"));
|
|
404
|
+
if (fromDiff) {
|
|
405
|
+
console.log(c.gray(" --from-diff: the code already exists — describe its CURRENT behaviour, and"));
|
|
406
|
+
console.log(c.gray(" mark each criterion [unverified] until a test proves it (don't assume the"));
|
|
407
|
+
console.log(c.gray(" diff is tested). The changed files are listed in the proposal's ## Why."));
|
|
408
|
+
}
|
|
201
409
|
console.log("");
|
|
202
410
|
console.log(`4. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
|
|
203
411
|
console.log(" with small, checkable implementation tasks (a few hours each, max).");
|
|
@@ -207,15 +415,18 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
207
415
|
console.log(" If the prompt is genuinely ambiguous, ask the user before assuming.");
|
|
208
416
|
console.log("");
|
|
209
417
|
console.log(`6. ${c.cyan(`doctrina analyze ${id}`)} — fix every ✗ before applying.`);
|
|
210
|
-
console.log(`7. ${c.cyan(`doctrina change apply ${id}`)} (MODIFIED
|
|
418
|
+
console.log(`7. ${c.cyan(`doctrina change apply ${id}`)} (MODIFIED with an ops block applies mechanically; prose merges by hand).`);
|
|
211
419
|
console.log("8. Prove it before declaring done (archive refuses unchecked boxes):");
|
|
212
420
|
console.log(` ${c.cyan("doctrina verify")} — the project's typecheck/test/build gate`);
|
|
213
421
|
console.log(` ${c.cyan("doctrina coverage")} — each acceptance criterion cites a real test`);
|
|
422
|
+
console.log(` ${c.cyan("doctrina trace")} — the capability traces to product intent`);
|
|
214
423
|
console.log(" Then check the proposal's ## Verification boxes and every task,");
|
|
215
424
|
console.log(" closing steps included, and bump Implementation to verified.");
|
|
216
425
|
console.log("");
|
|
217
426
|
console.log(`9. ${c.cyan(`doctrina change archive ${id}`)}`);
|
|
218
427
|
console.log(`10. ${c.cyan("doctrina validate")}, then ${c.cyan("doctrina next")} for the follow-up.`);
|
|
428
|
+
console.log(c.gray(" If this change taught a reusable lesson (a fix you'd hate to relearn,"));
|
|
429
|
+
console.log(c.gray(" a recurring convention), capture it: ") + c.cyan("doctrina skill new <slug>") + c.gray("."));
|
|
219
430
|
}
|
|
220
431
|
|
|
221
432
|
export const help = `
|
|
@@ -231,8 +442,18 @@ context → spec delta → tasks → implement → analyze → apply → verify
|
|
|
231
442
|
(verify + coverage) → archive → validate. No natural-language
|
|
232
443
|
interpretation happens in the CLI.
|
|
233
444
|
|
|
445
|
+
When the prompt is a bare "continue"/"prossiga"/"next" and a change is
|
|
446
|
+
already open, work suggests resuming it instead of opening a junk change
|
|
447
|
+
named after that word (review G1). Use --resume to do so directly.
|
|
448
|
+
|
|
234
449
|
Options:
|
|
235
450
|
--capability <cap> Pin the capability instead of ranking matches
|
|
236
451
|
--id <id> Override the derived change id
|
|
237
|
-
--
|
|
452
|
+
--resume <id> Reprint the playbook for an open change; create nothing
|
|
453
|
+
--from-diff Backfill: scaffold from the working-tree changes (no
|
|
454
|
+
prompt needed) and print a code-first playbook (F8)
|
|
455
|
+
--chore, --no-spec Open a spec-less chore change (infra/docs/build) with a
|
|
456
|
+
playbook that skips the spec-delta steps (F9)
|
|
457
|
+
--force Open a new change even when one is open, and overwrite
|
|
458
|
+
an existing change folder
|
|
238
459
|
`;
|
package/src/index.js
CHANGED
|
@@ -26,11 +26,17 @@ import * as coverage from "./commands/coverage.js";
|
|
|
26
26
|
import * as verify from "./commands/verify.js";
|
|
27
27
|
import * as contract from "./commands/contract.js";
|
|
28
28
|
import * as trace from "./commands/trace.js";
|
|
29
|
+
import * as status from "./commands/status.js";
|
|
30
|
+
import * as close from "./commands/close.js";
|
|
31
|
+
import * as review from "./commands/review.js";
|
|
32
|
+
import * as watch from "./commands/watch.js";
|
|
33
|
+
import * as why from "./commands/why.js";
|
|
29
34
|
|
|
30
35
|
const COMMANDS = {
|
|
31
36
|
init, spec, change, decision, validate, hooks, analyze, clarify,
|
|
32
37
|
templates, skill, index: indexCmd, next, metrics, context, search,
|
|
33
38
|
intake, work, coverage, verify, contract, trace,
|
|
39
|
+
status, close, review, watch, why,
|
|
34
40
|
};
|
|
35
41
|
|
|
36
42
|
const TOP_HELP = `
|
|
@@ -56,6 +62,7 @@ Commands:
|
|
|
56
62
|
skill new Scaffold an on-demand procedural memory skill
|
|
57
63
|
skill list List skills with their descriptions
|
|
58
64
|
skill sync Mirror skill frontmatter descriptions into index.json
|
|
65
|
+
skill suggest Surface fix-shaped lessons worth a skill (--write scaffolds)
|
|
59
66
|
analyze Inspect a change folder before applying
|
|
60
67
|
clarify Smell-test a Markdown file for ambiguity (--all for the tree)
|
|
61
68
|
context Print the context pack for a task in read order
|
|
@@ -63,12 +70,17 @@ Commands:
|
|
|
63
70
|
validate Run schema and structural checks
|
|
64
71
|
coverage Report acceptance criteria with linked evidence (--strict gates)
|
|
65
72
|
trace Report intent provenance: product intent → specs (--strict gates)
|
|
73
|
+
review Conformance review of your changes vs specs/ADRs/contracts
|
|
66
74
|
verify Run project-declared typecheck/test/build checks (the real gate)
|
|
75
|
+
close Run the whole close sequence for a change in one pass
|
|
76
|
+
status One-glance project health dashboard
|
|
77
|
+
why Explain a capability's provenance (intent → proof → ADRs)
|
|
67
78
|
templates list List the templates shipped by the installed CLI
|
|
68
79
|
templates check Compare the project against the recommended template shape
|
|
69
80
|
hooks install Install the pre-commit hook
|
|
70
81
|
index rebuild Regenerate index.json from the artifacts on disk
|
|
71
82
|
next Print the recommended next workflow actions
|
|
83
|
+
watch Re-run validate --fix + next on every change (--once for one pass)
|
|
72
84
|
metrics Local git-derived adoption metrics (no network)
|
|
73
85
|
|
|
74
86
|
Global flags:
|
|
@@ -78,7 +90,7 @@ Global flags:
|
|
|
78
90
|
|
|
79
91
|
async function main(argv) {
|
|
80
92
|
const { positional, flags } = parseArgs(argv, {
|
|
81
|
-
boolean: ["help", "h", "version", "v", "force", "non-interactive", "check", "save", "bug", "write", "all", "concat", "archive", "strict", "list", "init"],
|
|
93
|
+
boolean: ["help", "h", "version", "v", "force", "non-interactive", "check", "save", "bug", "write", "all", "concat", "archive", "strict", "list", "init", "fix", "once", "clean"],
|
|
82
94
|
});
|
|
83
95
|
|
|
84
96
|
if (flags.get("version") || flags.get("v")) {
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Structured spec operations (ADR 0007 / review F3). A MODIFIED spec delta
|
|
2
|
+
// historically carried prose ("flip criterion 2 to verified", "bump to
|
|
3
|
+
// 0.3.0") that a human merged by hand — the single most error-prone step in
|
|
4
|
+
// the change flow, and the source of index/version drift. These functions
|
|
5
|
+
// turn that prose into a small, declarative, mechanically applicable
|
|
6
|
+
// operation set so `change apply` can edit the spec and regenerate the index
|
|
7
|
+
// in one transaction. The verbs are deliberately narrow: they touch headers
|
|
8
|
+
// and acceptance-criteria markers, never arbitrary spec prose — semantic
|
|
9
|
+
// rewriting stays the agent's job (ADR 0005); only the bookkeeping is
|
|
10
|
+
// automated.
|
|
11
|
+
//
|
|
12
|
+
// Delta format — an optional fenced block inside a MODIFIED delta body:
|
|
13
|
+
//
|
|
14
|
+
// ```ops
|
|
15
|
+
// set-header Implementation: verified — durable adapter (`src/db.ts`)
|
|
16
|
+
// bump-version minor
|
|
17
|
+
// set-criterion 1: verified
|
|
18
|
+
// replace-criterion 2: [verified] new text — verified by `test/x.test.ts`
|
|
19
|
+
// append-criterion [unverified] new signal — verified by `test/y.test.ts`
|
|
20
|
+
// ```
|
|
21
|
+
//
|
|
22
|
+
// A `# comment` or blank line inside the block is ignored. When a delta has
|
|
23
|
+
// no ops block, apply falls back to the manual-merge pointer (backward
|
|
24
|
+
// compatible).
|
|
25
|
+
|
|
26
|
+
const HEADER_RE = (name) =>
|
|
27
|
+
new RegExp(`^(\\s*(?:-\\s+)?\\*\\*${escapeRe(name)}:\\*\\*)\\s*(.*)$`, "m");
|
|
28
|
+
|
|
29
|
+
function escapeRe(s) {
|
|
30
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Pull the operations out of a delta body. Returns an array of parsed ops
|
|
34
|
+
// (or an empty array when there is no `ops` block). Parsing is line-based and
|
|
35
|
+
// tolerant of CRLF.
|
|
36
|
+
export function extractOps(deltaText) {
|
|
37
|
+
const block = matchOpsBlock(deltaText);
|
|
38
|
+
if (block === null) return [];
|
|
39
|
+
const ops = [];
|
|
40
|
+
for (const raw of block.split(/\r?\n/)) {
|
|
41
|
+
const line = raw.trim();
|
|
42
|
+
if (line === "" || line.startsWith("#") || line.startsWith("<!--")) continue;
|
|
43
|
+
const op = parseOpLine(line);
|
|
44
|
+
ops.push(op); // includes { verb: null, error } for unknown lines
|
|
45
|
+
}
|
|
46
|
+
return ops;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Find the body of the first ```ops fenced block, or null if absent. The
|
|
50
|
+
// info string must be exactly "ops" (case-insensitive) so a normal code
|
|
51
|
+
// fence in the prose is never mistaken for operations.
|
|
52
|
+
function matchOpsBlock(text) {
|
|
53
|
+
const m = text.match(/^[ \t]*```[ \t]*ops[ \t]*\r?\n([\s\S]*?)^[ \t]*```[ \t]*$/m);
|
|
54
|
+
return m ? m[1] : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseOpLine(line) {
|
|
58
|
+
// `<verb> <rest>` — the verb is the first whitespace-delimited token.
|
|
59
|
+
const sp = line.indexOf(" ");
|
|
60
|
+
const verb = (sp < 0 ? line : line.slice(0, sp)).toLowerCase();
|
|
61
|
+
const rest = sp < 0 ? "" : line.slice(sp + 1).trim();
|
|
62
|
+
|
|
63
|
+
switch (verb) {
|
|
64
|
+
case "set-header": {
|
|
65
|
+
// set-header <Name>: <value>
|
|
66
|
+
const colon = rest.indexOf(":");
|
|
67
|
+
if (colon < 0) return { verb, error: `set-header needs "<Name>: <value>" (got "${rest}")` };
|
|
68
|
+
const name = rest.slice(0, colon).trim();
|
|
69
|
+
const value = rest.slice(colon + 1).trim();
|
|
70
|
+
if (!name) return { verb, error: "set-header is missing the header name" };
|
|
71
|
+
return { verb, name, value };
|
|
72
|
+
}
|
|
73
|
+
case "bump-version": {
|
|
74
|
+
const level = rest.toLowerCase();
|
|
75
|
+
if (!["major", "minor", "patch"].includes(level)) {
|
|
76
|
+
return { verb, error: `bump-version needs major|minor|patch (got "${rest}")` };
|
|
77
|
+
}
|
|
78
|
+
return { verb, level };
|
|
79
|
+
}
|
|
80
|
+
case "set-criterion":
|
|
81
|
+
case "replace-criterion": {
|
|
82
|
+
// <verb> <n>: <value>
|
|
83
|
+
const colon = rest.indexOf(":");
|
|
84
|
+
if (colon < 0) return { verb, error: `${verb} needs "<n>: <value>" (got "${rest}")` };
|
|
85
|
+
const n = Number(rest.slice(0, colon).trim());
|
|
86
|
+
const value = rest.slice(colon + 1).trim();
|
|
87
|
+
if (!Number.isInteger(n) || n < 1) return { verb, error: `${verb} needs a positive criterion number (got "${rest}")` };
|
|
88
|
+
if (!value) return { verb, error: `${verb} ${n} is missing a value` };
|
|
89
|
+
return { verb, n, value };
|
|
90
|
+
}
|
|
91
|
+
case "append-criterion": {
|
|
92
|
+
if (!rest) return { verb, error: "append-criterion is missing its text" };
|
|
93
|
+
return { verb, value: rest };
|
|
94
|
+
}
|
|
95
|
+
default:
|
|
96
|
+
return { verb: null, error: `unknown operation "${verb}"` };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Apply a parsed op list to a spec's Markdown text. Pure: returns
|
|
101
|
+
// { text, applied: string[], errors: string[] } and never throws. A failed
|
|
102
|
+
// op is recorded in `errors` and leaves the text unchanged for that op, so
|
|
103
|
+
// `change apply` can refuse to write a partially-mutated spec.
|
|
104
|
+
export function applyOps(specText, ops) {
|
|
105
|
+
let text = specText;
|
|
106
|
+
const applied = [];
|
|
107
|
+
const errors = [];
|
|
108
|
+
for (const op of ops) {
|
|
109
|
+
if (op.error || !op.verb) {
|
|
110
|
+
errors.push(op.error ?? "malformed operation");
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const res = applyOne(text, op);
|
|
114
|
+
if (res.error) errors.push(res.error);
|
|
115
|
+
else {
|
|
116
|
+
text = res.text;
|
|
117
|
+
applied.push(res.summary);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { text, applied, errors };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function applyOne(text, op) {
|
|
124
|
+
switch (op.verb) {
|
|
125
|
+
case "set-header":
|
|
126
|
+
return setHeader(text, op.name, op.value);
|
|
127
|
+
case "bump-version":
|
|
128
|
+
return bumpVersion(text, op.level);
|
|
129
|
+
case "set-criterion":
|
|
130
|
+
return setCriterionMark(text, op.n, op.value);
|
|
131
|
+
case "replace-criterion":
|
|
132
|
+
return replaceCriterion(text, op.n, op.value);
|
|
133
|
+
case "append-criterion":
|
|
134
|
+
return appendCriterion(text, op.value);
|
|
135
|
+
default:
|
|
136
|
+
return { error: `unknown operation "${op.verb}"` };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Replace the value of a `**Name:** ...` header line (dash-optional),
|
|
141
|
+
// preserving the original prefix. Errors when the header is absent — a
|
|
142
|
+
// set-header that silently does nothing is exactly the drift this prevents.
|
|
143
|
+
export function setHeader(text, name, value) {
|
|
144
|
+
const re = HEADER_RE(name);
|
|
145
|
+
if (!re.test(text)) {
|
|
146
|
+
return { error: `set-header: no "**${name}:**" header in the spec to set` };
|
|
147
|
+
}
|
|
148
|
+
const next = text.replace(re, (_m, prefix) => `${prefix} ${value}`.replace(/\s+$/, ""));
|
|
149
|
+
return { text: next, summary: `set ${name}: ${value}` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function bumpVersion(text, level) {
|
|
153
|
+
const re = HEADER_RE("Version");
|
|
154
|
+
const m = text.match(re);
|
|
155
|
+
if (!m) return { error: "bump-version: no **Version:** header in the spec" };
|
|
156
|
+
const cur = (m[2] ?? "").trim();
|
|
157
|
+
const parts = cur.split(".").map((x) => Number(x));
|
|
158
|
+
if (parts.length !== 3 || parts.some((x) => !Number.isInteger(x))) {
|
|
159
|
+
return { error: `bump-version: current version "${cur}" is not semver X.Y.Z` };
|
|
160
|
+
}
|
|
161
|
+
let [maj, min, pat] = parts;
|
|
162
|
+
if (level === "major") { maj += 1; min = 0; pat = 0; }
|
|
163
|
+
else if (level === "minor") { min += 1; pat = 0; }
|
|
164
|
+
else { pat += 1; }
|
|
165
|
+
const next = `${maj}.${min}.${pat}`;
|
|
166
|
+
const out = text.replace(re, (_m, prefix) => `${prefix} ${next}`);
|
|
167
|
+
return { text: out, summary: `bump-version ${level}: ${cur} -> ${next}` };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The acceptance-criteria block: lines under "## Acceptance criteria" that
|
|
171
|
+
// begin with "<n>. ". Returns { start, end, items: [{ n, lineIndex, line }] }
|
|
172
|
+
// where lines is the full split array. Null when the section is absent.
|
|
173
|
+
function locateCriteria(text) {
|
|
174
|
+
const lines = text.split(/\r?\n/);
|
|
175
|
+
let inSection = false;
|
|
176
|
+
let sectionStart = -1;
|
|
177
|
+
const items = [];
|
|
178
|
+
for (let i = 0; i < lines.length; i++) {
|
|
179
|
+
const line = lines[i];
|
|
180
|
+
if (/^##\s+/.test(line)) {
|
|
181
|
+
if (inSection) { inSection = false; }
|
|
182
|
+
if (/^##\s+Acceptance criteria\b/i.test(line)) { inSection = true; sectionStart = i; }
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (!inSection) continue;
|
|
186
|
+
const m = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
|
|
187
|
+
if (m) items.push({ n: Number(m[2]), lineIndex: i, indent: m[1] });
|
|
188
|
+
}
|
|
189
|
+
if (sectionStart < 0) return null;
|
|
190
|
+
return { lines, items };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// set-criterion <n>: <mark> — replace the [mark] token on criterion n.
|
|
194
|
+
// Accepts "verified"/"[verified]"; if the criterion has no [..] token, one
|
|
195
|
+
// is inserted right after the "n. " prefix.
|
|
196
|
+
export function setCriterionMark(text, n, markRaw) {
|
|
197
|
+
const loc = locateCriteria(text);
|
|
198
|
+
if (!loc) return { error: "set-criterion: spec has no '## Acceptance criteria' section" };
|
|
199
|
+
const item = loc.items.find((it) => it.n === n);
|
|
200
|
+
if (!item) return { error: `set-criterion: no criterion #${n} found` };
|
|
201
|
+
const mark = markRaw.replace(/^\[|\]$/g, "").trim();
|
|
202
|
+
const line = loc.lines[item.lineIndex];
|
|
203
|
+
let next;
|
|
204
|
+
if (/\[[^\]]*\]/.test(line)) {
|
|
205
|
+
next = line.replace(/\[[^\]]*\]/, `[${mark}]`);
|
|
206
|
+
} else {
|
|
207
|
+
next = line.replace(/^(\s*\d+\.\s+)/, `$1[${mark}] `);
|
|
208
|
+
}
|
|
209
|
+
loc.lines[item.lineIndex] = next;
|
|
210
|
+
return { text: loc.lines.join("\n"), summary: `set-criterion ${n}: ${mark}` };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// replace-criterion <n>: <text> — replace the prose of criterion n (keeping
|
|
214
|
+
// its "n. " numbering).
|
|
215
|
+
export function replaceCriterion(text, n, value) {
|
|
216
|
+
const loc = locateCriteria(text);
|
|
217
|
+
if (!loc) return { error: "replace-criterion: spec has no '## Acceptance criteria' section" };
|
|
218
|
+
const item = loc.items.find((it) => it.n === n);
|
|
219
|
+
if (!item) return { error: `replace-criterion: no criterion #${n} found` };
|
|
220
|
+
loc.lines[item.lineIndex] = `${item.indent}${n}. ${value}`;
|
|
221
|
+
return { text: loc.lines.join("\n"), summary: `replace-criterion ${n}` };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// append-criterion <text> — add a new numbered criterion after the last one
|
|
225
|
+
// in the section.
|
|
226
|
+
export function appendCriterion(text, value) {
|
|
227
|
+
const loc = locateCriteria(text);
|
|
228
|
+
if (!loc) return { error: "append-criterion: spec has no '## Acceptance criteria' section" };
|
|
229
|
+
if (loc.items.length === 0) {
|
|
230
|
+
return { error: "append-criterion: no existing numbered criterion to append after" };
|
|
231
|
+
}
|
|
232
|
+
const last = loc.items[loc.items.length - 1];
|
|
233
|
+
const nextN = Math.max(...loc.items.map((it) => it.n)) + 1;
|
|
234
|
+
loc.lines.splice(last.lineIndex + 1, 0, `${last.indent}${nextN}. ${value}`);
|
|
235
|
+
return { text: loc.lines.join("\n"), summary: `append-criterion ${nextN}` };
|
|
236
|
+
}
|