doctrina-cli 0.12.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 +10 -0
- package/src/commands/change.js +245 -6
- package/src/commands/clarify.js +32 -18
- package/src/commands/close.js +63 -10
- package/src/commands/init.js +15 -2
- package/src/commands/templates.js +113 -10
- package/src/commands/upgrade.js +15 -11
- package/src/commands/validate.js +35 -1
- package/src/commands/work.js +61 -6
- package/src/lib/adr-guard.js +46 -0
- package/src/lib/commands.js +100 -2
- package/src/lib/spec-ops.js +143 -3
- package/templates/.gitkeep +0 -0
- package/templates/AGENTS.md.template +25 -35
- package/templates/change/spec-delta.md.template +13 -7
package/package.json
CHANGED
package/src/commands/analyze.js
CHANGED
|
@@ -41,6 +41,16 @@ export async function run(positional, _flags) {
|
|
|
41
41
|
} else {
|
|
42
42
|
results.push(pass("tasks.md present"));
|
|
43
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
|
+
}
|
|
44
54
|
if (/^\s*-\s*\[\s\]/m.test(text)) results.push(pass("tasks.md has at least one unchecked task"));
|
|
45
55
|
else results.push(info("tasks.md has no unchecked tasks (already done?)"));
|
|
46
56
|
}
|
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();
|
|
@@ -246,6 +285,191 @@ function changeApply(args, _flags) {
|
|
|
246
285
|
return errors > 0 ? 1 : 0;
|
|
247
286
|
}
|
|
248
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
|
+
|
|
249
473
|
function changeArchive(args, flags) {
|
|
250
474
|
const id = args[0];
|
|
251
475
|
if (!id) {
|
|
@@ -589,7 +813,7 @@ Subcommands:
|
|
|
589
813
|
(--chore / --no-spec: a spec-less change for infra /
|
|
590
814
|
docs / build that still gets a proposal + ledger;
|
|
591
815
|
--design: also scaffold design.md, opt-in)
|
|
592
|
-
apply <id
|
|
816
|
+
apply <id...> Apply spec deltas: ADDED writes the full body (it
|
|
593
817
|
also REPLACES a target that is still the untouched
|
|
594
818
|
\`spec new\` scaffold — the canonical new-capability
|
|
595
819
|
flow; real content refuses), REMOVED deletes,
|
|
@@ -597,19 +821,34 @@ Subcommands:
|
|
|
597
821
|
(set-header / bump-version / set-criterion / ...),
|
|
598
822
|
MODIFIED without one prints a manual-merge pointer.
|
|
599
823
|
On any spec write the index is rebuilt from the tree.
|
|
600
|
-
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.
|
|
601
833
|
abandon <id> Delete an open change folder and its index entry, and
|
|
602
834
|
record the abandonment in the ledger ([--reason "..."]).
|
|
603
835
|
diff <id> Preview every spec delta: line diff for MODIFIED,
|
|
604
836
|
summary for ADDED/REMOVED. Read-only.
|
|
605
837
|
|
|
838
|
+
apply / archive / check accept multiple ids (batch close of a backlog); the
|
|
839
|
+
exit code is the worst per-id result.
|
|
840
|
+
|
|
606
841
|
A MODIFIED delta may carry a fenced operations block that \`apply\` executes:
|
|
607
842
|
\`\`\`ops
|
|
608
843
|
set-header Implementation: verified — durable adapter (\`src/db.ts\`)
|
|
609
844
|
bump-version minor
|
|
610
845
|
set-criterion 1: verified
|
|
611
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>.
|
|
612
849
|
\`\`\`
|
|
850
|
+
append-* ops resolve numbering/position at apply time, so concurrent open
|
|
851
|
+
changes appending to the same spec cannot collide.
|
|
613
852
|
|
|
614
853
|
Options:
|
|
615
854
|
--force Overwrite existing files (where applicable)
|
package/src/commands/clarify.js
CHANGED
|
@@ -2,7 +2,7 @@ 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
|
|
@@ -78,16 +78,30 @@ function detectLang(text) {
|
|
|
78
78
|
return pt > en ? "pt" : "en";
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
function rulesFor(projectRoot, text) {
|
|
82
|
-
const
|
|
83
|
-
const lang = declared ?? detectLang(text);
|
|
81
|
+
function rulesFor(projectRoot, text, forcedLang = null) {
|
|
82
|
+
const lang = forcedLang ?? projectLanguage(projectRoot) ?? detectLang(text);
|
|
84
83
|
return lang === "pt" ? RULES_PT : RULES_EN;
|
|
85
84
|
}
|
|
86
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
|
+
|
|
87
101
|
export async function run(positional, flags) {
|
|
88
102
|
const projectRoot = process.cwd();
|
|
89
103
|
|
|
90
|
-
if (flagBool(flags, "all", false)) return clarifyAll(projectRoot);
|
|
104
|
+
if (flagBool(flags, "all", false)) return clarifyAll(projectRoot, forcedLang(flags));
|
|
91
105
|
|
|
92
106
|
const target = positional[0];
|
|
93
107
|
if (!target) {
|
|
@@ -103,7 +117,7 @@ export async function run(positional, flags) {
|
|
|
103
117
|
console.log(`clarify ${relPath(projectRoot, fullPath)}`);
|
|
104
118
|
console.log("");
|
|
105
119
|
|
|
106
|
-
const smells = scanFile(fullPath);
|
|
120
|
+
const smells = scanFile(fullPath, projectRoot, forcedLang(flags));
|
|
107
121
|
for (const s of smells) {
|
|
108
122
|
console.log(`${c.yellow("⚠")} line ${s.line}: ${s.rule} "${s.token}" — ${s.hint}`);
|
|
109
123
|
}
|
|
@@ -122,7 +136,7 @@ export async function run(positional, flags) {
|
|
|
122
136
|
// and clarify each. ADRs are immutable and the archive is history, so
|
|
123
137
|
// neither is included; AGENTS.md is dense command text, a different
|
|
124
138
|
// register from prose specs.
|
|
125
|
-
function clarifyAll(projectRoot) {
|
|
139
|
+
function clarifyAll(projectRoot, lang = null) {
|
|
126
140
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
127
141
|
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
128
142
|
}
|
|
@@ -149,7 +163,7 @@ function clarifyAll(projectRoot) {
|
|
|
149
163
|
|
|
150
164
|
let total = 0;
|
|
151
165
|
for (const f of files) {
|
|
152
|
-
const smells = scanFile(f);
|
|
166
|
+
const smells = scanFile(f, projectRoot, lang);
|
|
153
167
|
if (smells.length === 0) continue;
|
|
154
168
|
total += smells.length;
|
|
155
169
|
const rel = relPath(projectRoot, f);
|
|
@@ -167,10 +181,10 @@ function clarifyAll(projectRoot) {
|
|
|
167
181
|
return 1;
|
|
168
182
|
}
|
|
169
183
|
|
|
170
|
-
function scanFile(fullPath, projectRoot = process.cwd()) {
|
|
184
|
+
function scanFile(fullPath, projectRoot = process.cwd(), lang = null) {
|
|
171
185
|
const text = read(fullPath);
|
|
172
186
|
const skippedRanges = collectSkippedRanges(text);
|
|
173
|
-
const rules = rulesFor(projectRoot, text);
|
|
187
|
+
const rules = rulesFor(projectRoot, text, lang);
|
|
174
188
|
const smells = [];
|
|
175
189
|
|
|
176
190
|
// Per-line rules: regex pass with skip-range filter. A line carrying the
|
|
@@ -257,20 +271,20 @@ function findAcceptanceLine(text) {
|
|
|
257
271
|
}
|
|
258
272
|
|
|
259
273
|
export const help = `
|
|
260
|
-
Usage: doctrina clarify <path>
|
|
261
|
-
doctrina clarify --all
|
|
274
|
+
Usage: doctrina clarify <path> [--lang pt|en]
|
|
275
|
+
doctrina clarify --all [--lang pt|en]
|
|
262
276
|
|
|
263
277
|
Read a Markdown file and report weasel words, vague quantifiers,
|
|
264
278
|
placeholders, and empty Acceptance criteria sections. Exits 0 when
|
|
265
279
|
no smells are found, 1 otherwise. Matches inside fenced code
|
|
266
280
|
blocks, HTML comments, and inline backtick spans are skipped.
|
|
267
281
|
|
|
268
|
-
Language-aware:
|
|
269
|
-
({ "language": "pt-BR" })
|
|
270
|
-
Portuguese mode uses a PT lexicon (bare
|
|
271
|
-
"TODO:" is a marker; "some"/"several" are
|
|
272
|
-
in <!-- clarify:ok --> is author-accepted
|
|
273
|
-
escape hatch for a false positive.
|
|
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.
|
|
274
288
|
|
|
275
289
|
With --all, every living document is scanned in one pass:
|
|
276
290
|
product.md, capability specs, open changes, and skills. ADRs
|
package/src/commands/close.js
CHANGED
|
@@ -4,12 +4,14 @@ import { exists, isFile, read, walk } from "../lib/fs-ops.js";
|
|
|
4
4
|
import { flagBool } from "../lib/args.js";
|
|
5
5
|
import { c } from "../lib/colors.js";
|
|
6
6
|
import { parseCapabilityFromDelta } from "./change.js";
|
|
7
|
+
import { printAdrCheckpoint } from "../lib/adr-guard.js";
|
|
7
8
|
import * as analyze from "./analyze.js";
|
|
8
9
|
import * as change from "./change.js";
|
|
9
10
|
import * as verify from "./verify.js";
|
|
10
11
|
import * as coverage from "./coverage.js";
|
|
11
12
|
import * as trace from "./trace.js";
|
|
12
13
|
import * as validate from "./validate.js";
|
|
14
|
+
import * as skill from "./skill.js";
|
|
13
15
|
|
|
14
16
|
// One-command close (review 2026-06-27 passive-user feature #2). The work
|
|
15
17
|
// playbook lists the closing sequence — analyze → apply → verify → coverage →
|
|
@@ -20,8 +22,7 @@ import * as validate from "./validate.js";
|
|
|
20
22
|
// driver over the existing commands; it adds no new checks of its own.
|
|
21
23
|
|
|
22
24
|
export async function run(positional, flags) {
|
|
23
|
-
|
|
24
|
-
if (!id) {
|
|
25
|
+
if (positional.length === 0) {
|
|
25
26
|
console.error(c.red("error:") + " close requires a change <id> (e.g. doctrina close 0001-add-login)");
|
|
26
27
|
return 2;
|
|
27
28
|
}
|
|
@@ -30,6 +31,27 @@ export async function run(positional, flags) {
|
|
|
30
31
|
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
// Batch close (operator review 2026-07-19 §4.5): each id closes
|
|
35
|
+
// independently, worst exit code wins, so a 9-change implementation lot is
|
|
36
|
+
// one command, not nine.
|
|
37
|
+
let worst = 0;
|
|
38
|
+
for (const id of positional) {
|
|
39
|
+
if (positional.length > 1) {
|
|
40
|
+
console.log("");
|
|
41
|
+
console.log(c.bold(`════ close ${id}`));
|
|
42
|
+
}
|
|
43
|
+
worst = Math.max(worst, await closeOne(projectRoot, id, flags));
|
|
44
|
+
}
|
|
45
|
+
if (positional.length > 1) {
|
|
46
|
+
console.log("");
|
|
47
|
+
console.log(worst === 0
|
|
48
|
+
? c.green("ok") + ` all ${positional.length} changes closed`
|
|
49
|
+
: c.red("fail") + " at least one close stopped — see above");
|
|
50
|
+
}
|
|
51
|
+
return worst;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function closeOne(projectRoot, id, flags) {
|
|
33
55
|
const force = flagBool(flags, "force", false);
|
|
34
56
|
const archiveFlags = force ? new Map([["force", true]]) : new Map();
|
|
35
57
|
|
|
@@ -52,6 +74,20 @@ export async function run(positional, flags) {
|
|
|
52
74
|
const verifyConfigured = isFile(path.join(projectRoot, ".doctrina", "verify.json"));
|
|
53
75
|
const steps = [
|
|
54
76
|
{ label: "analyze", rerun: `doctrina analyze ${id}`, run: () => analyze.run([id], new Map()) },
|
|
77
|
+
// ADR checkpoint (operator review §4.6): the playbook's "record an ADR"
|
|
78
|
+
// step was skippable in silence. Advisory — it names the accepted ADRs
|
|
79
|
+
// whose text cites the touched capabilities and the amend commands, but
|
|
80
|
+
// an ADR merely mentioning a capability is normal, so it never blocks.
|
|
81
|
+
{
|
|
82
|
+
label: "ADR checkpoint (advisory)",
|
|
83
|
+
rerun: "doctrina decision list",
|
|
84
|
+
run: async () => {
|
|
85
|
+
if (printAdrCheckpoint(projectRoot, touched, { c }) === 0) {
|
|
86
|
+
console.log(c.green("ok") + " no accepted ADR cites the touched capabilities");
|
|
87
|
+
}
|
|
88
|
+
return 0;
|
|
89
|
+
},
|
|
90
|
+
},
|
|
55
91
|
{ label: "apply", rerun: `doctrina change apply ${id}`, run: () => change.run(["apply", id], new Map()) },
|
|
56
92
|
verifyConfigured
|
|
57
93
|
? { label: "verify", rerun: "doctrina verify", run: () => verify.run([], new Map()) }
|
|
@@ -68,7 +104,7 @@ export async function run(positional, flags) {
|
|
|
68
104
|
{ label: "validate", rerun: "doctrina validate", run: () => validate.run([], new Map()) },
|
|
69
105
|
];
|
|
70
106
|
|
|
71
|
-
console.log(c.bold(`Closing change ${id}`) + c.gray(" — analyze → apply → verify → coverage → trace → archive → validate"));
|
|
107
|
+
console.log(c.bold(`Closing change ${id}`) + c.gray(" — analyze → ADR checkpoint → apply → verify → coverage → trace → archive → validate"));
|
|
72
108
|
|
|
73
109
|
for (let i = 0; i < steps.length; i++) {
|
|
74
110
|
const step = steps[i];
|
|
@@ -95,6 +131,16 @@ export async function run(positional, flags) {
|
|
|
95
131
|
}
|
|
96
132
|
}
|
|
97
133
|
|
|
134
|
+
// Capture the lesson while it is fresh (operator review §4.7): surface the
|
|
135
|
+
// fix-shaped lessons not yet written as skills, right where the operator
|
|
136
|
+
// just proved they finish what they start. Advisory — a listing, never a
|
|
137
|
+
// gate, and a suggest failure never un-closes the change.
|
|
138
|
+
console.log("");
|
|
139
|
+
console.log(c.gray("──── skills (advisory)"));
|
|
140
|
+
try {
|
|
141
|
+
await skill.run(["suggest"], new Map());
|
|
142
|
+
} catch { /* advisory only */ }
|
|
143
|
+
|
|
98
144
|
console.log("");
|
|
99
145
|
console.log(c.green(`✓ change ${id} closed`) + c.gray(" — verified, archived, and validated."));
|
|
100
146
|
console.log(c.gray("Next: ") + c.cyan("doctrina next"));
|
|
@@ -115,21 +161,28 @@ function touchedCapabilities(projectRoot, id) {
|
|
|
115
161
|
}
|
|
116
162
|
|
|
117
163
|
export const help = `
|
|
118
|
-
Usage: doctrina close <id
|
|
164
|
+
Usage: doctrina close <id...> [--force]
|
|
119
165
|
|
|
120
166
|
Run the whole closing sequence for a change in one pass, stopping at the
|
|
121
167
|
first failure with the exact command to rerun:
|
|
122
168
|
|
|
123
|
-
analyze → change apply → verify → coverage
|
|
124
|
-
change's touched capabilities) → trace → change
|
|
169
|
+
analyze → ADR checkpoint (advisory) → change apply → verify → coverage
|
|
170
|
+
--strict (scoped to the change's touched capabilities) → trace → change
|
|
171
|
+
archive → validate → skill suggest (advisory)
|
|
125
172
|
|
|
126
173
|
The coverage gate is scoped to the capabilities the change's deltas touch,
|
|
127
174
|
so a deliberately deferred spec elsewhere cannot block an unrelated close;
|
|
128
175
|
a change with no deltas gates on the whole tree. verify is skipped (with a
|
|
129
|
-
note) when no .doctrina/verify.json is declared; trace
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
176
|
+
note) when no .doctrina/verify.json is declared; trace, the ADR checkpoint
|
|
177
|
+
(accepted ADRs citing the touched capabilities — amend via \`decision
|
|
178
|
+
supersede\`, not silence), and the closing skill-suggest listing are
|
|
179
|
+
advisory (reports, never blockers). This is a driver over the existing
|
|
180
|
+
commands so the agent makes one call instead of nine and the human
|
|
181
|
+
approves once.
|
|
182
|
+
|
|
183
|
+
Multiple ids close in sequence, each independently; the exit code is the
|
|
184
|
+
worst per-id result. Preview what close would refuse: \`doctrina change
|
|
185
|
+
check <id>\`.
|
|
133
186
|
|
|
134
187
|
Options:
|
|
135
188
|
--force Pass through to \`change archive\` (archive even if verification
|
package/src/commands/init.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, write } from "../lib/fs-ops.js";
|
|
4
4
|
import { locateTemplatesDir, loadTemplateTree, materialiseEntry, substitute } from "../lib/templates.js";
|
|
5
|
+
import { surfaceBlock, findSurfaceBlock } from "../lib/commands.js";
|
|
5
6
|
import { today } from "../lib/dates.js";
|
|
6
7
|
import { cliVersion } from "../lib/version.js";
|
|
7
8
|
import { flagBool, flagString } from "../lib/args.js";
|
|
@@ -108,10 +109,13 @@ export async function run(_positional, flags) {
|
|
|
108
109
|
FRAMEWORK_VERSION: cliVersion(),
|
|
109
110
|
};
|
|
110
111
|
|
|
111
|
-
// Root AGENTS.md — with optional --from base content prepended.
|
|
112
|
+
// Root AGENTS.md — with optional --from base content prepended. The
|
|
113
|
+
// doctrina:surface block is regenerated from the installed catalog at init
|
|
114
|
+
// time, so a freshly scaffolded hub can never document a stale surface even
|
|
115
|
+
// if the static template lags a release (ADR 0015).
|
|
112
116
|
const agentsTemplatePath = path.join(templatesDir, "AGENTS.md.template");
|
|
113
117
|
const agentsTemplateBody = read(agentsTemplatePath);
|
|
114
|
-
const projectAgents = substitute(agentsTemplateBody, tokens);
|
|
118
|
+
const projectAgents = refreshSurfaceBlock(substitute(agentsTemplateBody, tokens));
|
|
115
119
|
const finalAgents = fromAgentsContent
|
|
116
120
|
? fromAgentsContent.replace(/\n+$/, "") + "\n\n---\n\n## Project-specific\n\n" + projectAgents
|
|
117
121
|
: projectAgents;
|
|
@@ -178,6 +182,15 @@ export async function run(_positional, flags) {
|
|
|
178
182
|
return 0;
|
|
179
183
|
}
|
|
180
184
|
|
|
185
|
+
// Replace the template's doctrina:surface span with the one generated from
|
|
186
|
+
// the running CLI's catalog; pass the text through untouched when the
|
|
187
|
+
// template carries no markers (older custom templates).
|
|
188
|
+
function refreshSurfaceBlock(text) {
|
|
189
|
+
const block = findSurfaceBlock(text);
|
|
190
|
+
if (!block) return text;
|
|
191
|
+
return text.slice(0, block.start) + surfaceBlock() + text.slice(block.end);
|
|
192
|
+
}
|
|
193
|
+
|
|
181
194
|
// First non-empty line of the intake, stripped of Markdown heading marks
|
|
182
195
|
// and clipped, as the one-line description token for the templates.
|
|
183
196
|
function firstLineSummary(text) {
|