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
package/package.json
CHANGED
package/src/commands/analyze.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
4
4
|
import { c } from "../lib/colors.js";
|
|
5
|
+
import { isUntouchedScaffold } from "./change.js";
|
|
5
6
|
|
|
6
7
|
export async function run(positional, _flags) {
|
|
7
8
|
const id = positional[0];
|
|
@@ -40,6 +41,16 @@ export async function run(positional, _flags) {
|
|
|
40
41
|
} else {
|
|
41
42
|
results.push(pass("tasks.md present"));
|
|
42
43
|
const text = read(tasksPath);
|
|
44
|
+
// Scaffold placeholders never replaced ("- [ ]" with no text, checked or
|
|
45
|
+
// not) mean the change was OPENED but never PLANNED — the agent went
|
|
46
|
+
// straight to implementing on a hollow change and nothing barked
|
|
47
|
+
// (operator report 2026-07-19). A hard failure here blocks `change
|
|
48
|
+
// check` and `close` until the plan is real; ticking an empty box does
|
|
49
|
+
// not help, `change tick` refuses those too.
|
|
50
|
+
const placeholders = (text.match(/^\s*-\s*\[[ xX]\]\s*$/gm) ?? []).length;
|
|
51
|
+
if (placeholders > 0) {
|
|
52
|
+
results.push(fail(`tasks.md still carries ${placeholders} scaffold placeholder task${placeholders === 1 ? "" : "s"} ("- [ ]" with no text) — plan the change before implementing: replace them with real tasks (or delete the lines)`));
|
|
53
|
+
}
|
|
43
54
|
if (/^\s*-\s*\[\s\]/m.test(text)) results.push(pass("tasks.md has at least one unchecked task"));
|
|
44
55
|
else results.push(info("tasks.md has no unchecked tasks (already done?)"));
|
|
45
56
|
}
|
|
@@ -74,8 +85,13 @@ export async function run(positional, _flags) {
|
|
|
74
85
|
const targetSpec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
|
|
75
86
|
const targetRel = relPath(projectRoot, targetSpec);
|
|
76
87
|
if (op === "ADDED") {
|
|
77
|
-
|
|
78
|
-
|
|
88
|
+
// Mirrors `change apply`: an existing target that is still the
|
|
89
|
+
// untouched `spec new` scaffold is the canonical flow (spec new →
|
|
90
|
+
// ADDED delta), so it passes as a replacement; only real content fails.
|
|
91
|
+
if (exists(targetSpec) && !isUntouchedScaffold(read(targetSpec), cap)) {
|
|
92
|
+
results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first`, " "));
|
|
93
|
+
} else if (exists(targetSpec)) {
|
|
94
|
+
results.push(pass(` ${cap} (ADDED) → ${targetRel} (replaces the untouched scaffold)`, " "));
|
|
79
95
|
} else {
|
|
80
96
|
results.push(pass(` ${cap} (ADDED) → ${targetRel} (new)`, " "));
|
|
81
97
|
}
|
package/src/commands/change.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import { exists, isDir, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
|
|
3
|
+
import { exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
|
|
4
4
|
import { diffLines, formatUnified } from "../lib/diff.js";
|
|
5
5
|
import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "../lib/templates.js";
|
|
6
6
|
import * as idx from "../lib/index-json.js";
|
|
7
7
|
import { deriveIndex } from "../lib/scan.js";
|
|
8
8
|
import { extractOps, applyOps } from "../lib/spec-ops.js";
|
|
9
|
+
import { printAdrCheckpoint } from "../lib/adr-guard.js";
|
|
9
10
|
import { today } from "../lib/dates.js";
|
|
10
11
|
import { flagBool, flagString } from "../lib/args.js";
|
|
11
12
|
import { c } from "../lib/colors.js";
|
|
12
13
|
import { suggest } from "../lib/suggest.js";
|
|
13
14
|
|
|
14
|
-
const SUBCOMMANDS = ["new", "apply", "archive", "diff", "abandon"];
|
|
15
|
+
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "diff", "abandon"];
|
|
15
16
|
|
|
16
17
|
export async function run(positional, flags) {
|
|
17
18
|
const sub = positional[0];
|
|
@@ -19,9 +20,13 @@ export async function run(positional, flags) {
|
|
|
19
20
|
case "new":
|
|
20
21
|
return changeNew(positional.slice(1), flags);
|
|
21
22
|
case "apply":
|
|
22
|
-
return
|
|
23
|
+
return forEachId(positional.slice(1), "apply", (id) => changeApply([id], flags));
|
|
23
24
|
case "archive":
|
|
24
|
-
return
|
|
25
|
+
return forEachId(positional.slice(1), "archive", (id) => changeArchive([id], flags));
|
|
26
|
+
case "check":
|
|
27
|
+
return forEachId(positional.slice(1), "check", (id) => changeCheck(id));
|
|
28
|
+
case "tick":
|
|
29
|
+
return changeTick(positional.slice(1), flags);
|
|
25
30
|
case "diff":
|
|
26
31
|
return changeDiff(positional.slice(1), flags);
|
|
27
32
|
case "abandon":
|
|
@@ -36,6 +41,40 @@ export async function run(positional, flags) {
|
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
// Batch driver (operator review 2026-07-19 §3.5/§4.5): apply/archive/check
|
|
45
|
+
// accept multiple ids so a backlog closes without a bash loop. Each id runs
|
|
46
|
+
// independently — one failure does not stop the rest — and the exit code is
|
|
47
|
+
// the worst one seen. `new` and `abandon` stay single-id on purpose (creation
|
|
48
|
+
// wants a title; abandonment is destructive and deserves one deliberate call).
|
|
49
|
+
async function forEachId(ids, name, one) {
|
|
50
|
+
if (ids.length === 0) {
|
|
51
|
+
console.error(c.red("error:") + ` change ${name} requires at least one <id>`);
|
|
52
|
+
return 2;
|
|
53
|
+
}
|
|
54
|
+
let worst = 0;
|
|
55
|
+
for (const id of ids) {
|
|
56
|
+
if (ids.length > 1) {
|
|
57
|
+
console.log("");
|
|
58
|
+
console.log(c.bold(`──── change ${name} ${id}`));
|
|
59
|
+
}
|
|
60
|
+
let code;
|
|
61
|
+
try {
|
|
62
|
+
code = await one(id);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error(c.red("error:") + ` ${err.message}`);
|
|
65
|
+
code = 1;
|
|
66
|
+
}
|
|
67
|
+
worst = Math.max(worst, code);
|
|
68
|
+
}
|
|
69
|
+
if (ids.length > 1) {
|
|
70
|
+
console.log("");
|
|
71
|
+
console.log(worst === 0
|
|
72
|
+
? c.green("ok") + ` all ${ids.length} changes passed ${name}`
|
|
73
|
+
: c.red("fail") + ` at least one change failed ${name}`);
|
|
74
|
+
}
|
|
75
|
+
return worst;
|
|
76
|
+
}
|
|
77
|
+
|
|
39
78
|
function changeNew(args, flags) {
|
|
40
79
|
const id = args[0];
|
|
41
80
|
const title = args.slice(1).join(" ").trim();
|
|
@@ -67,9 +106,15 @@ function changeNew(args, flags) {
|
|
|
67
106
|
const date = today();
|
|
68
107
|
const tokens = { CHANGE_ID: id, CHANGE_TITLE: title, DATE: date, CAPABILITY: "" };
|
|
69
108
|
|
|
109
|
+
// design.md is opt-in (--design): in practice it scaffolded on every change
|
|
110
|
+
// and stayed empty — nine changes out of nine in the 0.11.0 field review. A
|
|
111
|
+
// change that needs a design doc asks for one; the rest stop carrying a
|
|
112
|
+
// blank file through apply/archive/ledger.
|
|
113
|
+
const wantDesign = flagBool(flags, "design", false);
|
|
70
114
|
const tree = loadTemplateTree(templatesDir, "change");
|
|
71
115
|
for (const entry of tree) {
|
|
72
116
|
if (entry.relativePath === "spec-delta.md.template") continue;
|
|
117
|
+
if (entry.relativePath === "design.md.template" && !wantDesign) continue;
|
|
73
118
|
const written = materialiseEntry(entry, changeDir, tokens, { force });
|
|
74
119
|
console.log(c.green("created") + ` ${relPath(projectRoot, written)}`);
|
|
75
120
|
}
|
|
@@ -139,15 +184,24 @@ function changeApply(args, _flags) {
|
|
|
139
184
|
const targetPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
|
|
140
185
|
|
|
141
186
|
if (op === "ADDED") {
|
|
142
|
-
|
|
143
|
-
|
|
187
|
+
// The canonical new-capability flow is `spec new <cap>` (which creates
|
|
188
|
+
// the file) → write the ADDED delta — so "target already exists" is the
|
|
189
|
+
// NORMAL case, not a conflict. When the existing spec is still the
|
|
190
|
+
// untouched `spec new` scaffold, ADDED means "replace the placeholder
|
|
191
|
+
// with the real spec" and applies as a whole-file write. Only a spec
|
|
192
|
+
// with real content refuses, since overwriting it would destroy truth.
|
|
193
|
+
if (exists(targetPath) && !isUntouchedScaffold(read(targetPath), capability)) {
|
|
194
|
+
console.error(c.red("error:") + ` ADDED delta targets existing spec ${relPath(projectRoot, targetPath)} with real content — refusing to overwrite`);
|
|
195
|
+
console.error(c.gray("hint: ") + "use a MODIFIED delta (with an ```ops block for bookkeeping edits), or REMOVE the spec first if it is truly being replaced");
|
|
144
196
|
errors += 1;
|
|
145
197
|
continue;
|
|
146
198
|
}
|
|
199
|
+
const replacing = exists(targetPath);
|
|
147
200
|
const body = extractDeltaBody(text);
|
|
148
|
-
write(targetPath, body, { force:
|
|
201
|
+
write(targetPath, body, { force: replacing });
|
|
149
202
|
writes += 1;
|
|
150
|
-
console.log(c.green("applied[ADDED]") + ` ${relPath(projectRoot, targetPath)}`
|
|
203
|
+
console.log(c.green("applied[ADDED]") + ` ${relPath(projectRoot, targetPath)}` +
|
|
204
|
+
(replacing ? c.gray(" (replaced the untouched spec-new scaffold)") : ""));
|
|
151
205
|
} else if (op === "REMOVED") {
|
|
152
206
|
if (!exists(targetPath)) {
|
|
153
207
|
console.log(c.yellow("warn:") + ` REMOVED delta targets missing spec ${relPath(projectRoot, targetPath)} — skipping`);
|
|
@@ -231,6 +285,191 @@ function changeApply(args, _flags) {
|
|
|
231
285
|
return errors > 0 ? 1 : 0;
|
|
232
286
|
}
|
|
233
287
|
|
|
288
|
+
// Pre-close dry-run (operator review 2026-07-19 §4.4): everything analyze,
|
|
289
|
+
// apply, and archive would refuse, listed BEFORE any of them runs, with the
|
|
290
|
+
// remediation next to each finding. Read-only — the per-change `doctor`.
|
|
291
|
+
async function changeCheck(id) {
|
|
292
|
+
const projectRoot = process.cwd();
|
|
293
|
+
ensureDoctrinaProject(projectRoot);
|
|
294
|
+
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
295
|
+
if (!isDir(changeDir)) {
|
|
296
|
+
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
297
|
+
return 1;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let failures = 0;
|
|
301
|
+
|
|
302
|
+
// 1. Structural analysis — same checks analyze runs before an apply.
|
|
303
|
+
console.log(c.gray("──── 1/3 structure (analyze)"));
|
|
304
|
+
const analyze = await import("./analyze.js");
|
|
305
|
+
if ((await analyze.run([id], new Map())) !== 0) failures += 1;
|
|
306
|
+
|
|
307
|
+
// 2. Ops dry-run: execute every MODIFIED delta's ops block against the
|
|
308
|
+
// target spec in memory. An op that would fail at apply time (missing
|
|
309
|
+
// header, no such criterion, unknown verb) is reported here, not days
|
|
310
|
+
// later; a delta with no ops block is flagged as a manual merge so the
|
|
311
|
+
// close is planned around it instead of surprised by it.
|
|
312
|
+
console.log(c.gray("──── 2/3 ops dry-run (what apply would do)"));
|
|
313
|
+
const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
314
|
+
let opsFindings = 0;
|
|
315
|
+
for (const deltaPath of deltaFiles) {
|
|
316
|
+
const text = read(deltaPath);
|
|
317
|
+
if (parseOperation(text) !== "MODIFIED") continue;
|
|
318
|
+
const capability = parseCapabilityFromDelta(text, deltaPath);
|
|
319
|
+
const rel = relPath(projectRoot, deltaPath);
|
|
320
|
+
const ops = extractOps(text);
|
|
321
|
+
if (ops.length === 0) {
|
|
322
|
+
console.log(c.yellow("⚠ ") + `${rel}: no ops block — apply will print a manual-merge pointer`);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const targetPath = path.join(projectRoot, ".doctrina", "specs", capability ?? "", "spec.md");
|
|
326
|
+
if (!capability || !exists(targetPath)) {
|
|
327
|
+
console.log(c.red("✗ ") + `${rel}: MODIFIED targets missing spec`);
|
|
328
|
+
opsFindings += 1;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const result = applyOps(read(targetPath), ops);
|
|
332
|
+
if (result.errors.length > 0) {
|
|
333
|
+
console.log(c.red("✗ ") + `${rel}: ${result.errors.length} op error${result.errors.length === 1 ? "" : "s"} (apply would refuse):`);
|
|
334
|
+
for (const e of result.errors) console.log(` - ${e}`);
|
|
335
|
+
opsFindings += 1;
|
|
336
|
+
} else {
|
|
337
|
+
console.log(c.green("✓ ") + `${rel}: ${result.applied.length} op${result.applied.length === 1 ? "" : "s"} would apply cleanly`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (deltaFiles.length === 0) console.log(c.gray("- no spec deltas"));
|
|
341
|
+
if (opsFindings > 0) failures += 1;
|
|
342
|
+
|
|
343
|
+
// 3. Archive gate preview: what archive will refuse, listed with the fix.
|
|
344
|
+
console.log(c.gray("──── 3/3 archive gate"));
|
|
345
|
+
const blockers = collectArchiveBlockers(changeDir);
|
|
346
|
+
if (blockers.length === 0) {
|
|
347
|
+
console.log(c.green("✓ ") + "archive gate clear (all boxes checked)");
|
|
348
|
+
} else {
|
|
349
|
+
failures += 1;
|
|
350
|
+
for (const b of blockers) console.log(c.red("✗ ") + b);
|
|
351
|
+
console.log(c.gray(" check them off as they land, or in bulk: ") + c.cyan(`doctrina change tick ${id} --all`));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Advisory (never gates): accepted ADRs citing the touched capabilities.
|
|
355
|
+
const caps = [...new Set(deltaFiles.map((p) => parseCapabilityFromDelta(read(p), p)).filter(Boolean))].sort();
|
|
356
|
+
console.log("");
|
|
357
|
+
printAdrCheckpoint(projectRoot, caps, { c });
|
|
358
|
+
|
|
359
|
+
console.log("");
|
|
360
|
+
if (failures === 0) {
|
|
361
|
+
console.log(c.green("ok") + ` ${id} is ready to close: ` + c.cyan(`doctrina close ${id}`));
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
console.log(c.red("fail") + ` ${failures} area${failures === 1 ? "" : "s"} would block the close — fix them first`);
|
|
365
|
+
return 1;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Bulk checkbox marking (operator review 2026-07-19 §3.5: "marcar checkbox
|
|
369
|
+
// não tem comando nenhum" — it was sed/Python by hand). With no args it
|
|
370
|
+
// LISTS the unchecked boxes with ordinals; `tick <id> 2 5` checks those;
|
|
371
|
+
// `tick <id> --all` checks everything (tasks.md + the proposal's
|
|
372
|
+
// ## Verification). Ticking is a claim of completion — the honest gate is
|
|
373
|
+
// still archive/verify; this only removes the mechanical friction.
|
|
374
|
+
function changeTick(args, flags) {
|
|
375
|
+
const id = args[0];
|
|
376
|
+
if (!id) {
|
|
377
|
+
console.error(c.red("error:") + " change tick requires <id> [ordinals... | --all]");
|
|
378
|
+
return 2;
|
|
379
|
+
}
|
|
380
|
+
const projectRoot = process.cwd();
|
|
381
|
+
ensureDoctrinaProject(projectRoot);
|
|
382
|
+
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
383
|
+
if (!isDir(changeDir)) {
|
|
384
|
+
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
385
|
+
return 1;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Unified ordinal space over both files, in reading order: every unchecked
|
|
389
|
+
// `- [ ]` in tasks.md, then in the proposal's ## Verification section.
|
|
390
|
+
const files = [
|
|
391
|
+
{ label: "tasks.md", path: path.join(changeDir, "tasks.md"), section: null },
|
|
392
|
+
{ label: "proposal.md ## Verification", path: path.join(changeDir, "proposal.md"), section: "Verification" },
|
|
393
|
+
];
|
|
394
|
+
const boxes = [];
|
|
395
|
+
for (const f of files) {
|
|
396
|
+
if (!isFile(f.path)) continue;
|
|
397
|
+
const lines = read(f.path).split(/\r?\n/);
|
|
398
|
+
const inScope = f.section === null
|
|
399
|
+
? () => true
|
|
400
|
+
: (() => {
|
|
401
|
+
const scoped = new Set();
|
|
402
|
+
let inSection = false;
|
|
403
|
+
for (let i = 0; i < lines.length; i++) {
|
|
404
|
+
if (/^##\s+/.test(lines[i])) inSection = new RegExp(`^##\\s+${f.section}\\b`, "i").test(lines[i]);
|
|
405
|
+
else if (inSection) scoped.add(i);
|
|
406
|
+
}
|
|
407
|
+
return (i) => scoped.has(i);
|
|
408
|
+
})();
|
|
409
|
+
for (let i = 0; i < lines.length; i++) {
|
|
410
|
+
if (/^\s*-\s*\[ \]/.test(lines[i]) && inScope(i)) {
|
|
411
|
+
boxes.push({ file: f, lineIndex: i, text: lines[i].replace(/^\s*-\s*\[ \]\s*/, "") });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (boxes.length === 0) {
|
|
417
|
+
console.log(c.green("ok") + " no unchecked boxes in tasks.md or the proposal's ## Verification");
|
|
418
|
+
return 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const all = flagBool(flags, "all", false);
|
|
422
|
+
const ordinals = args.slice(1).map(Number);
|
|
423
|
+
const isPlaceholder = (box) => box.text.trim() === "";
|
|
424
|
+
if (!all && ordinals.length === 0) {
|
|
425
|
+
console.log(c.bold(`Unchecked boxes in ${id}:`));
|
|
426
|
+
console.log("");
|
|
427
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
428
|
+
const label = isPlaceholder(boxes[i])
|
|
429
|
+
? c.yellow("(scaffold placeholder — write the real task, or delete the line)")
|
|
430
|
+
: boxes[i].text;
|
|
431
|
+
console.log(` ${String(i + 1).padStart(3)}. ${label} ${c.gray(`[${boxes[i].file.label}]`)}`);
|
|
432
|
+
}
|
|
433
|
+
console.log("");
|
|
434
|
+
console.log(c.gray("tick some: ") + c.cyan(`doctrina change tick ${id} 1 3`) + c.gray(" · all: ") + c.cyan(`doctrina change tick ${id} --all`));
|
|
435
|
+
return 0;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const picked = all ? boxes.map((_, i) => i + 1) : ordinals;
|
|
439
|
+
for (const n of picked) {
|
|
440
|
+
if (!Number.isInteger(n) || n < 1 || n > boxes.length) {
|
|
441
|
+
console.error(c.red("error:") + ` no box #${n} (1..${boxes.length} — run \`doctrina change tick ${id}\` to list)`);
|
|
442
|
+
return 2;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// Ticking an empty scaffold placeholder is a meaningless claim — it is how
|
|
446
|
+
// a hollow change games the archive gate. Refuse (all-or-nothing) and name
|
|
447
|
+
// the fix; analyze/close hard-fail on the placeholders regardless.
|
|
448
|
+
const empties = picked.filter((n) => isPlaceholder(boxes[n - 1]));
|
|
449
|
+
if (empties.length > 0) {
|
|
450
|
+
console.error(c.red("error:") + ` box${empties.length === 1 ? "" : "es"} ${empties.join(", ")} ${empties.length === 1 ? "is a" : "are"} scaffold placeholder${empties.length === 1 ? "" : "s"} with no text — nothing was ticked`);
|
|
451
|
+
console.error(c.gray("hint: ") + "write the real task on each line (or delete it), then tick");
|
|
452
|
+
return 2;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Group edits per file so each file is read/written once.
|
|
456
|
+
const byPath = new Map();
|
|
457
|
+
for (const n of picked) {
|
|
458
|
+
const box = boxes[n - 1];
|
|
459
|
+
if (!byPath.has(box.file.path)) byPath.set(box.file.path, []);
|
|
460
|
+
byPath.get(box.file.path).push(box);
|
|
461
|
+
}
|
|
462
|
+
for (const [filePath, hits] of byPath) {
|
|
463
|
+
const lines = read(filePath).split(/\r?\n/);
|
|
464
|
+
for (const box of hits) {
|
|
465
|
+
lines[box.lineIndex] = lines[box.lineIndex].replace(/\[ \]/, "[x]");
|
|
466
|
+
}
|
|
467
|
+
write(filePath, lines.join("\n"), { force: true });
|
|
468
|
+
}
|
|
469
|
+
console.log(c.green("ticked ") + `${picked.length} box${picked.length === 1 ? "" : "es"} in ${byPath.size} file${byPath.size === 1 ? "" : "s"}`);
|
|
470
|
+
return 0;
|
|
471
|
+
}
|
|
472
|
+
|
|
234
473
|
function changeArchive(args, flags) {
|
|
235
474
|
const id = args[0];
|
|
236
475
|
if (!id) {
|
|
@@ -275,6 +514,25 @@ function changeArchive(args, flags) {
|
|
|
275
514
|
console.error(c.red("error:") + ` archive target already exists: ${relPath(projectRoot, archiveDir)}`);
|
|
276
515
|
return 1;
|
|
277
516
|
}
|
|
517
|
+
|
|
518
|
+
// Flip the proposal Status before the move. `apply` only flips it when no
|
|
519
|
+
// delta fell back to a manual merge, so a change whose merges were manual
|
|
520
|
+
// (the common case before the ops-block docs) reached the archive still
|
|
521
|
+
// saying "proposed" — the index recorded applied, the file lied. Archiving
|
|
522
|
+
// IS the declaration that the change went in; stamp the file to match.
|
|
523
|
+
const proposalPathLive = path.join(changeDir, "proposal.md");
|
|
524
|
+
if (exists(proposalPathLive)) {
|
|
525
|
+
const txt = read(proposalPathLive);
|
|
526
|
+
const updated = txt.replace(
|
|
527
|
+
/^(-\s+\*\*Status:\*\*)\s+proposed\s*$/m,
|
|
528
|
+
`$1 applied\n- **Applied:** ${date}`,
|
|
529
|
+
);
|
|
530
|
+
if (updated !== txt) {
|
|
531
|
+
write(proposalPathLive, updated, { force: true });
|
|
532
|
+
console.log(c.green("status") + " proposal.md → applied (stamped at archive)");
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
278
536
|
move(changeDir, archiveDir);
|
|
279
537
|
console.log(c.green("archived") + ` ${relPath(projectRoot, archiveDir)}`);
|
|
280
538
|
|
|
@@ -470,6 +728,38 @@ function extractDeltaBody(text) {
|
|
|
470
728
|
return text.slice(idxSep + 5).replace(/^\n+/, "");
|
|
471
729
|
}
|
|
472
730
|
|
|
731
|
+
// Is the on-disk spec still the untouched `spec new <cap>` scaffold? Precise
|
|
732
|
+
// check: render the shipped capability template for the same capability and
|
|
733
|
+
// compare, ignoring the date-bearing "Last updated" line and whitespace
|
|
734
|
+
// normalisation. When the template cannot be located (unusual installs),
|
|
735
|
+
// fall back to the scaffold's own placeholder fingerprints — text no real
|
|
736
|
+
// spec keeps. Used by `change apply` so an ADDED delta can replace a
|
|
737
|
+
// scaffold (the canonical spec-new → delta flow) without ever clobbering a
|
|
738
|
+
// spec that carries real content.
|
|
739
|
+
function isUntouchedScaffold(specText, capability) {
|
|
740
|
+
const normalize = (s) =>
|
|
741
|
+
s.replace(/\r\n/g, "\n")
|
|
742
|
+
.split("\n")
|
|
743
|
+
.filter((line) => !/^\*\*Last updated:\*\*/.test(line))
|
|
744
|
+
.join("\n")
|
|
745
|
+
.trim();
|
|
746
|
+
try {
|
|
747
|
+
const tplPath = path.join(locateTemplatesDir(), "spec.md.template");
|
|
748
|
+
const rendered = read(tplPath)
|
|
749
|
+
.replace(/\{\{CAPABILITY\}\}/g, capability)
|
|
750
|
+
.replace(/\{\{DATE\}\}/g, "");
|
|
751
|
+
if (normalize(rendered) === normalize(specText)) return true;
|
|
752
|
+
} catch {
|
|
753
|
+
// fall through to the fingerprint heuristic
|
|
754
|
+
}
|
|
755
|
+
// Fingerprints: the Purpose placeholder comment AND an empty Ubiquitous
|
|
756
|
+
// section survive only in a scaffold nobody edited.
|
|
757
|
+
return (
|
|
758
|
+
specText.includes("<!-- One paragraph: what this capability does and why it exists. -->") &&
|
|
759
|
+
/##\s+Requirements \(EARS\)[\s\S]*?### Ubiquitous\s*\n\s*-\s*\n/.test(specText)
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
473
763
|
// Reasons a change is not finished enough to archive. Counts unchecked
|
|
474
764
|
// GitHub-style checkboxes (`- [ ]`) in tasks.md (every task, including the
|
|
475
765
|
// closing steps) and in the proposal's "## Verification" section. Returns
|
|
@@ -521,32 +811,52 @@ Usage: doctrina change <subcommand> [args]
|
|
|
521
811
|
Subcommands:
|
|
522
812
|
new <id> "<title>" Open a new change proposal at .doctrina/changes/<id>/
|
|
523
813
|
(--chore / --no-spec: a spec-less change for infra /
|
|
524
|
-
docs / build that still gets a proposal + ledger
|
|
525
|
-
|
|
814
|
+
docs / build that still gets a proposal + ledger;
|
|
815
|
+
--design: also scaffold design.md, opt-in)
|
|
816
|
+
apply <id...> Apply spec deltas: ADDED writes the full body (it
|
|
817
|
+
also REPLACES a target that is still the untouched
|
|
818
|
+
\`spec new\` scaffold — the canonical new-capability
|
|
819
|
+
flow; real content refuses), REMOVED deletes,
|
|
526
820
|
MODIFIED with an \`\`\`ops block is applied mechanically
|
|
527
821
|
(set-header / bump-version / set-criterion / ...),
|
|
528
822
|
MODIFIED without one prints a manual-merge pointer.
|
|
529
823
|
On any spec write the index is rebuilt from the tree.
|
|
530
|
-
archive <id
|
|
824
|
+
archive <id...> Move the change to .doctrina/changes/archive/YYYY-MM-DD-<id>/
|
|
825
|
+
check <id...> Pre-close dry-run, read-only: analyze's structural
|
|
826
|
+
checks + every ops block executed in memory against
|
|
827
|
+
its target + the archive gate preview + an advisory
|
|
828
|
+
list of accepted ADRs citing the touched capabilities.
|
|
829
|
+
Everything close would refuse, listed BEFORE it runs.
|
|
830
|
+
tick <id> [n... |--all] List the unchecked boxes (tasks.md + proposal
|
|
831
|
+
## Verification) with ordinals; tick the given ones,
|
|
832
|
+
or every one with --all. No args = list only.
|
|
531
833
|
abandon <id> Delete an open change folder and its index entry, and
|
|
532
834
|
record the abandonment in the ledger ([--reason "..."]).
|
|
533
835
|
diff <id> Preview every spec delta: line diff for MODIFIED,
|
|
534
836
|
summary for ADDED/REMOVED. Read-only.
|
|
535
837
|
|
|
838
|
+
apply / archive / check accept multiple ids (batch close of a backlog); the
|
|
839
|
+
exit code is the worst per-id result.
|
|
840
|
+
|
|
536
841
|
A MODIFIED delta may carry a fenced operations block that \`apply\` executes:
|
|
537
842
|
\`\`\`ops
|
|
538
843
|
set-header Implementation: verified — durable adapter (\`src/db.ts\`)
|
|
539
844
|
bump-version minor
|
|
540
845
|
set-criterion 1: verified
|
|
541
846
|
append-criterion [unverified] new signal — verified by \`test/x.test.ts\`
|
|
847
|
+
append-requirement event: When <trigger>, the system shall <action>.
|
|
848
|
+
replace-requirement ubiquitous 2: The system shall <action>.
|
|
542
849
|
\`\`\`
|
|
850
|
+
append-* ops resolve numbering/position at apply time, so concurrent open
|
|
851
|
+
changes appending to the same spec cannot collide.
|
|
543
852
|
|
|
544
853
|
Options:
|
|
545
854
|
--force Overwrite existing files (where applicable)
|
|
546
855
|
--chore, --no-spec With new, open a spec-less chore change
|
|
856
|
+
--design With new, also scaffold design.md (opt-in)
|
|
547
857
|
--reason "<text>" With abandon, the reason recorded in the ledger
|
|
548
858
|
`;
|
|
549
859
|
|
|
550
860
|
// Re-export parsers so scan.js (index rebuild) can reuse them, and the
|
|
551
861
|
// scaffold so `work` can open a change without duplicating the logic.
|
|
552
|
-
export { parseOperation, parseCapabilityFromDelta, changeNew };
|
|
862
|
+
export { parseOperation, parseCapabilityFromDelta, changeNew, isUntouchedScaffold };
|
package/src/commands/clarify.js
CHANGED
|
@@ -2,10 +2,17 @@ import path from "node:path";
|
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
4
|
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
5
|
-
import { flagBool } from "../lib/args.js";
|
|
5
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
6
6
|
import { c } from "../lib/colors.js";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// Two lexicons, one linter (0.11.0 field review item 3: an English-only
|
|
9
|
+
// clarify is permanently red on a PT-BR project — "some" is the verb *sumir*,
|
|
10
|
+
// "TODO o pensamento" is the pronoun *todo* — which teaches people to ignore
|
|
11
|
+
// the gate). The language comes from `.doctrina/config.json` ("language":
|
|
12
|
+
// "pt-BR" | "en"), else a per-file stopword heuristic. Portuguese mode swaps
|
|
13
|
+
// the ambiguous EN tokens for a PT lexicon; TODO only counts with a colon
|
|
14
|
+
// ("TODO:") since bare TODO is the pronoun.
|
|
15
|
+
const RULES_EN = [
|
|
9
16
|
{
|
|
10
17
|
// "may" is intentionally NOT in this list: EARS Optional grammar
|
|
11
18
|
// uses "the system may ..." legitimately, and a clarify pass that
|
|
@@ -27,10 +34,74 @@ const RULES = [
|
|
|
27
34
|
},
|
|
28
35
|
];
|
|
29
36
|
|
|
37
|
+
const RULES_PT = [
|
|
38
|
+
{
|
|
39
|
+
name: "weasel",
|
|
40
|
+
re: /\b(talvez|possivelmente|provavelmente|aproximadamente|quem sabe|eventualmente)\b/gi,
|
|
41
|
+
hint: "afirme explicitamente, com um verbo definido",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
// ASCII-folded variants included so "varios"/"vários" both match.
|
|
45
|
+
name: "vague",
|
|
46
|
+
re: /\b(muitos|muitas|poucos|poucas|v[aá]rios|v[aá]rias|alguns|algumas|adequado|adequada|robusto|robusta|escal[aá]vel)\b(?!\s+\d)/gi,
|
|
47
|
+
hint: "quantifique (um número ou um escopo preciso)",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
// Bare TODO is the Portuguese pronoun; only TODO: is a marker here.
|
|
51
|
+
name: "placeholder",
|
|
52
|
+
re: /\b(TBD|FIXME|XXX|\?\?\?)\b|\bTODO:/g,
|
|
53
|
+
hint: "resolva antes de aplicar",
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// Language for a file: the project-declared `.doctrina/config.json`
|
|
58
|
+
// ("language": "pt-BR" / "pt" / "en") wins; otherwise a deterministic
|
|
59
|
+
// stopword count over the file text decides. Never semantic (ADR 0005).
|
|
60
|
+
const PT_STOPWORDS = /\b(que|n[aã]o|para|uma|como|mais|ser|quando|est[aá]|s[aã]o|pela|pelo|dos|das|ou seja|deve)\b/gi;
|
|
61
|
+
const EN_STOPWORDS = /\b(the|and|that|with|shall|when|this|from|are|not|for|must)\b/gi;
|
|
62
|
+
|
|
63
|
+
function projectLanguage(projectRoot) {
|
|
64
|
+
const cfgPath = path.join(projectRoot, ".doctrina", "config.json");
|
|
65
|
+
if (isFile(cfgPath)) {
|
|
66
|
+
try {
|
|
67
|
+
const lang = String(JSON.parse(read(cfgPath))?.language ?? "").toLowerCase();
|
|
68
|
+
if (lang.startsWith("pt")) return "pt";
|
|
69
|
+
if (lang.startsWith("en")) return "en";
|
|
70
|
+
} catch { /* fall through to detection */ }
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function detectLang(text) {
|
|
76
|
+
const pt = (text.match(PT_STOPWORDS) ?? []).length;
|
|
77
|
+
const en = (text.match(EN_STOPWORDS) ?? []).length;
|
|
78
|
+
return pt > en ? "pt" : "en";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function rulesFor(projectRoot, text, forcedLang = null) {
|
|
82
|
+
const lang = forcedLang ?? projectLanguage(projectRoot) ?? detectLang(text);
|
|
83
|
+
return lang === "pt" ? RULES_PT : RULES_EN;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --lang pt|en wins over the project config and the per-file heuristic
|
|
87
|
+
// (operator review 2026-07-19 §4.8: on a PT-BR project with no config.json,
|
|
88
|
+
// a file whose stopword count tips English gets the EN lexicon and "some" —
|
|
89
|
+
// the verb *sumir* — is flagged as the vague quantifier). One flag, zero
|
|
90
|
+
// guessing; persist it with .doctrina/config.json { "language": "pt-BR" }.
|
|
91
|
+
function forcedLang(flags) {
|
|
92
|
+
const raw = flagString(flags, "lang");
|
|
93
|
+
if (!raw || raw === true) return null;
|
|
94
|
+
const lang = String(raw).toLowerCase();
|
|
95
|
+
if (lang.startsWith("pt")) return "pt";
|
|
96
|
+
if (lang.startsWith("en")) return "en";
|
|
97
|
+
console.error(c.yellow("warn:") + ` unknown --lang "${raw}" (use pt|en) — falling back to detection`);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
30
101
|
export async function run(positional, flags) {
|
|
31
102
|
const projectRoot = process.cwd();
|
|
32
103
|
|
|
33
|
-
if (flagBool(flags, "all", false)) return clarifyAll(projectRoot);
|
|
104
|
+
if (flagBool(flags, "all", false)) return clarifyAll(projectRoot, forcedLang(flags));
|
|
34
105
|
|
|
35
106
|
const target = positional[0];
|
|
36
107
|
if (!target) {
|
|
@@ -46,7 +117,7 @@ export async function run(positional, flags) {
|
|
|
46
117
|
console.log(`clarify ${relPath(projectRoot, fullPath)}`);
|
|
47
118
|
console.log("");
|
|
48
119
|
|
|
49
|
-
const smells = scanFile(fullPath);
|
|
120
|
+
const smells = scanFile(fullPath, projectRoot, forcedLang(flags));
|
|
50
121
|
for (const s of smells) {
|
|
51
122
|
console.log(`${c.yellow("⚠")} line ${s.line}: ${s.rule} "${s.token}" — ${s.hint}`);
|
|
52
123
|
}
|
|
@@ -65,7 +136,7 @@ export async function run(positional, flags) {
|
|
|
65
136
|
// and clarify each. ADRs are immutable and the archive is history, so
|
|
66
137
|
// neither is included; AGENTS.md is dense command text, a different
|
|
67
138
|
// register from prose specs.
|
|
68
|
-
function clarifyAll(projectRoot) {
|
|
139
|
+
function clarifyAll(projectRoot, lang = null) {
|
|
69
140
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
70
141
|
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
71
142
|
}
|
|
@@ -92,7 +163,7 @@ function clarifyAll(projectRoot) {
|
|
|
92
163
|
|
|
93
164
|
let total = 0;
|
|
94
165
|
for (const f of files) {
|
|
95
|
-
const smells = scanFile(f);
|
|
166
|
+
const smells = scanFile(f, projectRoot, lang);
|
|
96
167
|
if (smells.length === 0) continue;
|
|
97
168
|
total += smells.length;
|
|
98
169
|
const rel = relPath(projectRoot, f);
|
|
@@ -110,19 +181,27 @@ function clarifyAll(projectRoot) {
|
|
|
110
181
|
return 1;
|
|
111
182
|
}
|
|
112
183
|
|
|
113
|
-
function scanFile(fullPath) {
|
|
184
|
+
function scanFile(fullPath, projectRoot = process.cwd(), lang = null) {
|
|
114
185
|
const text = read(fullPath);
|
|
115
186
|
const skippedRanges = collectSkippedRanges(text);
|
|
187
|
+
const rules = rulesFor(projectRoot, text, lang);
|
|
116
188
|
const smells = [];
|
|
117
189
|
|
|
118
|
-
// Per-line rules: regex pass with skip-range filter.
|
|
190
|
+
// Per-line rules: regex pass with skip-range filter. A line carrying the
|
|
191
|
+
// inline suppression marker `<!-- clarify:ok -->` is deliberately accepted
|
|
192
|
+
// by the author — skip it entirely (the escape hatch for a false positive
|
|
193
|
+
// the lexicon cannot know about; "block, never imprison").
|
|
119
194
|
const lines = text.split("\n");
|
|
120
195
|
let cursor = 0;
|
|
121
196
|
for (let i = 0; i < lines.length; i++) {
|
|
122
197
|
const line = lines[i];
|
|
123
198
|
const lineStart = cursor;
|
|
124
199
|
const lineEnd = cursor + line.length;
|
|
125
|
-
|
|
200
|
+
if (line.includes("clarify:ok")) {
|
|
201
|
+
cursor = lineEnd + 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
for (const rule of rules) {
|
|
126
205
|
rule.re.lastIndex = 0;
|
|
127
206
|
let m;
|
|
128
207
|
while ((m = rule.re.exec(line))) {
|
|
@@ -192,14 +271,21 @@ function findAcceptanceLine(text) {
|
|
|
192
271
|
}
|
|
193
272
|
|
|
194
273
|
export const help = `
|
|
195
|
-
Usage: doctrina clarify <path>
|
|
196
|
-
doctrina clarify --all
|
|
274
|
+
Usage: doctrina clarify <path> [--lang pt|en]
|
|
275
|
+
doctrina clarify --all [--lang pt|en]
|
|
197
276
|
|
|
198
277
|
Read a Markdown file and report weasel words, vague quantifiers,
|
|
199
278
|
placeholders, and empty Acceptance criteria sections. Exits 0 when
|
|
200
279
|
no smells are found, 1 otherwise. Matches inside fenced code
|
|
201
280
|
blocks, HTML comments, and inline backtick spans are skipped.
|
|
202
281
|
|
|
282
|
+
Language-aware: --lang pt|en forces the lexicon; otherwise
|
|
283
|
+
.doctrina/config.json ({ "language": "pt-BR" }) decides, else a
|
|
284
|
+
per-file stopword count. Portuguese mode uses a PT lexicon (bare
|
|
285
|
+
TODO is the pronoun, only "TODO:" is a marker; "some"/"several" are
|
|
286
|
+
not smells). A line ending in <!-- clarify:ok --> is author-accepted
|
|
287
|
+
and never flagged — the escape hatch for a false positive.
|
|
288
|
+
|
|
203
289
|
With --all, every living document is scanned in one pass:
|
|
204
290
|
product.md, capability specs, open changes, and skills. ADRs
|
|
205
291
|
(immutable) and the archive (history) are excluded. CI-friendly.
|