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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -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
- if (exists(targetSpec)) {
78
- results.push(fail(` ${cap} (ADDED) but target ${targetRel} already exists`, " "));
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
  }
@@ -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 changeApply(positional.slice(1), flags);
23
+ return forEachId(positional.slice(1), "apply", (id) => changeApply([id], flags));
23
24
  case "archive":
24
- return changeArchive(positional.slice(1), flags);
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
- if (exists(targetPath)) {
143
- console.error(c.red("error:") + ` ADDED delta targets existing spec ${relPath(projectRoot, targetPath)} — refusing to overwrite`);
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: false });
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
- apply <id> Apply spec deltas: ADDED writes, REMOVED deletes,
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> Move the change to .doctrina/changes/archive/YYYY-MM-DD-<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 };
@@ -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
- const RULES = [
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
- for (const rule of RULES) {
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.