doctrina-cli 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +3 -2
- package/src/commands/adapter.js +205 -0
- package/src/commands/analyze.js +76 -19
- package/src/commands/change.js +341 -44
- package/src/commands/clarify.js +40 -19
- package/src/commands/close.js +119 -11
- package/src/commands/completion.js +6 -0
- package/src/commands/constitution.js +8 -1
- package/src/commands/context.js +359 -53
- package/src/commands/contract.js +17 -24
- package/src/commands/coverage.js +16 -4
- package/src/commands/decision.js +172 -40
- package/src/commands/doctor.js +38 -7
- package/src/commands/handoff.js +8 -1
- package/src/commands/hooks.js +6 -0
- package/src/commands/index-rebuild.js +8 -1
- package/src/commands/init.js +152 -6
- package/src/commands/intake.js +11 -2
- package/src/commands/intent.js +8 -1
- package/src/commands/metrics.js +80 -7
- package/src/commands/next.js +14 -2
- package/src/commands/prime.js +8 -1
- package/src/commands/report.js +8 -1
- package/src/commands/review.js +8 -1
- package/src/commands/search.js +8 -1
- package/src/commands/show.js +8 -1
- package/src/commands/skill.js +16 -6
- package/src/commands/spec.js +13 -6
- package/src/commands/status.js +14 -2
- package/src/commands/templates.js +277 -40
- package/src/commands/trace.js +15 -3
- package/src/commands/upgrade.js +23 -12
- package/src/commands/validate.js +93 -3
- package/src/commands/verify.js +12 -2
- package/src/commands/watch.js +8 -1
- package/src/commands/why.js +10 -24
- package/src/commands/work.js +69 -7
- package/src/index.js +60 -6
- package/src/lib/adapters.js +140 -0
- package/src/lib/adr-guard.js +47 -0
- package/src/lib/args.js +10 -0
- package/src/lib/clarity.js +1 -0
- package/src/lib/colors.js +1 -0
- package/src/lib/commands.js +314 -3
- package/src/lib/criteria.js +1 -0
- package/src/lib/dates.js +1 -0
- package/src/lib/diff.js +1 -0
- package/src/lib/doc-model.js +287 -0
- package/src/lib/docs-impact.js +167 -0
- package/src/lib/ears.js +1 -0
- package/src/lib/exit-codes.js +103 -0
- package/src/lib/flag-catalog.js +106 -0
- package/src/lib/fs-ops.js +1 -0
- package/src/lib/gates.js +123 -0
- package/src/lib/git.js +97 -0
- package/src/lib/index-json.js +14 -6
- package/src/lib/json-out.js +83 -0
- package/src/lib/prompt.js +18 -1
- package/src/lib/scan.js +81 -33
- package/src/lib/spec-ops.js +228 -3
- package/src/lib/suggest.js +1 -0
- package/src/lib/templates.js +89 -0
- package/src/lib/usage.js +89 -0
- package/src/lib/version.js +1 -0
- package/templates/.gitkeep +0 -0
- package/templates/AGENTS.md.template +71 -75
- package/templates/change/spec-delta.md.template +13 -7
package/src/commands/change.js
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getSection, getHeader } from "../lib/doc-model.js";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import process from "node:process";
|
|
3
|
-
import { exists, isDir, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
|
|
5
|
+
import { exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
|
|
4
6
|
import { diffLines, formatUnified } from "../lib/diff.js";
|
|
5
7
|
import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "../lib/templates.js";
|
|
6
8
|
import * as idx from "../lib/index-json.js";
|
|
7
9
|
import { deriveIndex } from "../lib/scan.js";
|
|
8
10
|
import { extractOps, applyOps } from "../lib/spec-ops.js";
|
|
11
|
+
import { printAdrCheckpoint } from "../lib/adr-guard.js";
|
|
12
|
+
import { GATES, TRANSITIONS, checkTransition, recordForcedGap } from "../lib/gates.js";
|
|
9
13
|
import { today } from "../lib/dates.js";
|
|
10
14
|
import { flagBool, flagString } from "../lib/args.js";
|
|
11
15
|
import { c } from "../lib/colors.js";
|
|
12
16
|
import { suggest } from "../lib/suggest.js";
|
|
17
|
+
import { confirm, isInteractive } from "../lib/prompt.js";
|
|
18
|
+
import { EXIT } from "../lib/exit-codes.js";
|
|
19
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
20
|
|
|
14
|
-
const SUBCOMMANDS = ["new", "apply", "archive", "diff", "abandon"];
|
|
21
|
+
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "diff", "abandon"];
|
|
22
|
+
|
|
23
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
24
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
25
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
26
|
+
export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec"], string: ["reason"] };
|
|
15
27
|
|
|
16
28
|
export async function run(positional, flags) {
|
|
17
29
|
const sub = positional[0];
|
|
@@ -19,13 +31,17 @@ export async function run(positional, flags) {
|
|
|
19
31
|
case "new":
|
|
20
32
|
return changeNew(positional.slice(1), flags);
|
|
21
33
|
case "apply":
|
|
22
|
-
return
|
|
34
|
+
return forEachId(positional.slice(1), "apply", (id) => changeApply([id], flags));
|
|
23
35
|
case "archive":
|
|
24
|
-
return
|
|
36
|
+
return forEachId(positional.slice(1), "archive", (id) => changeArchive([id], flags));
|
|
37
|
+
case "check":
|
|
38
|
+
return forEachId(positional.slice(1), "check", (id) => changeCheck(id));
|
|
39
|
+
case "tick":
|
|
40
|
+
return changeTick(positional.slice(1), flags);
|
|
25
41
|
case "diff":
|
|
26
42
|
return changeDiff(positional.slice(1), flags);
|
|
27
43
|
case "abandon":
|
|
28
|
-
return changeAbandon(positional.slice(1), flags);
|
|
44
|
+
return await changeAbandon(positional.slice(1), flags);
|
|
29
45
|
default:
|
|
30
46
|
console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
|
|
31
47
|
const guess = suggest(sub, SUBCOMMANDS);
|
|
@@ -36,6 +52,63 @@ export async function run(positional, flags) {
|
|
|
36
52
|
}
|
|
37
53
|
}
|
|
38
54
|
|
|
55
|
+
// Enforce a lifecycle transition's preconditions from the shared gate map.
|
|
56
|
+
// Returns true when the transition may proceed. One implementation, so
|
|
57
|
+
// every driver of a transition refuses — and explains, and forces —
|
|
58
|
+
// identically (C6).
|
|
59
|
+
function enforceTransition(projectRoot, id, changeDir, transition, flags) {
|
|
60
|
+
const force = flagBool(flags, "force", false);
|
|
61
|
+
const { ok, blockers } = checkTransition(projectRoot, changeDir, transition);
|
|
62
|
+
if (ok) return true;
|
|
63
|
+
|
|
64
|
+
const label = TRANSITIONS[transition].label;
|
|
65
|
+
if (!force) {
|
|
66
|
+
console.error(c.red("error:") + ` refusing to ${transition} "${id}" — ${blockers.length} blocker${blockers.length === 1 ? "" : "s"}:`);
|
|
67
|
+
for (const b of blockers) console.error(` - [${b.gate}] ${b.message}`);
|
|
68
|
+
const reruns = [...new Set(blockers.map((b) => GATES[b.gate].rerun(id)))];
|
|
69
|
+
console.error(c.gray("hint: ") + `fix them (${reruns.join(" · ")}), or pass --force to ${transition} anyway (records the gap)`);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
console.log(c.yellow("warn:") + ` ${label} "${id}" with ${blockers.length} blocker${blockers.length === 1 ? "" : "s"} (--force):`);
|
|
73
|
+
for (const b of blockers) console.log(c.yellow(" - ") + `[${b.gate}] ${b.message}`);
|
|
74
|
+
recordForcedGap(projectRoot, id, transition, blockers);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Batch driver (operator review 2026-07-19 §3.5/§4.5): apply/archive/check
|
|
79
|
+
// accept multiple ids so a backlog closes without a bash loop. Each id runs
|
|
80
|
+
// independently — one failure does not stop the rest — and the exit code is
|
|
81
|
+
// the worst one seen. `new` and `abandon` stay single-id on purpose (creation
|
|
82
|
+
// wants a title; abandonment is destructive and deserves one deliberate call).
|
|
83
|
+
async function forEachId(ids, name, one) {
|
|
84
|
+
if (ids.length === 0) {
|
|
85
|
+
console.error(c.red("error:") + ` change ${name} requires at least one <id>`);
|
|
86
|
+
return 2;
|
|
87
|
+
}
|
|
88
|
+
let worst = 0;
|
|
89
|
+
for (const id of ids) {
|
|
90
|
+
if (ids.length > 1) {
|
|
91
|
+
console.log("");
|
|
92
|
+
console.log(c.bold(`──── change ${name} ${id}`));
|
|
93
|
+
}
|
|
94
|
+
let code;
|
|
95
|
+
try {
|
|
96
|
+
code = await one(id);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error(c.red("error:") + ` ${err.message}`);
|
|
99
|
+
code = 1;
|
|
100
|
+
}
|
|
101
|
+
worst = Math.max(worst, code);
|
|
102
|
+
}
|
|
103
|
+
if (ids.length > 1) {
|
|
104
|
+
console.log("");
|
|
105
|
+
console.log(worst === 0
|
|
106
|
+
? c.green("ok") + ` all ${ids.length} changes passed ${name}`
|
|
107
|
+
: c.red("fail") + ` at least one change failed ${name}`);
|
|
108
|
+
}
|
|
109
|
+
return worst;
|
|
110
|
+
}
|
|
111
|
+
|
|
39
112
|
function changeNew(args, flags) {
|
|
40
113
|
const id = args[0];
|
|
41
114
|
const title = args.slice(1).join(" ").trim();
|
|
@@ -107,7 +180,7 @@ function changeNew(args, flags) {
|
|
|
107
180
|
return 0;
|
|
108
181
|
}
|
|
109
182
|
|
|
110
|
-
function changeApply(args,
|
|
183
|
+
function changeApply(args, flags) {
|
|
111
184
|
const id = args[0];
|
|
112
185
|
if (!id) {
|
|
113
186
|
console.error(c.red("error:") + " change apply requires <id>");
|
|
@@ -122,6 +195,30 @@ function changeApply(args, _flags) {
|
|
|
122
195
|
return 1;
|
|
123
196
|
}
|
|
124
197
|
|
|
198
|
+
// Gate parity (C6). `apply` used to mutate specs with no preconditions,
|
|
199
|
+
// so `analyze` could exit 1 on a change and `apply` would write it anyway
|
|
200
|
+
// and exit 0 — through the very analyze → apply path the docs prescribe.
|
|
201
|
+
// The preconditions now come from the shared map, so what guards a
|
|
202
|
+
// transition does not depend on which command drove it.
|
|
203
|
+
if (!enforceTransition(projectRoot, id, changeDir, "apply", flags)) return 1;
|
|
204
|
+
|
|
205
|
+
// Applying twice is silent corruption. The ops verbs are ADDITIVE —
|
|
206
|
+
// `append-requirement` appends, `bump-version` bumps — so a second pass
|
|
207
|
+
// duplicates every requirement and criterion the delta carries and bumps
|
|
208
|
+
// the version again. `apply` stamps the proposal `applied` on success, so
|
|
209
|
+
// that stamp is the guard. (Found by `close` re-running apply on an
|
|
210
|
+
// already-applied change and doubling 15 requirements on this repo.)
|
|
211
|
+
const proposalPath = path.join(changeDir, "proposal.md");
|
|
212
|
+
const alreadyApplied = isFile(proposalPath)
|
|
213
|
+
&& (getHeader(read(proposalPath), "Status") ?? "").trim().toLowerCase() === "applied";
|
|
214
|
+
if (alreadyApplied && !flagBool(flags, "force", false)) {
|
|
215
|
+
console.log(c.gray("skip ") + `change "${id}" is already applied — nothing to do`);
|
|
216
|
+
console.log(c.gray("The ops verbs are additive: applying twice duplicates every"));
|
|
217
|
+
console.log(c.gray("requirement this delta carries. Re-apply anyway with --force"));
|
|
218
|
+
console.log(c.gray("only after reverting the specs to their pre-apply state."));
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
125
222
|
const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
126
223
|
const date = today();
|
|
127
224
|
let errors = 0;
|
|
@@ -246,6 +343,193 @@ function changeApply(args, _flags) {
|
|
|
246
343
|
return errors > 0 ? 1 : 0;
|
|
247
344
|
}
|
|
248
345
|
|
|
346
|
+
// Pre-close dry-run (operator review 2026-07-19 §4.4): everything analyze,
|
|
347
|
+
// apply, and archive would refuse, listed BEFORE any of them runs, with the
|
|
348
|
+
// remediation next to each finding. Read-only — the per-change `doctor`.
|
|
349
|
+
async function changeCheck(id) {
|
|
350
|
+
const projectRoot = process.cwd();
|
|
351
|
+
ensureDoctrinaProject(projectRoot);
|
|
352
|
+
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
353
|
+
if (!isDir(changeDir)) {
|
|
354
|
+
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
355
|
+
return 1;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
let failures = 0;
|
|
359
|
+
|
|
360
|
+
// 1. Structural analysis — same checks analyze runs before an apply.
|
|
361
|
+
console.log(c.gray("──── 1/3 structure (analyze)"));
|
|
362
|
+
const analyze = await import("./analyze.js");
|
|
363
|
+
if ((await analyze.run([id], new Map())) !== 0) failures += 1;
|
|
364
|
+
|
|
365
|
+
// 2. Ops dry-run: execute every MODIFIED delta's ops block against the
|
|
366
|
+
// target spec in memory. An op that would fail at apply time (missing
|
|
367
|
+
// header, no such criterion, unknown verb) is reported here, not days
|
|
368
|
+
// later; a delta with no ops block is flagged as a manual merge so the
|
|
369
|
+
// close is planned around it instead of surprised by it.
|
|
370
|
+
console.log(c.gray("──── 2/3 ops dry-run (what apply would do)"));
|
|
371
|
+
const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
372
|
+
let opsFindings = 0;
|
|
373
|
+
for (const deltaPath of deltaFiles) {
|
|
374
|
+
const text = read(deltaPath);
|
|
375
|
+
if (parseOperation(text) !== "MODIFIED") continue;
|
|
376
|
+
const capability = parseCapabilityFromDelta(text, deltaPath);
|
|
377
|
+
const rel = relPath(projectRoot, deltaPath);
|
|
378
|
+
const ops = extractOps(text);
|
|
379
|
+
if (ops.length === 0) {
|
|
380
|
+
console.log(c.yellow("⚠ ") + `${rel}: no ops block — apply will print a manual-merge pointer`);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const targetPath = path.join(projectRoot, ".doctrina", "specs", capability ?? "", "spec.md");
|
|
384
|
+
if (!capability || !exists(targetPath)) {
|
|
385
|
+
console.log(c.red("✗ ") + `${rel}: MODIFIED targets missing spec`);
|
|
386
|
+
opsFindings += 1;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const result = applyOps(read(targetPath), ops);
|
|
390
|
+
if (result.errors.length > 0) {
|
|
391
|
+
console.log(c.red("✗ ") + `${rel}: ${result.errors.length} op error${result.errors.length === 1 ? "" : "s"} (apply would refuse):`);
|
|
392
|
+
for (const e of result.errors) console.log(` - ${e}`);
|
|
393
|
+
opsFindings += 1;
|
|
394
|
+
} else {
|
|
395
|
+
console.log(c.green("✓ ") + `${rel}: ${result.applied.length} op${result.applied.length === 1 ? "" : "s"} would apply cleanly`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (deltaFiles.length === 0) console.log(c.gray("- no spec deltas"));
|
|
399
|
+
if (opsFindings > 0) failures += 1;
|
|
400
|
+
|
|
401
|
+
// 3. Archive gate preview: what archive will refuse, listed with the fix.
|
|
402
|
+
console.log(c.gray("──── 3/3 archive gate"));
|
|
403
|
+
// Preview through the SAME map the transitions enforce, so `check` can
|
|
404
|
+
// never disagree with what `apply` / `archive` will actually do (C6).
|
|
405
|
+
const archiveGate = checkTransition(projectRoot, changeDir, "archive");
|
|
406
|
+
if (archiveGate.ok) {
|
|
407
|
+
console.log(c.green("✓ ") + `archive gate clear (${archiveGate.gates.join(" + ")})`);
|
|
408
|
+
} else {
|
|
409
|
+
failures += 1;
|
|
410
|
+
for (const b of archiveGate.blockers) console.log(c.red("✗ ") + `[${b.gate}] ${b.message}`);
|
|
411
|
+
console.log(c.gray(" check them off as they land, or in bulk: ") + c.cyan(`doctrina change tick ${id} --all`));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Advisory (never gates): accepted ADRs citing the touched capabilities.
|
|
415
|
+
const caps = [...new Set(deltaFiles.map((p) => parseCapabilityFromDelta(read(p), p)).filter(Boolean))].sort();
|
|
416
|
+
console.log("");
|
|
417
|
+
printAdrCheckpoint(projectRoot, caps, { c });
|
|
418
|
+
|
|
419
|
+
console.log("");
|
|
420
|
+
if (failures === 0) {
|
|
421
|
+
console.log(c.green("ok") + ` ${id} is ready to close: ` + c.cyan(`doctrina close ${id}`));
|
|
422
|
+
return 0;
|
|
423
|
+
}
|
|
424
|
+
console.log(c.red("fail") + ` ${failures} area${failures === 1 ? "" : "s"} would block the close — fix them first`);
|
|
425
|
+
return 1;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Bulk checkbox marking (operator review 2026-07-19 §3.5: "marcar checkbox
|
|
429
|
+
// não tem comando nenhum" — it was sed/Python by hand). With no args it
|
|
430
|
+
// LISTS the unchecked boxes with ordinals; `tick <id> 2 5` checks those;
|
|
431
|
+
// `tick <id> --all` checks everything (tasks.md + the proposal's
|
|
432
|
+
// ## Verification). Ticking is a claim of completion — the honest gate is
|
|
433
|
+
// still archive/verify; this only removes the mechanical friction.
|
|
434
|
+
function changeTick(args, flags) {
|
|
435
|
+
const id = args[0];
|
|
436
|
+
if (!id) {
|
|
437
|
+
console.error(c.red("error:") + " change tick requires <id> [ordinals... | --all]");
|
|
438
|
+
return 2;
|
|
439
|
+
}
|
|
440
|
+
const projectRoot = process.cwd();
|
|
441
|
+
ensureDoctrinaProject(projectRoot);
|
|
442
|
+
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
443
|
+
if (!isDir(changeDir)) {
|
|
444
|
+
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
445
|
+
return 1;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Unified ordinal space over both files, in reading order: every unchecked
|
|
449
|
+
// `- [ ]` in tasks.md, then in the proposal's ## Verification section.
|
|
450
|
+
const files = [
|
|
451
|
+
{ label: "tasks.md", path: path.join(changeDir, "tasks.md"), section: null },
|
|
452
|
+
{ label: "proposal.md ## Verification", path: path.join(changeDir, "proposal.md"), section: "Verification" },
|
|
453
|
+
];
|
|
454
|
+
const boxes = [];
|
|
455
|
+
for (const f of files) {
|
|
456
|
+
if (!isFile(f.path)) continue;
|
|
457
|
+
const lines = read(f.path).split(/\r?\n/);
|
|
458
|
+
const inScope = f.section === null
|
|
459
|
+
? () => true
|
|
460
|
+
: (() => {
|
|
461
|
+
const scoped = new Set();
|
|
462
|
+
let inSection = false;
|
|
463
|
+
for (let i = 0; i < lines.length; i++) {
|
|
464
|
+
if (/^##\s+/.test(lines[i])) inSection = new RegExp(`^##\\s+${f.section}\\b`, "i").test(lines[i]);
|
|
465
|
+
else if (inSection) scoped.add(i);
|
|
466
|
+
}
|
|
467
|
+
return (i) => scoped.has(i);
|
|
468
|
+
})();
|
|
469
|
+
for (let i = 0; i < lines.length; i++) {
|
|
470
|
+
if (/^\s*-\s*\[ \]/.test(lines[i]) && inScope(i)) {
|
|
471
|
+
boxes.push({ file: f, lineIndex: i, text: lines[i].replace(/^\s*-\s*\[ \]\s*/, "") });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (boxes.length === 0) {
|
|
477
|
+
console.log(c.green("ok") + " no unchecked boxes in tasks.md or the proposal's ## Verification");
|
|
478
|
+
return 0;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const all = flagBool(flags, "all", false);
|
|
482
|
+
const ordinals = args.slice(1).map(Number);
|
|
483
|
+
const isPlaceholder = (box) => box.text.trim() === "";
|
|
484
|
+
if (!all && ordinals.length === 0) {
|
|
485
|
+
console.log(c.bold(`Unchecked boxes in ${id}:`));
|
|
486
|
+
console.log("");
|
|
487
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
488
|
+
const label = isPlaceholder(boxes[i])
|
|
489
|
+
? c.yellow("(scaffold placeholder — write the real task, or delete the line)")
|
|
490
|
+
: boxes[i].text;
|
|
491
|
+
console.log(` ${String(i + 1).padStart(3)}. ${label} ${c.gray(`[${boxes[i].file.label}]`)}`);
|
|
492
|
+
}
|
|
493
|
+
console.log("");
|
|
494
|
+
console.log(c.gray("tick some: ") + c.cyan(`doctrina change tick ${id} 1 3`) + c.gray(" · all: ") + c.cyan(`doctrina change tick ${id} --all`));
|
|
495
|
+
return 0;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const picked = all ? boxes.map((_, i) => i + 1) : ordinals;
|
|
499
|
+
for (const n of picked) {
|
|
500
|
+
if (!Number.isInteger(n) || n < 1 || n > boxes.length) {
|
|
501
|
+
console.error(c.red("error:") + ` no box #${n} (1..${boxes.length} — run \`doctrina change tick ${id}\` to list)`);
|
|
502
|
+
return 2;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// Ticking an empty scaffold placeholder is a meaningless claim — it is how
|
|
506
|
+
// a hollow change games the archive gate. Refuse (all-or-nothing) and name
|
|
507
|
+
// the fix; analyze/close hard-fail on the placeholders regardless.
|
|
508
|
+
const empties = picked.filter((n) => isPlaceholder(boxes[n - 1]));
|
|
509
|
+
if (empties.length > 0) {
|
|
510
|
+
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`);
|
|
511
|
+
console.error(c.gray("hint: ") + "write the real task on each line (or delete it), then tick");
|
|
512
|
+
return 2;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Group edits per file so each file is read/written once.
|
|
516
|
+
const byPath = new Map();
|
|
517
|
+
for (const n of picked) {
|
|
518
|
+
const box = boxes[n - 1];
|
|
519
|
+
if (!byPath.has(box.file.path)) byPath.set(box.file.path, []);
|
|
520
|
+
byPath.get(box.file.path).push(box);
|
|
521
|
+
}
|
|
522
|
+
for (const [filePath, hits] of byPath) {
|
|
523
|
+
const lines = read(filePath).split(/\r?\n/);
|
|
524
|
+
for (const box of hits) {
|
|
525
|
+
lines[box.lineIndex] = lines[box.lineIndex].replace(/\[ \]/, "[x]");
|
|
526
|
+
}
|
|
527
|
+
write(filePath, lines.join("\n"), { force: true });
|
|
528
|
+
}
|
|
529
|
+
console.log(c.green("ticked ") + `${picked.length} box${picked.length === 1 ? "" : "es"} in ${byPath.size} file${byPath.size === 1 ? "" : "s"}`);
|
|
530
|
+
return 0;
|
|
531
|
+
}
|
|
532
|
+
|
|
249
533
|
function changeArchive(args, flags) {
|
|
250
534
|
const id = args[0];
|
|
251
535
|
if (!id) {
|
|
@@ -266,22 +550,12 @@ function changeArchive(args, flags) {
|
|
|
266
550
|
// task (including closing steps) or any declared verification item is
|
|
267
551
|
// still unchecked. This is the difference between "boxes marked" and
|
|
268
552
|
// "verification passed" the framework was faulted for collapsing.
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
for (const b of blockers) console.error(` - ${b}`);
|
|
276
|
-
console.error(
|
|
277
|
-
c.gray("hint: ") +
|
|
278
|
-
"finish and check the items, or pass --force to archive anyway (records the gap)",
|
|
279
|
-
);
|
|
280
|
-
return 1;
|
|
281
|
-
}
|
|
282
|
-
console.log(c.yellow("warn:") + ` archiving "${id}" with verification incomplete (--force):`);
|
|
283
|
-
for (const b of blockers) console.log(c.yellow(" - ") + b);
|
|
284
|
-
}
|
|
553
|
+
// Preconditions from the shared map (C6): structure AND verification.
|
|
554
|
+
// Reaching archive directly used to skip the structural check that the
|
|
555
|
+
// `close` path always ran first, so the same state was guarded
|
|
556
|
+
// differently depending on the route taken. --force is the escape hatch
|
|
557
|
+
// for both, and records the gap.
|
|
558
|
+
if (!enforceTransition(projectRoot, id, changeDir, "archive", flags)) return 1;
|
|
285
559
|
|
|
286
560
|
const date = today();
|
|
287
561
|
const archiveName = `${date}-${id}`;
|
|
@@ -369,7 +643,7 @@ function changeArchive(args, flags) {
|
|
|
369
643
|
// and record the abandonment in the ledger so the history shows the dead end
|
|
370
644
|
// instead of a silent gap. This is the missing inverse of `change new` — the
|
|
371
645
|
// review had to delete a stray folder and hand-edit index.json (G6).
|
|
372
|
-
function changeAbandon(args, flags) {
|
|
646
|
+
async function changeAbandon(args, flags) {
|
|
373
647
|
const id = args[0];
|
|
374
648
|
if (!id) {
|
|
375
649
|
console.error(c.red("error:") + " change abandon requires <id>");
|
|
@@ -384,6 +658,31 @@ function changeAbandon(args, flags) {
|
|
|
384
658
|
return 1;
|
|
385
659
|
}
|
|
386
660
|
|
|
661
|
+
// Consent before destruction (audit item C9). This deletes work with no
|
|
662
|
+
// undo — the ledger line is good practice and is not a substitute for
|
|
663
|
+
// being asked. Off a terminal there is nobody to ask, so `--force` is
|
|
664
|
+
// required: silence is not consent.
|
|
665
|
+
const force = flagBool(flags, "force", false);
|
|
666
|
+
if (!force) {
|
|
667
|
+
const doomed = walk(changeDir);
|
|
668
|
+
console.log(c.yellow("About to delete") + ` ${relPath(projectRoot, changeDir)} — ${doomed.length} file${doomed.length === 1 ? "" : "s"}:`);
|
|
669
|
+
for (const f of doomed.slice(0, 10)) console.log(` ${c.gray(relPath(changeDir, f))}`);
|
|
670
|
+
if (doomed.length > 10) console.log(c.gray(` … and ${doomed.length - 10} more`));
|
|
671
|
+
console.log(c.gray("This cannot be undone (the change is deleted, not archived)."));
|
|
672
|
+
|
|
673
|
+
if (!isInteractive()) {
|
|
674
|
+
console.error("");
|
|
675
|
+
console.error(c.red("error:") + " refusing to delete without confirmation");
|
|
676
|
+
console.error(c.gray("hint: ") + `re-run with ${c.cyan("--force")} to abandon non-interactively`);
|
|
677
|
+
return EXIT.USAGE;
|
|
678
|
+
}
|
|
679
|
+
const ok = await confirm(`Abandon "${id}"?`, { defaultYes: false, whenNonInteractive: false });
|
|
680
|
+
if (!ok) {
|
|
681
|
+
console.log(c.gray("aborted — nothing was deleted"));
|
|
682
|
+
return EXIT.OK;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
387
686
|
const reason = flagString(flags, "reason") ?? "";
|
|
388
687
|
const date = today();
|
|
389
688
|
|
|
@@ -552,32 +851,15 @@ function collectArchiveBlockers(changeDir) {
|
|
|
552
851
|
|
|
553
852
|
const proposalPath = path.join(changeDir, "proposal.md");
|
|
554
853
|
if (exists(proposalPath)) {
|
|
555
|
-
const n = countUnchecked(
|
|
854
|
+
const n = countUnchecked(getSection(read(proposalPath), "Verification"));
|
|
556
855
|
if (n > 0) blockers.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
|
|
557
856
|
}
|
|
558
857
|
return blockers;
|
|
559
858
|
}
|
|
560
859
|
|
|
561
|
-
// Return the body of a "## <name>" section (lines after the heading up to
|
|
562
|
-
// the next "## " heading). Empty string when the section is absent.
|
|
563
|
-
function extractSection(text, name) {
|
|
564
|
-
const lines = text.split(/\r?\n/);
|
|
565
|
-
const head = new RegExp(`^##\\s+${name}\\b`, "i");
|
|
566
|
-
let inSection = false;
|
|
567
|
-
const out = [];
|
|
568
|
-
for (const line of lines) {
|
|
569
|
-
if (/^##\s+/.test(line)) {
|
|
570
|
-
inSection = head.test(line);
|
|
571
|
-
continue;
|
|
572
|
-
}
|
|
573
|
-
if (inSection) out.push(line);
|
|
574
|
-
}
|
|
575
|
-
return out.join("\n");
|
|
576
|
-
}
|
|
577
|
-
|
|
578
860
|
function ensureDoctrinaProject(projectRoot) {
|
|
579
861
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
580
|
-
throw
|
|
862
|
+
throw notADoctrinaProject();
|
|
581
863
|
}
|
|
582
864
|
}
|
|
583
865
|
|
|
@@ -589,7 +871,7 @@ Subcommands:
|
|
|
589
871
|
(--chore / --no-spec: a spec-less change for infra /
|
|
590
872
|
docs / build that still gets a proposal + ledger;
|
|
591
873
|
--design: also scaffold design.md, opt-in)
|
|
592
|
-
apply <id
|
|
874
|
+
apply <id...> Apply spec deltas: ADDED writes the full body (it
|
|
593
875
|
also REPLACES a target that is still the untouched
|
|
594
876
|
\`spec new\` scaffold — the canonical new-capability
|
|
595
877
|
flow; real content refuses), REMOVED deletes,
|
|
@@ -597,19 +879,34 @@ Subcommands:
|
|
|
597
879
|
(set-header / bump-version / set-criterion / ...),
|
|
598
880
|
MODIFIED without one prints a manual-merge pointer.
|
|
599
881
|
On any spec write the index is rebuilt from the tree.
|
|
600
|
-
archive <id
|
|
882
|
+
archive <id...> Move the change to .doctrina/changes/archive/YYYY-MM-DD-<id>/
|
|
883
|
+
check <id...> Pre-close dry-run, read-only: analyze's structural
|
|
884
|
+
checks + every ops block executed in memory against
|
|
885
|
+
its target + the archive gate preview + an advisory
|
|
886
|
+
list of accepted ADRs citing the touched capabilities.
|
|
887
|
+
Everything close would refuse, listed BEFORE it runs.
|
|
888
|
+
tick <id> [n... |--all] List the unchecked boxes (tasks.md + proposal
|
|
889
|
+
## Verification) with ordinals; tick the given ones,
|
|
890
|
+
or every one with --all. No args = list only.
|
|
601
891
|
abandon <id> Delete an open change folder and its index entry, and
|
|
602
892
|
record the abandonment in the ledger ([--reason "..."]).
|
|
603
893
|
diff <id> Preview every spec delta: line diff for MODIFIED,
|
|
604
894
|
summary for ADDED/REMOVED. Read-only.
|
|
605
895
|
|
|
896
|
+
apply / archive / check accept multiple ids (batch close of a backlog); the
|
|
897
|
+
exit code is the worst per-id result.
|
|
898
|
+
|
|
606
899
|
A MODIFIED delta may carry a fenced operations block that \`apply\` executes:
|
|
607
900
|
\`\`\`ops
|
|
608
901
|
set-header Implementation: verified — durable adapter (\`src/db.ts\`)
|
|
609
902
|
bump-version minor
|
|
610
903
|
set-criterion 1: verified
|
|
611
904
|
append-criterion [unverified] new signal — verified by \`test/x.test.ts\`
|
|
905
|
+
append-requirement event: When <trigger>, the system shall <action>.
|
|
906
|
+
replace-requirement ubiquitous 2: The system shall <action>.
|
|
612
907
|
\`\`\`
|
|
908
|
+
append-* ops resolve numbering/position at apply time, so concurrent open
|
|
909
|
+
changes appending to the same spec cannot collide.
|
|
613
910
|
|
|
614
911
|
Options:
|
|
615
912
|
--force Overwrite existing files (where applicable)
|
package/src/commands/clarify.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
4
5
|
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
5
|
-
import { flagBool } from "../lib/args.js";
|
|
6
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
6
7
|
import { c } from "../lib/colors.js";
|
|
8
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
7
9
|
|
|
8
10
|
// Two lexicons, one linter (0.11.0 field review item 3: an English-only
|
|
9
11
|
// clarify is permanently red on a PT-BR project — "some" is the verb *sumir*,
|
|
@@ -78,16 +80,35 @@ function detectLang(text) {
|
|
|
78
80
|
return pt > en ? "pt" : "en";
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
function rulesFor(projectRoot, text) {
|
|
82
|
-
const
|
|
83
|
-
const lang = declared ?? detectLang(text);
|
|
83
|
+
function rulesFor(projectRoot, text, forcedLang = null) {
|
|
84
|
+
const lang = forcedLang ?? projectLanguage(projectRoot) ?? detectLang(text);
|
|
84
85
|
return lang === "pt" ? RULES_PT : RULES_EN;
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
// --lang pt|en wins over the project config and the per-file heuristic
|
|
89
|
+
// (operator review 2026-07-19 §4.8: on a PT-BR project with no config.json,
|
|
90
|
+
// a file whose stopword count tips English gets the EN lexicon and "some" —
|
|
91
|
+
// the verb *sumir* — is flagged as the vague quantifier). One flag, zero
|
|
92
|
+
// guessing; persist it with .doctrina/config.json { "language": "pt-BR" }.
|
|
93
|
+
function forcedLang(flags) {
|
|
94
|
+
const raw = flagString(flags, "lang");
|
|
95
|
+
if (!raw || raw === true) return null;
|
|
96
|
+
const lang = String(raw).toLowerCase();
|
|
97
|
+
if (lang.startsWith("pt")) return "pt";
|
|
98
|
+
if (lang.startsWith("en")) return "en";
|
|
99
|
+
console.error(c.yellow("warn:") + ` unknown --lang "${raw}" (use pt|en) — falling back to detection`);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
104
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
105
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
106
|
+
export const flags = { boolean: ["json", "all"], string: ["lang"] };
|
|
107
|
+
|
|
87
108
|
export async function run(positional, flags) {
|
|
88
109
|
const projectRoot = process.cwd();
|
|
89
110
|
|
|
90
|
-
if (flagBool(flags, "all", false)) return clarifyAll(projectRoot);
|
|
111
|
+
if (flagBool(flags, "all", false)) return clarifyAll(projectRoot, forcedLang(flags));
|
|
91
112
|
|
|
92
113
|
const target = positional[0];
|
|
93
114
|
if (!target) {
|
|
@@ -103,7 +124,7 @@ export async function run(positional, flags) {
|
|
|
103
124
|
console.log(`clarify ${relPath(projectRoot, fullPath)}`);
|
|
104
125
|
console.log("");
|
|
105
126
|
|
|
106
|
-
const smells = scanFile(fullPath);
|
|
127
|
+
const smells = scanFile(fullPath, projectRoot, forcedLang(flags));
|
|
107
128
|
for (const s of smells) {
|
|
108
129
|
console.log(`${c.yellow("⚠")} line ${s.line}: ${s.rule} "${s.token}" — ${s.hint}`);
|
|
109
130
|
}
|
|
@@ -122,9 +143,9 @@ export async function run(positional, flags) {
|
|
|
122
143
|
// and clarify each. ADRs are immutable and the archive is history, so
|
|
123
144
|
// neither is included; AGENTS.md is dense command text, a different
|
|
124
145
|
// register from prose specs.
|
|
125
|
-
function clarifyAll(projectRoot) {
|
|
146
|
+
function clarifyAll(projectRoot, lang = null) {
|
|
126
147
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
127
|
-
throw
|
|
148
|
+
throw notADoctrinaProject();
|
|
128
149
|
}
|
|
129
150
|
const files = [];
|
|
130
151
|
const productPath = path.join(projectRoot, ".doctrina", "product.md");
|
|
@@ -149,7 +170,7 @@ function clarifyAll(projectRoot) {
|
|
|
149
170
|
|
|
150
171
|
let total = 0;
|
|
151
172
|
for (const f of files) {
|
|
152
|
-
const smells = scanFile(f);
|
|
173
|
+
const smells = scanFile(f, projectRoot, lang);
|
|
153
174
|
if (smells.length === 0) continue;
|
|
154
175
|
total += smells.length;
|
|
155
176
|
const rel = relPath(projectRoot, f);
|
|
@@ -167,10 +188,10 @@ function clarifyAll(projectRoot) {
|
|
|
167
188
|
return 1;
|
|
168
189
|
}
|
|
169
190
|
|
|
170
|
-
function scanFile(fullPath, projectRoot = process.cwd()) {
|
|
191
|
+
function scanFile(fullPath, projectRoot = process.cwd(), lang = null) {
|
|
171
192
|
const text = read(fullPath);
|
|
172
193
|
const skippedRanges = collectSkippedRanges(text);
|
|
173
|
-
const rules = rulesFor(projectRoot, text);
|
|
194
|
+
const rules = rulesFor(projectRoot, text, lang);
|
|
174
195
|
const smells = [];
|
|
175
196
|
|
|
176
197
|
// Per-line rules: regex pass with skip-range filter. A line carrying the
|
|
@@ -257,20 +278,20 @@ function findAcceptanceLine(text) {
|
|
|
257
278
|
}
|
|
258
279
|
|
|
259
280
|
export const help = `
|
|
260
|
-
Usage: doctrina clarify <path>
|
|
261
|
-
doctrina clarify --all
|
|
281
|
+
Usage: doctrina clarify <path> [--lang pt|en]
|
|
282
|
+
doctrina clarify --all [--lang pt|en]
|
|
262
283
|
|
|
263
284
|
Read a Markdown file and report weasel words, vague quantifiers,
|
|
264
285
|
placeholders, and empty Acceptance criteria sections. Exits 0 when
|
|
265
286
|
no smells are found, 1 otherwise. Matches inside fenced code
|
|
266
287
|
blocks, HTML comments, and inline backtick spans are skipped.
|
|
267
288
|
|
|
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.
|
|
289
|
+
Language-aware: --lang pt|en forces the lexicon; otherwise
|
|
290
|
+
.doctrina/config.json ({ "language": "pt-BR" }) decides, else a
|
|
291
|
+
per-file stopword count. Portuguese mode uses a PT lexicon (bare
|
|
292
|
+
TODO is the pronoun, only "TODO:" is a marker; "some"/"several" are
|
|
293
|
+
not smells). A line ending in <!-- clarify:ok --> is author-accepted
|
|
294
|
+
and never flagged — the escape hatch for a false positive.
|
|
274
295
|
|
|
275
296
|
With --all, every living document is scanned in one pass:
|
|
276
297
|
product.md, capability specs, open changes, and skills. ADRs
|