doctrina-cli 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/analyze.js +18 -2
- package/src/commands/change.js +322 -12
- package/src/commands/clarify.js +97 -11
- package/src/commands/close.js +99 -13
- package/src/commands/context.js +10 -1
- package/src/commands/coverage.js +157 -41
- package/src/commands/init.js +15 -2
- package/src/commands/intent.js +173 -0
- package/src/commands/review.js +25 -4
- package/src/commands/skill.js +46 -7
- package/src/commands/spec.js +13 -3
- package/src/commands/templates.js +113 -10
- package/src/commands/upgrade.js +133 -0
- package/src/commands/validate.js +152 -3
- package/src/commands/why.js +11 -1
- package/src/commands/work.js +105 -21
- package/src/index.js +11 -2
- package/src/lib/adr-guard.js +46 -0
- package/src/lib/commands.js +105 -4
- package/src/lib/scan.js +16 -0
- package/src/lib/spec-ops.js +143 -3
- package/templates/.gitkeep +0 -0
- package/templates/AGENTS.md.template +25 -31
- package/templates/change/spec-delta.md.template +13 -7
- package/templates/spec.md.template +9 -1
package/src/commands/validate.js
CHANGED
|
@@ -254,7 +254,12 @@ export async function run(_positional, flags) {
|
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
// 7. Every open change has a proposal
|
|
257
|
+
// 7. Every open change has a proposal, and its delta files carry a
|
|
258
|
+
// well-formed **Operation:** header. The delta used to be the one
|
|
259
|
+
// 100% hand-authored file with no early check: a missing header passed
|
|
260
|
+
// silently at creation and exploded days later in the closing analyze
|
|
261
|
+
// (operator review 2026-07-19 §3.2). Warning-level — the change is
|
|
262
|
+
// still a draft — but now every `validate` run names it.
|
|
258
263
|
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
259
264
|
if (isDir(changesDir)) {
|
|
260
265
|
const { readdirSync } = await import("node:fs");
|
|
@@ -267,6 +272,35 @@ export async function run(_positional, flags) {
|
|
|
267
272
|
`(every dir under .doctrina/changes/ needs a proposal.md — that exact filename)`,
|
|
268
273
|
);
|
|
269
274
|
}
|
|
275
|
+
// A change whose tasks are still the scaffold placeholders was opened
|
|
276
|
+
// but never planned — the agent is (or will be) implementing with no
|
|
277
|
+
// recorded plan. Early, every-run signal; analyze/close hard-fail it.
|
|
278
|
+
const tasksPath = path.join(changesDir, entry, "tasks.md");
|
|
279
|
+
if (isFile(tasksPath)) {
|
|
280
|
+
const ph = (read(tasksPath).match(/^\s*-\s*\[[ xX]\]\s*$/gm) ?? []).length;
|
|
281
|
+
if (ph > 0) {
|
|
282
|
+
warnings.push(
|
|
283
|
+
`open change "${entry}" tasks.md still carries ${ph} scaffold placeholder task${ph === 1 ? "" : "s"} — ` +
|
|
284
|
+
`the change was opened but never planned (replace them with real tasks; analyze/close refuse them)`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const deltaPath of walk(path.join(changesDir, entry, "specs"))) {
|
|
289
|
+
if (!deltaPath.endsWith("delta.md")) continue;
|
|
290
|
+
const text = read(deltaPath);
|
|
291
|
+
const m = text.match(/^\*\*Operation:\*\*\s*(\S+)/m);
|
|
292
|
+
if (!m) {
|
|
293
|
+
warnings.push(
|
|
294
|
+
`${relPath(projectRoot, deltaPath)} has no **Operation:** header — ` +
|
|
295
|
+
`analyze/apply will refuse it at close time (add "**Operation:** ADDED|MODIFIED|REMOVED")`,
|
|
296
|
+
);
|
|
297
|
+
} else if (!["ADDED", "MODIFIED", "REMOVED"].includes(m[1])) {
|
|
298
|
+
warnings.push(
|
|
299
|
+
`${relPath(projectRoot, deltaPath)} Operation "${m[1]}" is not ADDED|MODIFIED|REMOVED — ` +
|
|
300
|
+
`analyze/apply will refuse it at close time`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
270
304
|
}
|
|
271
305
|
}
|
|
272
306
|
|
|
@@ -451,8 +485,15 @@ export async function run(_positional, flags) {
|
|
|
451
485
|
if (index?.artifacts && isFile(ledgerPath)) {
|
|
452
486
|
const ledgerIds = new Set();
|
|
453
487
|
for (const line of read(ledgerPath).split(/\r?\n/)) {
|
|
454
|
-
const m = line.match(/^-\s+\d{4}-\d{2}-\d{2}\s+[—-]\s+(\S+)\s+[—-]\s
|
|
455
|
-
if (m)
|
|
488
|
+
const m = line.match(/^-\s+\d{4}-\d{2}-\d{2}\s+[—-]\s+(\S+)\s+[—-]\s+(.*)$/);
|
|
489
|
+
if (!m) continue;
|
|
490
|
+
// An abandoned change is ledger-only BY DESIGN: `change abandon`
|
|
491
|
+
// records the discard in history but deletes the folder, so there is
|
|
492
|
+
// deliberately no archive entry to cross-check. Requiring one made
|
|
493
|
+
// every abandonment turn validate permanently red (found dogfooding
|
|
494
|
+
// change 0001-review-followups).
|
|
495
|
+
if (/^abandoned\b/i.test(m[2].trim())) continue;
|
|
496
|
+
ledgerIds.add(m[1]);
|
|
456
497
|
}
|
|
457
498
|
const archiveIds = new Set((index.artifacts.changes_archive ?? []).map((ch) => ch.id));
|
|
458
499
|
for (const id of archiveIds) {
|
|
@@ -523,6 +564,14 @@ export async function run(_positional, flags) {
|
|
|
523
564
|
}
|
|
524
565
|
}
|
|
525
566
|
|
|
567
|
+
// Project rules — permanent, lintable constraints (.doctrina/rules.json;
|
|
568
|
+
// 0.11.0 field review item 12: "never reference company X" lived only in
|
|
569
|
+
// agent memory, and the remaining mentions sailed through every gate). Each
|
|
570
|
+
// rule is a forbid-regex over glob-scoped paths; a match is an ERROR with
|
|
571
|
+
// the rule's own message. Deterministic, zero-deps, and the instruction
|
|
572
|
+
// survives sessions because it is an artifact, not a memory.
|
|
573
|
+
for (const line of checkProjectRules(projectRoot)) errors.push(line);
|
|
574
|
+
|
|
526
575
|
// Output
|
|
527
576
|
if (flagBool(flags, "json", false)) {
|
|
528
577
|
console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings }, null, 2));
|
|
@@ -541,6 +590,106 @@ export async function run(_positional, flags) {
|
|
|
541
590
|
return errors.length === 0 ? 0 : 1;
|
|
542
591
|
}
|
|
543
592
|
|
|
593
|
+
// Enforce .doctrina/rules.json — permanent project constraints as
|
|
594
|
+
// forbid-regexes over glob-scoped paths:
|
|
595
|
+
// { "rules": [ { "id": "white-label", "forbid": "\\bAcmeCorp\\b",
|
|
596
|
+
// "paths": ["src/**", ".doctrina/specs/**"],
|
|
597
|
+
// "message": "white-label product; use a generic placeholder" } ] }
|
|
598
|
+
// Returns error strings (one per offending file+rule, capped per rule so a
|
|
599
|
+
// mass violation stays readable). Missing/invalid file → no rules (an
|
|
600
|
+
// invalid JSON is reported once). Binary-ish and vendored dirs are skipped
|
|
601
|
+
// by the same bounded walk validate already uses elsewhere.
|
|
602
|
+
const RULES_SKIP_DIRS = new Set([
|
|
603
|
+
".git", "node_modules", "vendor", "dist", "build", "out", "target",
|
|
604
|
+
".venv", "venv", "__pycache__", ".next", "coverage",
|
|
605
|
+
]);
|
|
606
|
+
const RULES_MAX_HITS_PER_RULE = 10;
|
|
607
|
+
|
|
608
|
+
function checkProjectRules(projectRoot) {
|
|
609
|
+
const rulesPath = path.join(projectRoot, ".doctrina", "rules.json");
|
|
610
|
+
if (!isFile(rulesPath)) return [];
|
|
611
|
+
let cfg;
|
|
612
|
+
try {
|
|
613
|
+
cfg = JSON.parse(read(rulesPath));
|
|
614
|
+
} catch (err) {
|
|
615
|
+
return [`.doctrina/rules.json is not valid JSON: ${err.message}`];
|
|
616
|
+
}
|
|
617
|
+
const rules = Array.isArray(cfg?.rules) ? cfg.rules : [];
|
|
618
|
+
const out = [];
|
|
619
|
+
const compiled = [];
|
|
620
|
+
for (const r of rules) {
|
|
621
|
+
if (!r || typeof r.forbid !== "string" || !r.forbid) {
|
|
622
|
+
out.push(`.doctrina/rules.json: rule "${r?.id ?? "?"}" needs a non-empty "forbid" regex`);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
let re;
|
|
626
|
+
try {
|
|
627
|
+
re = new RegExp(r.forbid, "m");
|
|
628
|
+
} catch (err) {
|
|
629
|
+
out.push(`.doctrina/rules.json: rule "${r.id ?? r.forbid}" has an invalid regex: ${err.message}`);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
const paths = Array.isArray(r.paths) && r.paths.length ? r.paths : ["**"];
|
|
633
|
+
compiled.push({ id: r.id ?? r.forbid, re, matchers: paths.map(globToRegExp), message: r.message ?? "", hits: 0 });
|
|
634
|
+
}
|
|
635
|
+
if (compiled.length === 0) return out;
|
|
636
|
+
|
|
637
|
+
// One bounded walk; each text file is read at most once.
|
|
638
|
+
const stack = [projectRoot];
|
|
639
|
+
while (stack.length) {
|
|
640
|
+
const dir = stack.pop();
|
|
641
|
+
let entries;
|
|
642
|
+
try {
|
|
643
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
644
|
+
} catch {
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
for (const entry of entries) {
|
|
648
|
+
const full = path.join(dir, entry.name);
|
|
649
|
+
if (entry.isDirectory()) {
|
|
650
|
+
if (!RULES_SKIP_DIRS.has(entry.name)) stack.push(full);
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
const rel = relPath(projectRoot, full).replace(/\\/g, "/");
|
|
654
|
+
if (rel === ".doctrina/rules.json") continue; // the rule text itself always matches
|
|
655
|
+
const applicable = compiled.filter((cr) => cr.hits < RULES_MAX_HITS_PER_RULE && cr.matchers.some((m) => m.test(rel)));
|
|
656
|
+
if (applicable.length === 0) continue;
|
|
657
|
+
let text;
|
|
658
|
+
try {
|
|
659
|
+
text = read(full);
|
|
660
|
+
} catch {
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
for (const cr of applicable) {
|
|
664
|
+
const m = cr.re.exec(text);
|
|
665
|
+
if (m) {
|
|
666
|
+
cr.hits += 1;
|
|
667
|
+
const lineNo = text.slice(0, m.index).split("\n").length;
|
|
668
|
+
out.push(`rule "${cr.id}": ${rel}:${lineNo} matches forbidden pattern${cr.message ? ` — ${cr.message}` : ""}`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
for (const cr of compiled) {
|
|
674
|
+
if (cr.hits >= RULES_MAX_HITS_PER_RULE) {
|
|
675
|
+
out.push(`rule "${cr.id}": more matches suppressed after ${RULES_MAX_HITS_PER_RULE} files`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return out;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Minimal glob → RegExp: ** crosses directories, * stays within a segment.
|
|
682
|
+
// Anchored to the whole repo-relative POSIX path.
|
|
683
|
+
function globToRegExp(glob) {
|
|
684
|
+
const esc = String(glob).replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
685
|
+
const body = esc
|
|
686
|
+
.replace(/\*\*\//g, "(?:.*/)?")
|
|
687
|
+
.replace(/\*\*/g, ".*")
|
|
688
|
+
.replace(/\*/g, "[^/]*")
|
|
689
|
+
.replace(/\?/g, "[^/]");
|
|
690
|
+
return new RegExp(`^${body}$`);
|
|
691
|
+
}
|
|
692
|
+
|
|
544
693
|
// Per-entry metadata drift between the on-disk index and the tree-derived
|
|
545
694
|
// one, for entries present in BOTH. Presence drift (an artifact on disk but
|
|
546
695
|
// not indexed, or indexed but missing) is left to the orphan warning and the
|
package/src/commands/why.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 } from "../lib/fs-ops.js";
|
|
5
|
-
import { specHeader, listHeader } from "../lib/scan.js";
|
|
5
|
+
import { specHeader, listHeader, parseDependsOn } from "../lib/scan.js";
|
|
6
6
|
import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
|
|
7
7
|
import * as idx from "../lib/index-json.js";
|
|
8
8
|
import { c } from "../lib/colors.js";
|
|
@@ -75,6 +75,16 @@ export async function run(positional, _flags) {
|
|
|
75
75
|
const purpose = sectionParagraph(text, "Purpose");
|
|
76
76
|
if (purpose) console.log(" " + purpose.replace(/\s+/g, " ").trim());
|
|
77
77
|
|
|
78
|
+
// Dependency graph, both directions (the machine-readable **Depends on:**
|
|
79
|
+
// header — specs used to cite each other only in prose, invisible here).
|
|
80
|
+
const deps = parseDependsOn(text);
|
|
81
|
+
const dependents = known
|
|
82
|
+
.filter((other) => other !== cap)
|
|
83
|
+
.filter((other) => parseDependsOn(read(path.join(specsDir, other, "spec.md"))).includes(cap))
|
|
84
|
+
.sort();
|
|
85
|
+
if (deps.length > 0) console.log(` ${c.gray("depends on:")} ${deps.map((d) => c.cyan(d)).join(", ")}`);
|
|
86
|
+
if (dependents.length > 0) console.log(` ${c.gray("depended on by:")} ${dependents.map((d) => c.cyan(d)).join(", ")}`);
|
|
87
|
+
|
|
78
88
|
// 3. The proof (acceptance criteria with cited evidence). Read through the
|
|
79
89
|
// shared multi-line parser so the marks here agree with `doctrina
|
|
80
90
|
// coverage` — a criterion that cites its proof on a continuation line is
|
package/src/commands/work.js
CHANGED
|
@@ -2,11 +2,12 @@ import path from "node:path";
|
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
5
|
+
import { exists, isDir, isFile, mkdirp, read, relPath, write } from "../lib/fs-ops.js";
|
|
6
6
|
import { flagString, flagBool } from "../lib/args.js";
|
|
7
7
|
import * as idx from "../lib/index-json.js";
|
|
8
8
|
import { c } from "../lib/colors.js";
|
|
9
9
|
import { assessBrief } from "../lib/clarity.js";
|
|
10
|
+
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
10
11
|
import { changeNew } from "./change.js";
|
|
11
12
|
|
|
12
13
|
// `work` is the second half of the no-ceremony path (ADR 0005): a brief
|
|
@@ -71,14 +72,18 @@ export async function run(positional, flags) {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
const effPrompt = prompt || `backfill specs from ${files.length} changed file${files.length === 1 ? "" : "s"}`;
|
|
74
|
-
|
|
75
|
+
// --title separates the short display name from the full brief: the title
|
|
76
|
+
// drives the slug and the proposal H1; the whole prompt still lands, intact,
|
|
77
|
+
// under ## Why. Without it a long prompt used to become a 900-char H1.
|
|
78
|
+
const title = flagString(flags, "title") ?? effPrompt;
|
|
79
|
+
const slug = prompt || flags.has("title") ? slugify(title) : "backfill";
|
|
75
80
|
const id = flagString(flags, "id") ?? `${nextChangeNumber(projectRoot)}-${slug}`;
|
|
76
81
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
|
|
77
82
|
console.error(c.red("error:") + ` invalid change id "${id}" (lowercase letters, digits, hyphens)`);
|
|
78
83
|
return 2;
|
|
79
84
|
}
|
|
80
85
|
|
|
81
|
-
const code = changeNew([id,
|
|
86
|
+
const code = changeNew([id, title], flags);
|
|
82
87
|
if (code !== 0) return code;
|
|
83
88
|
|
|
84
89
|
// Record the prompt verbatim as the change's Why — the raw intent has
|
|
@@ -102,6 +107,26 @@ export async function run(positional, flags) {
|
|
|
102
107
|
if (updated !== txt) write(proposalPath, updated, { force: true });
|
|
103
108
|
}
|
|
104
109
|
|
|
110
|
+
// A pinned capability is an explicit statement of intent, so the delta —
|
|
111
|
+
// historically the only 100% hand-authored file in the flow, and the one
|
|
112
|
+
// whose missing **Operation:** header exploded days later at analyze
|
|
113
|
+
// (operator review 2026-07-19 §3.2) — is scaffolded from the template with
|
|
114
|
+
// the Operation prefilled: MODIFIED when the spec exists, ADDED when it
|
|
115
|
+
// does not. Never scaffolded from a ranked GUESS (that would put a wrong
|
|
116
|
+
// capability's delta in the change); only from --capability.
|
|
117
|
+
if (pinned && !chore) {
|
|
118
|
+
const deltaPath = path.join(projectRoot, ".doctrina", "changes", id, "specs", pinned, "delta.md");
|
|
119
|
+
if (!exists(deltaPath)) {
|
|
120
|
+
const tpl = read(path.join(locateTemplatesDir(), "change", "spec-delta.md.template"));
|
|
121
|
+
const op = isFile(path.join(projectRoot, ".doctrina", "specs", pinned, "spec.md")) ? "MODIFIED" : "ADDED";
|
|
122
|
+
const body = substitute(tpl, { CAPABILITY: pinned })
|
|
123
|
+
.replace(/^\*\*Operation:\*\*.*$/m, `**Operation:** ${op}`);
|
|
124
|
+
mkdirp(path.dirname(deltaPath));
|
|
125
|
+
write(deltaPath, body);
|
|
126
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, deltaPath)}` + c.gray(` (Operation: ${op} prefilled)`));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
105
130
|
// Capability hint: term overlap for a prompt, changed-file overlap for a diff
|
|
106
131
|
// (review F10 — rank by what the working tree touched, not just prompt words).
|
|
107
132
|
const allChanged = fromDiff ? files : changedFiles(projectRoot);
|
|
@@ -110,6 +135,16 @@ export async function run(positional, flags) {
|
|
|
110
135
|
const capability = pinned ?? matches[0]?.id ?? null;
|
|
111
136
|
const clarity = assessBrief(effPrompt, { kind: "prompt" });
|
|
112
137
|
|
|
138
|
+
// --quiet: registering backlog, not starting now (operator review §3.7 — 19
|
|
139
|
+
// works printed 19 identical 50-line playbooks). One line per change; the
|
|
140
|
+
// playbook is reprintable on demand with --resume.
|
|
141
|
+
if (flagBool(flags, "quiet", false)) {
|
|
142
|
+
console.log("");
|
|
143
|
+
console.log(c.green("opened ") + c.cyan(id) +
|
|
144
|
+
c.gray(` — playbook on demand: doctrina work --resume ${id}`));
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
113
148
|
console.log("");
|
|
114
149
|
if (chore) {
|
|
115
150
|
printChorePlaybook(projectRoot, { id, prompt: effPrompt });
|
|
@@ -228,6 +263,9 @@ function resumeChange(projectRoot, resumeId) {
|
|
|
228
263
|
}
|
|
229
264
|
|
|
230
265
|
// ASCII-folded kebab-case of the prompt ("Faça o login" -> "faca-o-login").
|
|
266
|
+
// Over-long slugs are cut at a WORD boundary, not mid-token — a 900-char
|
|
267
|
+
// prompt used to produce folder names ending in a half word ("...-scoped-c"),
|
|
268
|
+
// which then leaks into every path, ledger line, and archive name.
|
|
231
269
|
export function slugify(text) {
|
|
232
270
|
let slug = text
|
|
233
271
|
.normalize("NFD")
|
|
@@ -235,7 +273,11 @@ export function slugify(text) {
|
|
|
235
273
|
.toLowerCase()
|
|
236
274
|
.replace(/[^a-z0-9]+/g, "-")
|
|
237
275
|
.replace(/^-+|-+$/g, "");
|
|
238
|
-
if (slug.length > 48)
|
|
276
|
+
if (slug.length > 48) {
|
|
277
|
+
const cut = slug.slice(0, 49); // one past the cap: a "-" here means 48 ends a word
|
|
278
|
+
const boundary = cut.lastIndexOf("-");
|
|
279
|
+
slug = (boundary > 0 ? cut.slice(0, boundary) : cut.slice(0, 48)).replace(/-+$/g, "");
|
|
280
|
+
}
|
|
239
281
|
return slug.length > 0 ? slug : "task";
|
|
240
282
|
}
|
|
241
283
|
|
|
@@ -292,7 +334,7 @@ export function rankCapabilities(projectRoot, prompt) {
|
|
|
292
334
|
// file scores its capability when the file sits under a path segment named for
|
|
293
335
|
// it, or when the spec cites the file as evidence. A deterministic overlap
|
|
294
336
|
// hint for the agent — never a decision (ADR 0005).
|
|
295
|
-
export function rankCapabilitiesByDiff(projectRoot, files) {
|
|
337
|
+
export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3 } = {}) {
|
|
296
338
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
297
339
|
if (!files || files.length === 0 || !isDir(specsDir)) return [];
|
|
298
340
|
const norm = files.map((f) => f.replace(/\\/g, "/"));
|
|
@@ -310,7 +352,10 @@ export function rankCapabilitiesByDiff(projectRoot, files) {
|
|
|
310
352
|
}
|
|
311
353
|
if (score > 0) ranked.push({ id: cap, score, path: `.doctrina/specs/${cap}/spec.md` });
|
|
312
354
|
}
|
|
313
|
-
|
|
355
|
+
// `limit` keeps the work-playbook hint short (top 3); review passes
|
|
356
|
+
// Infinity — truncating there is how it missed 5 of 8 touched capabilities
|
|
357
|
+
// in the 0.11.0 field session.
|
|
358
|
+
return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, limit);
|
|
314
359
|
}
|
|
315
360
|
|
|
316
361
|
// A chore is a spec-less change (review F9): the playbook drops the spec-delta
|
|
@@ -325,6 +370,8 @@ function printChorePlaybook(projectRoot, { id, prompt }) {
|
|
|
325
370
|
console.log("");
|
|
326
371
|
console.log(`1. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
|
|
327
372
|
console.log(" with small, checkable steps; implement them, checking each box.");
|
|
373
|
+
console.log(c.gray(" (Do it before implementing — analyze/close refuse leftover scaffold"));
|
|
374
|
+
console.log(c.gray(" placeholders, and `change tick` will not tick an empty box.)"));
|
|
328
375
|
console.log("2. Prove it (archive refuses unchecked boxes):");
|
|
329
376
|
console.log(` ${c.cyan("doctrina verify")} — the project's typecheck/test/build gate`);
|
|
330
377
|
console.log(` ${c.cyan("doctrina verify --clean")} — clean-checkout reproducibility lint`);
|
|
@@ -392,8 +439,14 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
392
439
|
console.log(c.gray(" \"**Realizes:** SC1\" header (or \"n/a — <why>\"). `doctrina validate` warns"));
|
|
393
440
|
console.log(c.gray(" on an active spec with no Realizes; `doctrina trace` reports the link."));
|
|
394
441
|
console.log("");
|
|
395
|
-
|
|
396
|
-
|
|
442
|
+
if (pinned) {
|
|
443
|
+
console.log(`3. A delta is already scaffolded (Operation prefilled) at`);
|
|
444
|
+
console.log(` .doctrina/changes/${id}/specs/${pinned}/delta.md — fill its body.`);
|
|
445
|
+
console.log(" Add one more delta per additional affected capability:");
|
|
446
|
+
} else {
|
|
447
|
+
console.log("3. Write one delta per affected capability at");
|
|
448
|
+
console.log(` .doctrina/changes/${id}/specs/<capability>/delta.md:`);
|
|
449
|
+
}
|
|
397
450
|
console.log(c.gray(" # Spec Delta — capability: <capability>"));
|
|
398
451
|
console.log(c.gray(" **Operation:** ADDED | MODIFIED | REMOVED"));
|
|
399
452
|
console.log(c.gray(" **Target spec on apply:** `.doctrina/specs/<capability>/spec.md`"));
|
|
@@ -401,6 +454,20 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
401
454
|
console.log(c.gray(" <EARS body. Keep the two axes honest (Status vs Implementation),"));
|
|
402
455
|
console.log(c.gray(" keep aspiration under ## Maturity → Future, and cite the proof per"));
|
|
403
456
|
console.log(c.gray(" criterion: \"1. [unverified] <signal> — verified by `path/to/test`\">"));
|
|
457
|
+
console.log(c.gray(" New capability? `spec new` then an ADDED delta with the FULL body — apply"));
|
|
458
|
+
console.log(c.gray(" replaces the untouched scaffold. Bookkeeping edits? MODIFIED with an ops"));
|
|
459
|
+
console.log(c.gray(" block, applied mechanically (all ops or none):"));
|
|
460
|
+
console.log(c.gray(" ```ops"));
|
|
461
|
+
console.log(c.gray(" set-header Implementation: verified — `src/x.js`"));
|
|
462
|
+
console.log(c.gray(" bump-version minor"));
|
|
463
|
+
console.log(c.gray(" set-criterion 1: verified"));
|
|
464
|
+
console.log(c.gray(" append-criterion [unverified] new signal — verified by `test/y.test.js`"));
|
|
465
|
+
console.log(c.gray(" append-requirement event: When <trigger>, the system shall <action>."));
|
|
466
|
+
console.log(c.gray(" replace-requirement ubiquitous 2: The system shall <action>."));
|
|
467
|
+
console.log(c.gray(" ```"));
|
|
468
|
+
console.log(c.gray(" append-* ops number/position at APPLY time, so concurrent changes"));
|
|
469
|
+
console.log(c.gray(" appending to the same spec never collide. Only free-prose rewrites"));
|
|
470
|
+
console.log(c.gray(" (Purpose, Maturity, ...) stay a by-hand merge."));
|
|
404
471
|
if (fromDiff) {
|
|
405
472
|
console.log(c.gray(" --from-diff: the code already exists — describe its CURRENT behaviour, and"));
|
|
406
473
|
console.log(c.gray(" mark each criterion [unverified] until a test proves it (don't assume the"));
|
|
@@ -408,25 +475,35 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
408
475
|
}
|
|
409
476
|
console.log("");
|
|
410
477
|
console.log(`4. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
|
|
411
|
-
console.log(" with small, checkable implementation tasks (a few hours each, max)
|
|
478
|
+
console.log(" with small, checkable implementation tasks (a few hours each, max),");
|
|
479
|
+
console.log(" and record the change's What/Scope in its proposal.md. Do this BEFORE");
|
|
480
|
+
console.log(" implementing — analyze and close refuse a change whose scaffold");
|
|
481
|
+
console.log(" placeholders were never replaced, and `change tick` will not tick them.");
|
|
412
482
|
console.log("");
|
|
413
483
|
console.log("5. Implement task by task, checking each box as it lands. Advance the");
|
|
414
484
|
console.log(" spec's Implementation: planned → partial → implemented as code lands.");
|
|
415
485
|
console.log(" If the prompt is genuinely ambiguous, ask the user before assuming.");
|
|
416
486
|
console.log("");
|
|
417
|
-
console.log(`6.
|
|
418
|
-
console.log(
|
|
419
|
-
console.log(
|
|
420
|
-
console.log(
|
|
421
|
-
console.log(
|
|
422
|
-
console.log(
|
|
423
|
-
console.log("
|
|
487
|
+
console.log(`6. ADR checkpoint — does this change decide something structural`);
|
|
488
|
+
console.log(" (an architecture, a boundary, a trade-off a future session must");
|
|
489
|
+
console.log(` not relitigate)? If yes: ${c.cyan("doctrina decision new \"<title>\"")} now,`);
|
|
490
|
+
console.log(" before closing — the definition of done requires it recorded.");
|
|
491
|
+
console.log(c.gray(" (close re-checks this: it warns when the change touches capabilities"));
|
|
492
|
+
console.log(c.gray(" cited by an accepted ADR — amend via decision supersede, not silence.)"));
|
|
493
|
+
console.log(" Touching an integration surface (ports, env vars, public endpoints)?");
|
|
494
|
+
console.log(` Own it in a contract: ${c.cyan("doctrina contract new <id>")} · ${c.cyan("doctrina contract check")}.`);
|
|
495
|
+
console.log("");
|
|
496
|
+
console.log(`7. Close in one attested pass (preferred — runs every gate and stops`);
|
|
497
|
+
console.log(" at the first failure with the exact rerun command):");
|
|
498
|
+
console.log(` ${c.cyan(`doctrina close ${id}`)}`);
|
|
499
|
+
console.log(c.gray(" (equivalent, step by step: ") +
|
|
500
|
+
c.gray(`analyze → change apply → verify → coverage → trace → change archive → validate)`));
|
|
501
|
+
console.log(" Before it: check the proposal's ## Verification boxes and every task,");
|
|
424
502
|
console.log(" closing steps included, and bump Implementation to verified.");
|
|
425
503
|
console.log("");
|
|
426
|
-
console.log(`
|
|
427
|
-
console.log(
|
|
428
|
-
console.log(c.gray("
|
|
429
|
-
console.log(c.gray(" a recurring convention), capture it: ") + c.cyan("doctrina skill new <slug>") + c.gray("."));
|
|
504
|
+
console.log(`8. ${c.cyan("doctrina next")} for the follow-up.`);
|
|
505
|
+
console.log(c.gray(" If this change taught a reusable lesson (a fix you'd hate to relearn,"));
|
|
506
|
+
console.log(c.gray(" a recurring convention), capture it: ") + c.cyan("doctrina skill new <slug>") + c.gray("."));
|
|
430
507
|
}
|
|
431
508
|
|
|
432
509
|
export const help = `
|
|
@@ -447,7 +524,14 @@ already open, work suggests resuming it instead of opening a junk change
|
|
|
447
524
|
named after that word (review G1). Use --resume to do so directly.
|
|
448
525
|
|
|
449
526
|
Options:
|
|
450
|
-
--
|
|
527
|
+
--title "<short>" Short display title: drives the slug and the proposal
|
|
528
|
+
H1; the full prompt still lands under ## Why
|
|
529
|
+
--capability <cap> Pin the capability instead of ranking matches. Also
|
|
530
|
+
scaffolds specs/<cap>/delta.md in the change with the
|
|
531
|
+
Operation header prefilled (MODIFIED when the spec
|
|
532
|
+
exists, ADDED when it does not)
|
|
533
|
+
--quiet Register the change and print one line — no playbook
|
|
534
|
+
(backlog entry; reprint later with --resume <id>)
|
|
451
535
|
--id <id> Override the derived change id
|
|
452
536
|
--resume <id> Reprint the playbook for an open change; create nothing
|
|
453
537
|
--from-diff Backfill: scaffold from the working-tree changes (no
|
package/src/index.js
CHANGED
|
@@ -39,6 +39,8 @@ import * as show from "./commands/show.js";
|
|
|
39
39
|
import * as doctor from "./commands/doctor.js";
|
|
40
40
|
import * as report from "./commands/report.js";
|
|
41
41
|
import * as completion from "./commands/completion.js";
|
|
42
|
+
import * as intent from "./commands/intent.js";
|
|
43
|
+
import * as upgrade from "./commands/upgrade.js";
|
|
42
44
|
|
|
43
45
|
const COMMANDS = {
|
|
44
46
|
init, spec, change, decision, validate, hooks, analyze, clarify,
|
|
@@ -46,6 +48,7 @@ const COMMANDS = {
|
|
|
46
48
|
intake, work, coverage, verify, contract, trace,
|
|
47
49
|
status, close, review, watch, why, constitution,
|
|
48
50
|
prime, handoff, show, doctor, report, completion,
|
|
51
|
+
intent, upgrade,
|
|
49
52
|
};
|
|
50
53
|
|
|
51
54
|
const TOP_HELP = `
|
|
@@ -60,11 +63,17 @@ Global flags:
|
|
|
60
63
|
`;
|
|
61
64
|
|
|
62
65
|
async function main(argv) {
|
|
66
|
+
// `--version` is only "print the CLI version" when NO command is given.
|
|
67
|
+
// With a command it is that command's flag to consume — the old global
|
|
68
|
+
// intercept made `doctrina spec set x --version 0.10.0` print the CLI
|
|
69
|
+
// version and exit, looking exactly like the spec's version had been set
|
|
70
|
+
// (0.11.0 field-review papercut). `-v` stays global either way.
|
|
71
|
+
const versionOnly = argv.every((t) => t === "--version" || t === "-v") && argv.length > 0;
|
|
63
72
|
const { positional, flags } = parseArgs(argv, {
|
|
64
|
-
boolean: ["help", "h", "
|
|
73
|
+
boolean: ["help", "h", "v", "force", "non-interactive", "check", "save", "bug", "write", "all", "concat", "archive", "strict", "list", "init", "fix", "once", "clean", "json"],
|
|
65
74
|
});
|
|
66
75
|
|
|
67
|
-
if (
|
|
76
|
+
if (versionOnly || flags.get("v") === true) {
|
|
68
77
|
console.log(cliVersion());
|
|
69
78
|
return 0;
|
|
70
79
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { isDir, read, walk } from "./fs-ops.js";
|
|
3
|
+
import { listHeader } from "./scan.js";
|
|
4
|
+
|
|
5
|
+
// Deterministic ADR checkpoint (operator review 2026-07-19 §4.6). The work
|
|
6
|
+
// playbook's "record an ADR" step was ignorable in silence: a change that
|
|
7
|
+
// flexibilised an accepted ADR closed with no amendment and nothing barked.
|
|
8
|
+
// This helper names the accepted ADRs whose text cites any of the change's
|
|
9
|
+
// touched capabilities, so `change check` and `close` can print a loud,
|
|
10
|
+
// advisory reminder: if the change alters what the ADR decided, record it
|
|
11
|
+
// (`decision supersede` / `decision new`) instead of drifting past it.
|
|
12
|
+
// A word match on capability names — a hint, never a decision (ADR 0005),
|
|
13
|
+
// and never a blocker (an ADR merely MENTIONING a capability is normal).
|
|
14
|
+
export function adrAdvisories(projectRoot, capabilities) {
|
|
15
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
16
|
+
if (!isDir(adrDir) || capabilities.length === 0) return [];
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const f of walk(adrDir)) {
|
|
19
|
+
const base = path.basename(f);
|
|
20
|
+
const m = base.match(/^(\d{4})-.*\.md$/);
|
|
21
|
+
if (!m) continue;
|
|
22
|
+
const text = read(f);
|
|
23
|
+
if ((listHeader(text, "Status") ?? "").toLowerCase() !== "accepted") continue;
|
|
24
|
+
const caps = capabilities.filter((cap) =>
|
|
25
|
+
new RegExp(`(^|[^a-z0-9-])${cap.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z0-9-]|$)`, "im").test(text),
|
|
26
|
+
);
|
|
27
|
+
if (caps.length === 0) continue;
|
|
28
|
+
const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
|
|
29
|
+
out.push({ id: m[1], title: titleMatch ? titleMatch[1].trim() : base, caps });
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Print the advisory block shared by `change check` and `close`. Returns the
|
|
35
|
+
// number of ADRs flagged (informational; callers never gate on it).
|
|
36
|
+
export function printAdrCheckpoint(projectRoot, capabilities, { c }) {
|
|
37
|
+
const hits = adrAdvisories(projectRoot, capabilities);
|
|
38
|
+
if (hits.length === 0) return 0;
|
|
39
|
+
console.log(c.yellow("ADR checkpoint") + c.gray(" — accepted ADRs cite the touched capabilities:"));
|
|
40
|
+
for (const h of hits) {
|
|
41
|
+
console.log(` ADR ${c.cyan(h.id)} ${h.title} ${c.gray(`(cites: ${h.caps.join(", ")})`)}`);
|
|
42
|
+
}
|
|
43
|
+
console.log(c.gray(" If this change alters what an ADR decided, record it now:"));
|
|
44
|
+
console.log(c.gray(" ") + c.cyan("doctrina decision supersede <n>") + c.gray(" · ") + c.cyan("doctrina decision new \"<title>\""));
|
|
45
|
+
return hits.length;
|
|
46
|
+
}
|