doctrina-cli 0.3.0 → 0.6.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 +48 -11
- package/package.json +1 -1
- package/src/commands/change.js +131 -29
- package/src/commands/coverage.js +73 -20
- package/src/commands/decision.js +83 -1
- package/src/commands/index-rebuild.js +9 -0
- package/src/commands/init.js +7 -1
- package/src/commands/intake.js +16 -1
- package/src/commands/search.js +42 -7
- package/src/commands/spec.js +92 -6
- package/src/commands/trace.js +157 -0
- package/src/commands/validate.js +140 -34
- package/src/commands/verify.js +152 -2
- package/src/commands/work.js +243 -19
- package/src/index.js +6 -15
- package/src/lib/clarity.js +49 -0
- package/src/lib/index-json.js +8 -2
- package/src/lib/scan.js +8 -0
- package/src/lib/spec-ops.js +236 -0
- package/src/lib/version.js +21 -0
- package/templates/change/spec-delta.md.template +13 -2
- package/templates/decision.md.template +8 -0
- package/templates/doctrina/index.json.template +1 -1
- package/templates/doctrina/product.md.template +6 -1
- package/templates/spec.md.template +4 -0
package/src/commands/work.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
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";
|
|
9
|
+
import { assessBrief } from "../lib/clarity.js";
|
|
7
10
|
import { changeNew } from "./change.js";
|
|
8
11
|
|
|
9
12
|
// `work` is the second half of the no-ceremony path (ADR 0005): a brief
|
|
@@ -16,15 +19,49 @@ import { changeNew } from "./change.js";
|
|
|
16
19
|
|
|
17
20
|
export async function run(positional, flags) {
|
|
18
21
|
const prompt = positional.join(" ").trim();
|
|
19
|
-
|
|
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) {
|
|
20
49
|
console.error(c.red("error:") + " work requires a prompt (quote it if it contains spaces)");
|
|
21
50
|
console.error(c.gray("hint: ") + "example: doctrina work \"add login with email and password\"");
|
|
22
51
|
return 2;
|
|
23
52
|
}
|
|
24
53
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
}
|
|
28
65
|
}
|
|
29
66
|
|
|
30
67
|
const pinned = flagString(flags, "capability");
|
|
@@ -33,37 +70,74 @@ export async function run(positional, flags) {
|
|
|
33
70
|
return 2;
|
|
34
71
|
}
|
|
35
72
|
|
|
36
|
-
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}`;
|
|
37
76
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
|
|
38
77
|
console.error(c.red("error:") + ` invalid change id "${id}" (lowercase letters, digits, hyphens)`);
|
|
39
78
|
return 2;
|
|
40
79
|
}
|
|
41
80
|
|
|
42
|
-
const code = changeNew([id,
|
|
81
|
+
const code = changeNew([id, effPrompt], flags);
|
|
43
82
|
if (code !== 0) return code;
|
|
44
83
|
|
|
45
84
|
// Record the prompt verbatim as the change's Why — the raw intent has
|
|
46
|
-
// 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.
|
|
47
87
|
const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
|
|
48
88
|
if (isFile(proposalPath)) {
|
|
49
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
|
+
}
|
|
50
96
|
// CRLF-tolerant: templates may be checked out with \r\n on Windows.
|
|
51
|
-
let updated = txt.replace(/(## Why\r?\n\r?\n)<!--[\s\S]*?-->[ \t]*\r?\n?/, `$1${
|
|
52
|
-
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`);
|
|
53
99
|
if (pinned) {
|
|
54
100
|
updated = updated.replace(/^(-\s+\*\*Affects specs:\*\*).*$/m, `$1 ${pinned}`);
|
|
55
101
|
}
|
|
56
102
|
if (updated !== txt) write(proposalPath, updated, { force: true });
|
|
57
103
|
}
|
|
58
104
|
|
|
59
|
-
|
|
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));
|
|
60
110
|
const capability = pinned ?? matches[0]?.id ?? null;
|
|
111
|
+
const clarity = assessBrief(effPrompt, { kind: "prompt" });
|
|
61
112
|
|
|
62
113
|
console.log("");
|
|
63
|
-
|
|
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
|
+
}
|
|
64
122
|
return 0;
|
|
65
123
|
}
|
|
66
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
|
+
|
|
67
141
|
// Next sequential NNNN across open changes and the archive, so work-driven
|
|
68
142
|
// ids stay ordered no matter how earlier changes were named.
|
|
69
143
|
function nextChangeNumber(projectRoot) {
|
|
@@ -89,6 +163,70 @@ function nextChangeNumber(projectRoot) {
|
|
|
89
163
|
return String(max + 1).padStart(4, "0");
|
|
90
164
|
}
|
|
91
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
|
+
|
|
92
230
|
// ASCII-folded kebab-case of the prompt ("Faça o login" -> "faca-o-login").
|
|
93
231
|
export function slugify(text) {
|
|
94
232
|
let slug = text
|
|
@@ -150,23 +288,93 @@ export function rankCapabilities(projectRoot, prompt) {
|
|
|
150
288
|
return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, 3);
|
|
151
289
|
}
|
|
152
290
|
|
|
153
|
-
|
|
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
|
+
}
|
|
337
|
+
|
|
338
|
+
function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity, fromDiff = false, diffMatches = [] }) {
|
|
154
339
|
const capToken = capability ?? "<capability>";
|
|
155
|
-
|
|
340
|
+
const title = fromDiff ? "Backfill playbook" : "Work playbook";
|
|
341
|
+
console.log(c.bold(`${title} — change ${id}`) + c.gray(" — agent-executed (ADR 0005)."));
|
|
156
342
|
console.log("");
|
|
157
343
|
console.log(`Prompt: "${prompt}"`);
|
|
344
|
+
if (fromDiff) {
|
|
345
|
+
console.log(c.gray("Code-first: write the spec that describes what the working tree already does."));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Clarification gate (review Topic A): a thin prompt is the moment to ask
|
|
349
|
+
// the user, not to invent a spec. Advisory — the change is still scaffolded
|
|
350
|
+
// (it is a draft), but the agent is told to resolve the gaps first.
|
|
351
|
+
if (clarity?.thin && !fromDiff) {
|
|
352
|
+
console.log("");
|
|
353
|
+
console.log(c.yellow("⚠ thin prompt — clarify with the user before writing spec deltas:"));
|
|
354
|
+
for (const r of clarity.reasons) console.log(` - ${r}`);
|
|
355
|
+
}
|
|
158
356
|
|
|
159
357
|
if (pinned) {
|
|
160
358
|
const hasSpec = isFile(path.join(projectRoot, ".doctrina", "specs", pinned, "spec.md"));
|
|
161
359
|
console.log(`Capability (pinned): ${c.cyan(pinned)}` +
|
|
162
360
|
(hasSpec ? "" : c.yellow(" — no spec yet; create it in step 2")));
|
|
163
361
|
} else if (matches.length > 0) {
|
|
164
|
-
|
|
362
|
+
const how = fromDiff ? "touched by your working tree" : "deterministic term match";
|
|
363
|
+
console.log(`Likely capabilities (${how} — a hint, not a decision):`);
|
|
165
364
|
for (const m of matches) {
|
|
166
365
|
console.log(` ${c.cyan(m.id.padEnd(20))} score ${String(m.score).padStart(3)} ${c.gray(m.path)}`);
|
|
167
366
|
}
|
|
168
367
|
} else {
|
|
169
|
-
console.log(c.gray(
|
|
368
|
+
console.log(c.gray(fromDiff
|
|
369
|
+
? "No existing spec matches the changed files — likely a new capability."
|
|
370
|
+
: "No existing spec matches the prompt — likely a new capability."));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Extra signal (review F10): even for a prompt-driven change, show which
|
|
374
|
+
// capabilities the working tree touched — often the truer hint.
|
|
375
|
+
if (!fromDiff && diffMatches.length > 0) {
|
|
376
|
+
console.log(c.gray("Also touched by your working tree: ") +
|
|
377
|
+
diffMatches.map((m) => c.cyan(m.id)).join(", "));
|
|
170
378
|
}
|
|
171
379
|
|
|
172
380
|
console.log("");
|
|
@@ -187,6 +395,11 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability })
|
|
|
187
395
|
console.log(c.gray(" <EARS body. Keep the two axes honest (Status vs Implementation),"));
|
|
188
396
|
console.log(c.gray(" keep aspiration under ## Maturity → Future, and cite the proof per"));
|
|
189
397
|
console.log(c.gray(" criterion: \"1. [unverified] <signal> — verified by `path/to/test`\">"));
|
|
398
|
+
if (fromDiff) {
|
|
399
|
+
console.log(c.gray(" --from-diff: the code already exists — describe its CURRENT behaviour, and"));
|
|
400
|
+
console.log(c.gray(" mark each criterion [unverified] until a test proves it (don't assume the"));
|
|
401
|
+
console.log(c.gray(" diff is tested). The changed files are listed in the proposal's ## Why."));
|
|
402
|
+
}
|
|
190
403
|
console.log("");
|
|
191
404
|
console.log(`4. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
|
|
192
405
|
console.log(" with small, checkable implementation tasks (a few hours each, max).");
|
|
@@ -196,7 +409,7 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability })
|
|
|
196
409
|
console.log(" If the prompt is genuinely ambiguous, ask the user before assuming.");
|
|
197
410
|
console.log("");
|
|
198
411
|
console.log(`6. ${c.cyan(`doctrina analyze ${id}`)} — fix every ✗ before applying.`);
|
|
199
|
-
console.log(`7. ${c.cyan(`doctrina change apply ${id}`)} (MODIFIED
|
|
412
|
+
console.log(`7. ${c.cyan(`doctrina change apply ${id}`)} (MODIFIED with an ops block applies mechanically; prose merges by hand).`);
|
|
200
413
|
console.log("8. Prove it before declaring done (archive refuses unchecked boxes):");
|
|
201
414
|
console.log(` ${c.cyan("doctrina verify")} — the project's typecheck/test/build gate`);
|
|
202
415
|
console.log(` ${c.cyan("doctrina coverage")} — each acceptance criterion cites a real test`);
|
|
@@ -214,13 +427,24 @@ Turn a brief prompt into a scaffolded change plus the work playbook the
|
|
|
214
427
|
host AI agent executes (see ADR 0005). The CLI derives a sequential
|
|
215
428
|
change id (NNNN-<slug>) from the prompt, opens the change folder,
|
|
216
429
|
records the prompt under the proposal's "## Why", ranks existing specs
|
|
217
|
-
by term overlap as a capability hint,
|
|
430
|
+
by term overlap as a capability hint, flags a thin/under-specified prompt
|
|
431
|
+
so you clarify before writing deltas (advisory), and prints the steps:
|
|
218
432
|
context → spec delta → tasks → implement → analyze → apply → verify
|
|
219
433
|
(verify + coverage) → archive → validate. No natural-language
|
|
220
434
|
interpretation happens in the CLI.
|
|
221
435
|
|
|
436
|
+
When the prompt is a bare "continue"/"prossiga"/"next" and a change is
|
|
437
|
+
already open, work suggests resuming it instead of opening a junk change
|
|
438
|
+
named after that word (review G1). Use --resume to do so directly.
|
|
439
|
+
|
|
222
440
|
Options:
|
|
223
441
|
--capability <cap> Pin the capability instead of ranking matches
|
|
224
442
|
--id <id> Override the derived change id
|
|
225
|
-
--
|
|
443
|
+
--resume <id> Reprint the playbook for an open change; create nothing
|
|
444
|
+
--from-diff Backfill: scaffold from the working-tree changes (no
|
|
445
|
+
prompt needed) and print a code-first playbook (F8)
|
|
446
|
+
--chore, --no-spec Open a spec-less chore change (infra/docs/build) with a
|
|
447
|
+
playbook that skips the spec-delta steps (F9)
|
|
448
|
+
--force Open a new change even when one is open, and overwrite
|
|
449
|
+
an existing change folder
|
|
226
450
|
`;
|
package/src/index.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { readFileSync } from "node:fs";
|
|
6
3
|
import { parseArgs } from "./lib/args.js";
|
|
7
4
|
import { c } from "./lib/colors.js";
|
|
8
5
|
import { suggest } from "./lib/suggest.js";
|
|
6
|
+
import { cliVersion } from "./lib/version.js";
|
|
9
7
|
|
|
10
8
|
import * as init from "./commands/init.js";
|
|
11
9
|
import * as spec from "./commands/spec.js";
|
|
@@ -27,11 +25,12 @@ import * as work from "./commands/work.js";
|
|
|
27
25
|
import * as coverage from "./commands/coverage.js";
|
|
28
26
|
import * as verify from "./commands/verify.js";
|
|
29
27
|
import * as contract from "./commands/contract.js";
|
|
28
|
+
import * as trace from "./commands/trace.js";
|
|
30
29
|
|
|
31
30
|
const COMMANDS = {
|
|
32
31
|
init, spec, change, decision, validate, hooks, analyze, clarify,
|
|
33
32
|
templates, skill, index: indexCmd, next, metrics, context, search,
|
|
34
|
-
intake, work, coverage, verify, contract,
|
|
33
|
+
intake, work, coverage, verify, contract, trace,
|
|
35
34
|
};
|
|
36
35
|
|
|
37
36
|
const TOP_HELP = `
|
|
@@ -51,6 +50,7 @@ Commands:
|
|
|
51
50
|
contract check Verify port collisions, env drift, referenced specs
|
|
52
51
|
decision new Create the next sequentially numbered ADR
|
|
53
52
|
decision accept Flip a proposed ADR to accepted
|
|
53
|
+
decision land Record that an accepted ADR is now implemented (non-mutating)
|
|
54
54
|
decision supersede Create a new ADR that supersedes an existing one
|
|
55
55
|
decision list List ADRs with status, date, and title
|
|
56
56
|
skill new Scaffold an on-demand procedural memory skill
|
|
@@ -62,6 +62,7 @@ Commands:
|
|
|
62
62
|
search Search the artifact tree, grouped by category
|
|
63
63
|
validate Run schema and structural checks
|
|
64
64
|
coverage Report acceptance criteria with linked evidence (--strict gates)
|
|
65
|
+
trace Report intent provenance: product intent → specs (--strict gates)
|
|
65
66
|
verify Run project-declared typecheck/test/build checks (the real gate)
|
|
66
67
|
templates list List the templates shipped by the installed CLI
|
|
67
68
|
templates check Compare the project against the recommended template shape
|
|
@@ -81,7 +82,7 @@ async function main(argv) {
|
|
|
81
82
|
});
|
|
82
83
|
|
|
83
84
|
if (flags.get("version") || flags.get("v")) {
|
|
84
|
-
console.log(
|
|
85
|
+
console.log(cliVersion());
|
|
85
86
|
return 0;
|
|
86
87
|
}
|
|
87
88
|
|
|
@@ -119,14 +120,4 @@ async function main(argv) {
|
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
function readVersion() {
|
|
123
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
124
|
-
const pkgPath = path.resolve(here, "..", "package.json");
|
|
125
|
-
try {
|
|
126
|
-
return JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0";
|
|
127
|
-
} catch {
|
|
128
|
-
return "0.0.0";
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
123
|
main(process.argv.slice(2)).then((code) => process.exit(code ?? 0));
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Deterministic under-specification detector for free-text briefs and
|
|
2
|
+
// intakes — the clarification gate (review Topic A). It does NOT judge
|
|
3
|
+
// meaning (that stays the agent's job, ADR 0005); it flags input that is
|
|
4
|
+
// structurally too thin to spec from, so `intake` and `work` prompt the
|
|
5
|
+
// agent to clarify with the user before speculating a spec or change out of
|
|
6
|
+
// too little. Advisory only: it surfaces the gap, it does not block.
|
|
7
|
+
|
|
8
|
+
const WEASEL = /\b(might|could|perhaps|maybe|probably|somehow|approximately|roughly|etc)\b/gi;
|
|
9
|
+
const VAGUE = /\b(some|several|various|appropriate|robust|scalable|nice|good|better|fast|simple|stuff|things?)\b/gi;
|
|
10
|
+
|
|
11
|
+
// Generic verbs/nouns that carry no capability information on their own. A
|
|
12
|
+
// brief made only of these ("add the feature", "do the thing") names nothing
|
|
13
|
+
// concrete to build.
|
|
14
|
+
const FILLER = new Set([
|
|
15
|
+
"the", "and", "for", "with", "this", "that", "something", "anything",
|
|
16
|
+
"make", "add", "create", "build", "implement", "feature", "thing",
|
|
17
|
+
"things", "stuff", "task", "work", "please", "just", "some", "any",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
// Assess a brief/intake. `kind` tunes the thresholds: a one-line work prompt
|
|
21
|
+
// is held to a lower bar than a full project intake.
|
|
22
|
+
export function assessBrief(text, { kind = "prompt" } = {}) {
|
|
23
|
+
const clean = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
24
|
+
const tokens = clean ? clean.split(" ") : [];
|
|
25
|
+
const words = tokens.length;
|
|
26
|
+
const vague = (clean.match(VAGUE) ?? []).length + (clean.match(WEASEL) ?? []).length;
|
|
27
|
+
const meaningful = tokens
|
|
28
|
+
.map((t) => t.toLowerCase().replace(/[^a-z0-9]/g, ""))
|
|
29
|
+
.filter((t) => t.length >= 3 && !FILLER.has(t));
|
|
30
|
+
|
|
31
|
+
const minWords = kind === "intake" ? 20 : 3;
|
|
32
|
+
const maxVague = kind === "intake" ? 4 : 2;
|
|
33
|
+
|
|
34
|
+
const reasons = [];
|
|
35
|
+
if (words < minWords) {
|
|
36
|
+
reasons.push(
|
|
37
|
+
`only ${words} word${words === 1 ? "" : "s"} — say what the behaviour is, ` +
|
|
38
|
+
`what the inputs are, and how you'll know it's done`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (words > 0 && meaningful.length === 0) {
|
|
42
|
+
reasons.push("no concrete terms once filler words are removed — name the actual capability or behaviour");
|
|
43
|
+
}
|
|
44
|
+
if (vague >= maxVague) {
|
|
45
|
+
reasons.push(`${vague} vague/weasel term${vague === 1 ? "" : "s"} — replace with definite, checkable wording`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { thin: reasons.length > 0, words, reasons };
|
|
49
|
+
}
|
package/src/lib/index-json.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { read, write, exists } from "./fs-ops.js";
|
|
3
|
+
import { cliVersion } from "./version.js";
|
|
3
4
|
|
|
4
|
-
const SCHEMA_VERSION = "0.1.0";
|
|
5
|
+
export const SCHEMA_VERSION = "0.1.0";
|
|
5
6
|
|
|
6
7
|
export function indexPath(projectRoot) {
|
|
7
8
|
return path.join(projectRoot, ".doctrina", "index.json");
|
|
@@ -21,6 +22,11 @@ export function load(projectRoot) {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export function save(projectRoot, index) {
|
|
25
|
+
// Stamp the managing framework version on every write (3.6): the index
|
|
26
|
+
// records which CLI last wrote it, so a stale stamp (an index written by an
|
|
27
|
+
// older CLI) is detectable by `doctrina validate` and migrated by
|
|
28
|
+
// `doctrina index rebuild`.
|
|
29
|
+
index.framework_version = cliVersion();
|
|
24
30
|
const p = indexPath(projectRoot);
|
|
25
31
|
const text = JSON.stringify(index, null, 2) + "\n";
|
|
26
32
|
write(p, text, { force: true });
|
|
@@ -30,7 +36,7 @@ export function blank(projectName, date) {
|
|
|
30
36
|
return {
|
|
31
37
|
$schema_version: SCHEMA_VERSION,
|
|
32
38
|
project: projectName,
|
|
33
|
-
framework_version:
|
|
39
|
+
framework_version: cliVersion(),
|
|
34
40
|
last_updated: date,
|
|
35
41
|
artifacts: {
|
|
36
42
|
product: {
|
package/src/lib/scan.js
CHANGED
|
@@ -81,6 +81,12 @@ export function deriveIndex(projectRoot, current) {
|
|
|
81
81
|
const implRaw = specHeader(text, "Implementation");
|
|
82
82
|
const impl = implRaw ? implRaw.trim() : prev?.implementation;
|
|
83
83
|
if (impl) entry.implementation = impl;
|
|
84
|
+
// Realizes: the product intent anchors this capability delivers (ADR
|
|
85
|
+
// 0006). Stored as an id list so the provenance link is queryable, not
|
|
86
|
+
// just prose. Header wins over the previous index, like Implementation.
|
|
87
|
+
const realizesRaw = specHeader(text, "Realizes");
|
|
88
|
+
const realizes = realizesRaw ? (realizesRaw.match(/[A-Z]+\d+/g) ?? []) : prev?.realizes;
|
|
89
|
+
if (realizes && realizes.length) entry.realizes = realizes;
|
|
84
90
|
out.artifacts.specs.push(entry);
|
|
85
91
|
}
|
|
86
92
|
|
|
@@ -104,6 +110,8 @@ export function deriveIndex(projectRoot, current) {
|
|
|
104
110
|
if (supersedes && supersedes !== "—") entry.supersedes = supersedes;
|
|
105
111
|
const supersededBy = listHeader(text, "Superseded by");
|
|
106
112
|
if (supersededBy && supersededBy !== "—") entry.superseded_by = supersededBy;
|
|
113
|
+
const landed = listHeader(text, "Landed");
|
|
114
|
+
if (landed && landed !== "—") entry.landed = landed;
|
|
107
115
|
out.artifacts.decisions.push(entry);
|
|
108
116
|
}
|
|
109
117
|
|