doctrina-cli 0.11.0 → 0.12.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 +8 -2
- package/src/commands/change.js +78 -7
- package/src/commands/clarify.js +76 -4
- package/src/commands/close.js +42 -9
- package/src/commands/context.js +10 -1
- package/src/commands/coverage.js +157 -41
- 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/upgrade.js +129 -0
- package/src/commands/validate.js +117 -2
- package/src/commands/why.js +11 -1
- package/src/commands/work.js +45 -16
- package/src/index.js +11 -2
- package/src/lib/commands.js +5 -2
- package/src/lib/scan.js +16 -0
- package/templates/AGENTS.md.template +8 -4
- package/templates/spec.md.template +9 -1
package/src/commands/spec.js
CHANGED
|
@@ -134,9 +134,10 @@ function specSet(args, flags) {
|
|
|
134
134
|
const impl = flagString(flags, "implementation");
|
|
135
135
|
const status = flagString(flags, "status");
|
|
136
136
|
const bump = flagString(flags, "bump");
|
|
137
|
+
const versionExplicit = flagString(flags, "version");
|
|
137
138
|
const criterion = flagString(flags, "criterion");
|
|
138
|
-
if (impl === undefined && status === undefined && bump === undefined && criterion === undefined) {
|
|
139
|
-
console.error(c.red("error:") + " spec set needs at least one of --implementation, --status, --bump, --criterion");
|
|
139
|
+
if (impl === undefined && status === undefined && bump === undefined && versionExplicit === undefined && criterion === undefined) {
|
|
140
|
+
console.error(c.red("error:") + " spec set needs at least one of --implementation, --status, --bump, --version, --criterion");
|
|
140
141
|
console.error(c.gray("hint: ") + `example: doctrina spec set ${capability} --implementation "verified — \`src/x.js\`" --bump minor`);
|
|
141
142
|
return 2;
|
|
142
143
|
}
|
|
@@ -156,6 +157,10 @@ function specSet(args, flags) {
|
|
|
156
157
|
if (!["major", "minor", "patch"].includes(bump)) errors.push(`--bump needs major|minor|patch (got "${bump}")`);
|
|
157
158
|
else apply(bumpVersion(text, bump));
|
|
158
159
|
}
|
|
160
|
+
if (versionExplicit !== undefined) {
|
|
161
|
+
if (!/^\d+\.\d+\.\d+$/.test(versionExplicit)) errors.push(`--version needs semver X.Y.Z (got "${versionExplicit}")`);
|
|
162
|
+
else apply(setHeader(text, "Version", versionExplicit));
|
|
163
|
+
}
|
|
159
164
|
if (criterion !== undefined) {
|
|
160
165
|
const m = String(criterion).match(/^(\d+)\s*:\s*(.+)$/);
|
|
161
166
|
if (!m) errors.push(`--criterion needs "<n>:<mark>" (got "${criterion}")`);
|
|
@@ -183,7 +188,10 @@ function specSet(args, flags) {
|
|
|
183
188
|
const derived = deriveIndex(projectRoot, index);
|
|
184
189
|
derived.last_updated = date;
|
|
185
190
|
idx.save(projectRoot, derived);
|
|
186
|
-
|
|
191
|
+
// Echo the SPEC's resulting version, not the framework's — the two look
|
|
192
|
+
// identical in output and the ambiguity was a field-review papercut.
|
|
193
|
+
const specVersion = specHeader(text, "Version") ?? "—";
|
|
194
|
+
console.log(c.green("indexed") + ` spec "${capability}" synced (spec version ${specVersion})`);
|
|
187
195
|
return 0;
|
|
188
196
|
}
|
|
189
197
|
|
|
@@ -214,6 +222,8 @@ Options:
|
|
|
214
222
|
--implementation "..." With \`set\`: set the Implementation header.
|
|
215
223
|
--status "..." With \`set\`: set the Status header.
|
|
216
224
|
--bump major|minor|patch With \`set\`: bump the spec Version.
|
|
225
|
+
--version X.Y.Z With \`set\`: set the spec Version explicitly
|
|
226
|
+
(the output echoes the SPEC version, not the CLI's).
|
|
217
227
|
--criterion "<n>:<mark>" With \`set\`: set criterion n's [mark]
|
|
218
228
|
(e.g. --criterion "2:verified").
|
|
219
229
|
`;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { exists } from "../lib/fs-ops.js";
|
|
4
|
+
import * as idx from "../lib/index-json.js";
|
|
5
|
+
import { deriveIndex, indexesMatch } from "../lib/scan.js";
|
|
6
|
+
import { cliVersion } from "../lib/version.js";
|
|
7
|
+
import { flagBool } from "../lib/args.js";
|
|
8
|
+
import { c } from "../lib/colors.js";
|
|
9
|
+
import { today } from "../lib/dates.js";
|
|
10
|
+
import * as templates from "./templates.js";
|
|
11
|
+
import * as validate from "./validate.js";
|
|
12
|
+
|
|
13
|
+
// Bring an existing project up to the installed CLI (user-reported gap: you
|
|
14
|
+
// `npm update doctrina-cli`, but the project you once `doctrina init`-ed
|
|
15
|
+
// keeps its old scaffold — stale framework stamp, an AGENTS.md that predates
|
|
16
|
+
// new commands, missing recommended sections). `upgrade` is the one command
|
|
17
|
+
// that closes that window, and it is an ORCHESTRATOR over the pieces that
|
|
18
|
+
// already exist — templates update (additive-only), index rebuild (stamp
|
|
19
|
+
// migration), validate --fix — so there is exactly one definition of each
|
|
20
|
+
// step. Preview by default; --write applies. Never rewrites user content
|
|
21
|
+
// (the same additive-only bound as `templates update`).
|
|
22
|
+
|
|
23
|
+
export async function run(_positional, flags) {
|
|
24
|
+
const projectRoot = process.cwd();
|
|
25
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
26
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
27
|
+
}
|
|
28
|
+
const writeMode = flagBool(flags, "write", false);
|
|
29
|
+
const running = cliVersion();
|
|
30
|
+
|
|
31
|
+
// 1. Version gap: what the tree was last managed by vs what is installed.
|
|
32
|
+
let index = null;
|
|
33
|
+
let stamped = null;
|
|
34
|
+
try {
|
|
35
|
+
index = idx.load(projectRoot);
|
|
36
|
+
stamped = index.framework_version ?? null;
|
|
37
|
+
} catch {
|
|
38
|
+
index = null;
|
|
39
|
+
}
|
|
40
|
+
console.log(c.bold("doctrina upgrade") + c.gray(` — project ${stamped ?? "unstamped"} → CLI ${running}${writeMode ? "" : " (preview; --write applies)"}`));
|
|
41
|
+
console.log("");
|
|
42
|
+
if (stamped === running) {
|
|
43
|
+
console.log(c.gray("The framework stamp already matches the installed CLI — checking the scaffold anyway."));
|
|
44
|
+
console.log("");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let pending = 0;
|
|
48
|
+
|
|
49
|
+
// 2. Additive template updates (missing recommended sections in AGENTS.md /
|
|
50
|
+
// product.md, missing index.json fields). Reuses `templates update`
|
|
51
|
+
// verbatim: preview without --write, apply with it.
|
|
52
|
+
console.log(c.gray("──── 1/3 scaffold shape (templates update)"));
|
|
53
|
+
const tplFlags = writeMode ? new Map([["write", true]]) : new Map();
|
|
54
|
+
const tplCode = await templates.run(["update"], tplFlags);
|
|
55
|
+
if (tplCode !== 0 && !writeMode) pending += 1; // preview exits 1 when updates are pending
|
|
56
|
+
|
|
57
|
+
// 3. Index: rebuild from the tree, which also migrates the framework stamp.
|
|
58
|
+
console.log("");
|
|
59
|
+
console.log(c.gray("──── 2/3 index + framework stamp"));
|
|
60
|
+
try {
|
|
61
|
+
const current = idx.load(projectRoot);
|
|
62
|
+
const drifted = !indexesMatch(deriveIndex(projectRoot, current), current);
|
|
63
|
+
const staleStamp = (current.framework_version ?? null) !== running;
|
|
64
|
+
if (!drifted && !staleStamp) {
|
|
65
|
+
console.log(c.green("ok") + " index.json matches the tree and the stamp is current");
|
|
66
|
+
} else if (writeMode) {
|
|
67
|
+
const derived = deriveIndex(projectRoot, current);
|
|
68
|
+
derived.last_updated = today();
|
|
69
|
+
idx.save(projectRoot, derived); // save() stamps framework_version
|
|
70
|
+
console.log(c.green("rebuilt") + ` index.json${staleStamp ? ` — stamp ${current.framework_version ?? "unstamped"} → ${running}` : ""}`);
|
|
71
|
+
} else {
|
|
72
|
+
if (drifted) console.log(c.yellow("would ") + "rebuild index.json from the tree (drifted)");
|
|
73
|
+
if (staleStamp) console.log(c.yellow("would ") + `migrate framework stamp ${current.framework_version ?? "unstamped"} → ${running}`);
|
|
74
|
+
pending += 1;
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
if (writeMode) {
|
|
78
|
+
console.log(c.red("error: ") + "index.json is missing or unreadable — run `doctrina index rebuild` manually");
|
|
79
|
+
} else {
|
|
80
|
+
console.log(c.yellow("would ") + "rebuild index.json (missing or unreadable)");
|
|
81
|
+
pending += 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 4. Full structural check — surfaces what an automatic, additive upgrade
|
|
86
|
+
// cannot fix: an AGENTS.md documenting a stale command surface, new
|
|
87
|
+
// validate checks the old scaffold fails, and so on. --fix under --write
|
|
88
|
+
// so the mechanical part self-heals.
|
|
89
|
+
console.log("");
|
|
90
|
+
console.log(c.gray("──── 3/3 validate" + (writeMode ? " --fix" : "")));
|
|
91
|
+
const vFlags = writeMode ? new Map([["fix", true]]) : new Map();
|
|
92
|
+
const vCode = await validate.run([], vFlags);
|
|
93
|
+
|
|
94
|
+
console.log("");
|
|
95
|
+
if (!writeMode) {
|
|
96
|
+
if (pending > 0) {
|
|
97
|
+
console.log(c.yellow("pending") + ` upgrade steps found — apply them: ${c.cyan("doctrina upgrade --write")}`);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
console.log(c.green("ok") + " nothing to upgrade — the project matches the installed CLI");
|
|
101
|
+
return vCode;
|
|
102
|
+
}
|
|
103
|
+
if (vCode === 0) {
|
|
104
|
+
console.log(c.green(`✓ project upgraded to ${running}`) + c.gray(" — review any warnings above; new commands: `doctrina --help`."));
|
|
105
|
+
console.log(c.gray("If AGENTS.md lists the command surface, refresh that section so agents see the new commands."));
|
|
106
|
+
} else {
|
|
107
|
+
console.log(c.yellow("upgraded with findings") + " — resolve the validate errors above (they predate or exceed what an additive upgrade can fix).");
|
|
108
|
+
}
|
|
109
|
+
return vCode;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const help = `
|
|
113
|
+
Usage: doctrina upgrade [--write]
|
|
114
|
+
|
|
115
|
+
Bring an existing project up to the installed CLI after an npm update.
|
|
116
|
+
The project keeps the scaffold of the version that init-ed it; this is
|
|
117
|
+
the one command that closes the gap, orchestrating the existing pieces:
|
|
118
|
+
|
|
119
|
+
1. templates update — append missing recommended sections to AGENTS.md
|
|
120
|
+
/ product.md and missing index.json fields
|
|
121
|
+
(additive-only; never rewrites your content)
|
|
122
|
+
2. index rebuild — regenerate index.json from the tree and migrate
|
|
123
|
+
the framework_version stamp to the running CLI
|
|
124
|
+
3. validate (--fix) — surface anything the additive upgrade cannot fix
|
|
125
|
+
(e.g. an AGENTS.md documenting a stale command
|
|
126
|
+
surface — refresh it so agents see new commands)
|
|
127
|
+
|
|
128
|
+
Preview by default (exits 1 when steps are pending); --write applies.
|
|
129
|
+
`;
|
package/src/commands/validate.js
CHANGED
|
@@ -451,8 +451,15 @@ export async function run(_positional, flags) {
|
|
|
451
451
|
if (index?.artifacts && isFile(ledgerPath)) {
|
|
452
452
|
const ledgerIds = new Set();
|
|
453
453
|
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)
|
|
454
|
+
const m = line.match(/^-\s+\d{4}-\d{2}-\d{2}\s+[—-]\s+(\S+)\s+[—-]\s+(.*)$/);
|
|
455
|
+
if (!m) continue;
|
|
456
|
+
// An abandoned change is ledger-only BY DESIGN: `change abandon`
|
|
457
|
+
// records the discard in history but deletes the folder, so there is
|
|
458
|
+
// deliberately no archive entry to cross-check. Requiring one made
|
|
459
|
+
// every abandonment turn validate permanently red (found dogfooding
|
|
460
|
+
// change 0001-review-followups).
|
|
461
|
+
if (/^abandoned\b/i.test(m[2].trim())) continue;
|
|
462
|
+
ledgerIds.add(m[1]);
|
|
456
463
|
}
|
|
457
464
|
const archiveIds = new Set((index.artifacts.changes_archive ?? []).map((ch) => ch.id));
|
|
458
465
|
for (const id of archiveIds) {
|
|
@@ -523,6 +530,14 @@ export async function run(_positional, flags) {
|
|
|
523
530
|
}
|
|
524
531
|
}
|
|
525
532
|
|
|
533
|
+
// Project rules — permanent, lintable constraints (.doctrina/rules.json;
|
|
534
|
+
// 0.11.0 field review item 12: "never reference company X" lived only in
|
|
535
|
+
// agent memory, and the remaining mentions sailed through every gate). Each
|
|
536
|
+
// rule is a forbid-regex over glob-scoped paths; a match is an ERROR with
|
|
537
|
+
// the rule's own message. Deterministic, zero-deps, and the instruction
|
|
538
|
+
// survives sessions because it is an artifact, not a memory.
|
|
539
|
+
for (const line of checkProjectRules(projectRoot)) errors.push(line);
|
|
540
|
+
|
|
526
541
|
// Output
|
|
527
542
|
if (flagBool(flags, "json", false)) {
|
|
528
543
|
console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings }, null, 2));
|
|
@@ -541,6 +556,106 @@ export async function run(_positional, flags) {
|
|
|
541
556
|
return errors.length === 0 ? 0 : 1;
|
|
542
557
|
}
|
|
543
558
|
|
|
559
|
+
// Enforce .doctrina/rules.json — permanent project constraints as
|
|
560
|
+
// forbid-regexes over glob-scoped paths:
|
|
561
|
+
// { "rules": [ { "id": "white-label", "forbid": "\\bAcmeCorp\\b",
|
|
562
|
+
// "paths": ["src/**", ".doctrina/specs/**"],
|
|
563
|
+
// "message": "white-label product; use a generic placeholder" } ] }
|
|
564
|
+
// Returns error strings (one per offending file+rule, capped per rule so a
|
|
565
|
+
// mass violation stays readable). Missing/invalid file → no rules (an
|
|
566
|
+
// invalid JSON is reported once). Binary-ish and vendored dirs are skipped
|
|
567
|
+
// by the same bounded walk validate already uses elsewhere.
|
|
568
|
+
const RULES_SKIP_DIRS = new Set([
|
|
569
|
+
".git", "node_modules", "vendor", "dist", "build", "out", "target",
|
|
570
|
+
".venv", "venv", "__pycache__", ".next", "coverage",
|
|
571
|
+
]);
|
|
572
|
+
const RULES_MAX_HITS_PER_RULE = 10;
|
|
573
|
+
|
|
574
|
+
function checkProjectRules(projectRoot) {
|
|
575
|
+
const rulesPath = path.join(projectRoot, ".doctrina", "rules.json");
|
|
576
|
+
if (!isFile(rulesPath)) return [];
|
|
577
|
+
let cfg;
|
|
578
|
+
try {
|
|
579
|
+
cfg = JSON.parse(read(rulesPath));
|
|
580
|
+
} catch (err) {
|
|
581
|
+
return [`.doctrina/rules.json is not valid JSON: ${err.message}`];
|
|
582
|
+
}
|
|
583
|
+
const rules = Array.isArray(cfg?.rules) ? cfg.rules : [];
|
|
584
|
+
const out = [];
|
|
585
|
+
const compiled = [];
|
|
586
|
+
for (const r of rules) {
|
|
587
|
+
if (!r || typeof r.forbid !== "string" || !r.forbid) {
|
|
588
|
+
out.push(`.doctrina/rules.json: rule "${r?.id ?? "?"}" needs a non-empty "forbid" regex`);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
let re;
|
|
592
|
+
try {
|
|
593
|
+
re = new RegExp(r.forbid, "m");
|
|
594
|
+
} catch (err) {
|
|
595
|
+
out.push(`.doctrina/rules.json: rule "${r.id ?? r.forbid}" has an invalid regex: ${err.message}`);
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const paths = Array.isArray(r.paths) && r.paths.length ? r.paths : ["**"];
|
|
599
|
+
compiled.push({ id: r.id ?? r.forbid, re, matchers: paths.map(globToRegExp), message: r.message ?? "", hits: 0 });
|
|
600
|
+
}
|
|
601
|
+
if (compiled.length === 0) return out;
|
|
602
|
+
|
|
603
|
+
// One bounded walk; each text file is read at most once.
|
|
604
|
+
const stack = [projectRoot];
|
|
605
|
+
while (stack.length) {
|
|
606
|
+
const dir = stack.pop();
|
|
607
|
+
let entries;
|
|
608
|
+
try {
|
|
609
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
610
|
+
} catch {
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
for (const entry of entries) {
|
|
614
|
+
const full = path.join(dir, entry.name);
|
|
615
|
+
if (entry.isDirectory()) {
|
|
616
|
+
if (!RULES_SKIP_DIRS.has(entry.name)) stack.push(full);
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
const rel = relPath(projectRoot, full).replace(/\\/g, "/");
|
|
620
|
+
if (rel === ".doctrina/rules.json") continue; // the rule text itself always matches
|
|
621
|
+
const applicable = compiled.filter((cr) => cr.hits < RULES_MAX_HITS_PER_RULE && cr.matchers.some((m) => m.test(rel)));
|
|
622
|
+
if (applicable.length === 0) continue;
|
|
623
|
+
let text;
|
|
624
|
+
try {
|
|
625
|
+
text = read(full);
|
|
626
|
+
} catch {
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
for (const cr of applicable) {
|
|
630
|
+
const m = cr.re.exec(text);
|
|
631
|
+
if (m) {
|
|
632
|
+
cr.hits += 1;
|
|
633
|
+
const lineNo = text.slice(0, m.index).split("\n").length;
|
|
634
|
+
out.push(`rule "${cr.id}": ${rel}:${lineNo} matches forbidden pattern${cr.message ? ` — ${cr.message}` : ""}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
for (const cr of compiled) {
|
|
640
|
+
if (cr.hits >= RULES_MAX_HITS_PER_RULE) {
|
|
641
|
+
out.push(`rule "${cr.id}": more matches suppressed after ${RULES_MAX_HITS_PER_RULE} files`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return out;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Minimal glob → RegExp: ** crosses directories, * stays within a segment.
|
|
648
|
+
// Anchored to the whole repo-relative POSIX path.
|
|
649
|
+
function globToRegExp(glob) {
|
|
650
|
+
const esc = String(glob).replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
651
|
+
const body = esc
|
|
652
|
+
.replace(/\*\*\//g, "(?:.*/)?")
|
|
653
|
+
.replace(/\*\*/g, ".*")
|
|
654
|
+
.replace(/\*/g, "[^/]*")
|
|
655
|
+
.replace(/\?/g, "[^/]");
|
|
656
|
+
return new RegExp(`^${body}$`);
|
|
657
|
+
}
|
|
658
|
+
|
|
544
659
|
// Per-entry metadata drift between the on-disk index and the tree-derived
|
|
545
660
|
// one, for entries present in BOTH. Presence drift (an artifact on disk but
|
|
546
661
|
// 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
|
@@ -71,14 +71,18 @@ export async function run(positional, flags) {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
const effPrompt = prompt || `backfill specs from ${files.length} changed file${files.length === 1 ? "" : "s"}`;
|
|
74
|
-
|
|
74
|
+
// --title separates the short display name from the full brief: the title
|
|
75
|
+
// drives the slug and the proposal H1; the whole prompt still lands, intact,
|
|
76
|
+
// under ## Why. Without it a long prompt used to become a 900-char H1.
|
|
77
|
+
const title = flagString(flags, "title") ?? effPrompt;
|
|
78
|
+
const slug = prompt || flags.has("title") ? slugify(title) : "backfill";
|
|
75
79
|
const id = flagString(flags, "id") ?? `${nextChangeNumber(projectRoot)}-${slug}`;
|
|
76
80
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
|
|
77
81
|
console.error(c.red("error:") + ` invalid change id "${id}" (lowercase letters, digits, hyphens)`);
|
|
78
82
|
return 2;
|
|
79
83
|
}
|
|
80
84
|
|
|
81
|
-
const code = changeNew([id,
|
|
85
|
+
const code = changeNew([id, title], flags);
|
|
82
86
|
if (code !== 0) return code;
|
|
83
87
|
|
|
84
88
|
// Record the prompt verbatim as the change's Why — the raw intent has
|
|
@@ -228,6 +232,9 @@ function resumeChange(projectRoot, resumeId) {
|
|
|
228
232
|
}
|
|
229
233
|
|
|
230
234
|
// ASCII-folded kebab-case of the prompt ("Faça o login" -> "faca-o-login").
|
|
235
|
+
// Over-long slugs are cut at a WORD boundary, not mid-token — a 900-char
|
|
236
|
+
// prompt used to produce folder names ending in a half word ("...-scoped-c"),
|
|
237
|
+
// which then leaks into every path, ledger line, and archive name.
|
|
231
238
|
export function slugify(text) {
|
|
232
239
|
let slug = text
|
|
233
240
|
.normalize("NFD")
|
|
@@ -235,7 +242,11 @@ export function slugify(text) {
|
|
|
235
242
|
.toLowerCase()
|
|
236
243
|
.replace(/[^a-z0-9]+/g, "-")
|
|
237
244
|
.replace(/^-+|-+$/g, "");
|
|
238
|
-
if (slug.length > 48)
|
|
245
|
+
if (slug.length > 48) {
|
|
246
|
+
const cut = slug.slice(0, 49); // one past the cap: a "-" here means 48 ends a word
|
|
247
|
+
const boundary = cut.lastIndexOf("-");
|
|
248
|
+
slug = (boundary > 0 ? cut.slice(0, boundary) : cut.slice(0, 48)).replace(/-+$/g, "");
|
|
249
|
+
}
|
|
239
250
|
return slug.length > 0 ? slug : "task";
|
|
240
251
|
}
|
|
241
252
|
|
|
@@ -292,7 +303,7 @@ export function rankCapabilities(projectRoot, prompt) {
|
|
|
292
303
|
// file scores its capability when the file sits under a path segment named for
|
|
293
304
|
// it, or when the spec cites the file as evidence. A deterministic overlap
|
|
294
305
|
// hint for the agent — never a decision (ADR 0005).
|
|
295
|
-
export function rankCapabilitiesByDiff(projectRoot, files) {
|
|
306
|
+
export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3 } = {}) {
|
|
296
307
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
297
308
|
if (!files || files.length === 0 || !isDir(specsDir)) return [];
|
|
298
309
|
const norm = files.map((f) => f.replace(/\\/g, "/"));
|
|
@@ -310,7 +321,10 @@ export function rankCapabilitiesByDiff(projectRoot, files) {
|
|
|
310
321
|
}
|
|
311
322
|
if (score > 0) ranked.push({ id: cap, score, path: `.doctrina/specs/${cap}/spec.md` });
|
|
312
323
|
}
|
|
313
|
-
|
|
324
|
+
// `limit` keeps the work-playbook hint short (top 3); review passes
|
|
325
|
+
// Infinity — truncating there is how it missed 5 of 8 touched capabilities
|
|
326
|
+
// in the 0.11.0 field session.
|
|
327
|
+
return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, limit);
|
|
314
328
|
}
|
|
315
329
|
|
|
316
330
|
// A chore is a spec-less change (review F9): the playbook drops the spec-delta
|
|
@@ -401,6 +415,16 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
401
415
|
console.log(c.gray(" <EARS body. Keep the two axes honest (Status vs Implementation),"));
|
|
402
416
|
console.log(c.gray(" keep aspiration under ## Maturity → Future, and cite the proof per"));
|
|
403
417
|
console.log(c.gray(" criterion: \"1. [unverified] <signal> — verified by `path/to/test`\">"));
|
|
418
|
+
console.log(c.gray(" New capability? `spec new` then an ADDED delta with the FULL body — apply"));
|
|
419
|
+
console.log(c.gray(" replaces the untouched scaffold. Bookkeeping edits? MODIFIED with an ops"));
|
|
420
|
+
console.log(c.gray(" block, applied mechanically (all ops or none):"));
|
|
421
|
+
console.log(c.gray(" ```ops"));
|
|
422
|
+
console.log(c.gray(" set-header Implementation: verified — `src/x.js`"));
|
|
423
|
+
console.log(c.gray(" bump-version minor"));
|
|
424
|
+
console.log(c.gray(" set-criterion 1: verified"));
|
|
425
|
+
console.log(c.gray(" append-criterion [unverified] new signal — verified by `test/y.test.js`"));
|
|
426
|
+
console.log(c.gray(" ```"));
|
|
427
|
+
console.log(c.gray(" Only prose rewrites stay a by-hand merge."));
|
|
404
428
|
if (fromDiff) {
|
|
405
429
|
console.log(c.gray(" --from-diff: the code already exists — describe its CURRENT behaviour, and"));
|
|
406
430
|
console.log(c.gray(" mark each criterion [unverified] until a test proves it (don't assume the"));
|
|
@@ -414,19 +438,22 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
414
438
|
console.log(" spec's Implementation: planned → partial → implemented as code lands.");
|
|
415
439
|
console.log(" If the prompt is genuinely ambiguous, ask the user before assuming.");
|
|
416
440
|
console.log("");
|
|
417
|
-
console.log(`6.
|
|
418
|
-
console.log(
|
|
419
|
-
console.log(
|
|
420
|
-
console.log(
|
|
421
|
-
console.log(
|
|
422
|
-
console.log(`
|
|
423
|
-
console.log("
|
|
441
|
+
console.log(`6. ADR checkpoint — does this change decide something structural`);
|
|
442
|
+
console.log(" (an architecture, a boundary, a trade-off a future session must");
|
|
443
|
+
console.log(` not relitigate)? If yes: ${c.cyan("doctrina decision new \"<title>\"")} now,`);
|
|
444
|
+
console.log(" before closing — the definition of done requires it recorded.");
|
|
445
|
+
console.log("");
|
|
446
|
+
console.log(`7. Close in one attested pass (preferred — runs every gate and stops`);
|
|
447
|
+
console.log(" at the first failure with the exact rerun command):");
|
|
448
|
+
console.log(` ${c.cyan(`doctrina close ${id}`)}`);
|
|
449
|
+
console.log(c.gray(" (equivalent, step by step: ") +
|
|
450
|
+
c.gray(`analyze → change apply → verify → coverage → trace → change archive → validate)`));
|
|
451
|
+
console.log(" Before it: check the proposal's ## Verification boxes and every task,");
|
|
424
452
|
console.log(" closing steps included, and bump Implementation to verified.");
|
|
425
453
|
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("."));
|
|
454
|
+
console.log(`8. ${c.cyan("doctrina next")} for the follow-up.`);
|
|
455
|
+
console.log(c.gray(" If this change taught a reusable lesson (a fix you'd hate to relearn,"));
|
|
456
|
+
console.log(c.gray(" a recurring convention), capture it: ") + c.cyan("doctrina skill new <slug>") + c.gray("."));
|
|
430
457
|
}
|
|
431
458
|
|
|
432
459
|
export const help = `
|
|
@@ -447,6 +474,8 @@ already open, work suggests resuming it instead of opening a junk change
|
|
|
447
474
|
named after that word (review G1). Use --resume to do so directly.
|
|
448
475
|
|
|
449
476
|
Options:
|
|
477
|
+
--title "<short>" Short display title: drives the slug and the proposal
|
|
478
|
+
H1; the full prompt still lands under ## Why
|
|
450
479
|
--capability <cap> Pin the capability instead of ranking matches
|
|
451
480
|
--id <id> Override the derived change id
|
|
452
481
|
--resume <id> Reprint the playbook for an open change; create nothing
|
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
|
}
|
package/src/lib/commands.js
CHANGED
|
@@ -13,13 +13,13 @@ export const COMMAND_NAMES = [
|
|
|
13
13
|
// bootstrap / day-to-day
|
|
14
14
|
"init", "intake", "work",
|
|
15
15
|
// authoring
|
|
16
|
-
"spec", "change", "contract", "decision", "skill",
|
|
16
|
+
"spec", "change", "contract", "decision", "skill", "intent",
|
|
17
17
|
// read / orient
|
|
18
18
|
"prime", "context", "show", "search", "status", "next", "why", "handoff", "constitution",
|
|
19
19
|
// gates
|
|
20
20
|
"analyze", "clarify", "validate", "coverage", "trace", "review", "verify", "close", "doctor",
|
|
21
21
|
// maintenance
|
|
22
|
-
"templates", "hooks", "index", "watch", "metrics", "report", "completion",
|
|
22
|
+
"templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade",
|
|
23
23
|
];
|
|
24
24
|
|
|
25
25
|
// The full operation surface — every `doctrina <command> [<subcommand>]` a
|
|
@@ -56,6 +56,8 @@ export const OPERATIONS = [
|
|
|
56
56
|
["skill list", "List skills with their descriptions"],
|
|
57
57
|
["skill sync", "Mirror skill frontmatter descriptions into index.json"],
|
|
58
58
|
["skill suggest", "Surface fix-shaped lessons worth a skill (--write scaffolds)"],
|
|
59
|
+
["intent add", "Append a new product intent anchor post-intake (- [SC5] ...)"],
|
|
60
|
+
["intent list", "List product.md intent anchors in document order"],
|
|
59
61
|
["analyze", "Inspect a change folder before applying"],
|
|
60
62
|
["clarify", "Smell-test a Markdown file for ambiguity (--all for the tree)"],
|
|
61
63
|
["prime", "Session primer: gates, rules, open work, next steps in one read"],
|
|
@@ -83,6 +85,7 @@ export const OPERATIONS = [
|
|
|
83
85
|
["metrics", "Local git-derived adoption metrics (no network)"],
|
|
84
86
|
["report", "Markdown digest for a period: changes, gates, git summary"],
|
|
85
87
|
["completion", "Print bash/zsh/pwsh completions (generated from the catalog)"],
|
|
88
|
+
["upgrade", "Bring an existing project up to the installed CLI (--write applies)"],
|
|
86
89
|
];
|
|
87
90
|
|
|
88
91
|
// The Commands block of `doctrina --help`, generated from OPERATIONS so the
|
package/src/lib/scan.js
CHANGED
|
@@ -25,6 +25,15 @@ export function listHeader(text, name) {
|
|
|
25
25
|
return m ? m[1].trim() : null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// The machine-readable "**Depends on:** defects, decisions" spec header: the
|
|
29
|
+
// sibling capabilities this spec builds on. Returns [] when absent, "n/a", or
|
|
30
|
+
// empty. One parser so index, why, context, and review agree.
|
|
31
|
+
export function parseDependsOn(text) {
|
|
32
|
+
const raw = specHeader(text, "Depends on");
|
|
33
|
+
if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
|
|
34
|
+
return raw.match(/[a-z][a-z0-9][a-z0-9-]*/g) ?? [];
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
function dirEntries(dir) {
|
|
29
38
|
if (!isDir(dir)) return [];
|
|
30
39
|
return readdirSync(dir).filter((e) => !e.startsWith(".")).sort();
|
|
@@ -87,6 +96,13 @@ export function deriveIndex(projectRoot, current) {
|
|
|
87
96
|
const realizesRaw = specHeader(text, "Realizes");
|
|
88
97
|
const realizes = realizesRaw ? (realizesRaw.match(/[A-Z]+\d+/g) ?? []) : prev?.realizes;
|
|
89
98
|
if (realizes && realizes.length) entry.realizes = realizes;
|
|
99
|
+
// Depends on: sibling capabilities this spec builds on (0.11.0 field
|
|
100
|
+
// review — specs cited each other only in prose, unreadable by machine).
|
|
101
|
+
// Stored as a capability-id list so `why` can show the graph, `context`
|
|
102
|
+
// can pull dependencies into the read pack, and `review` can flag
|
|
103
|
+
// dependents of a touched capability.
|
|
104
|
+
const depends = specHeader(text, "Depends on") !== null ? parseDependsOn(text) : prev?.depends_on;
|
|
105
|
+
if (depends && depends.length) entry.depends_on = depends;
|
|
90
106
|
out.artifacts.specs.push(entry);
|
|
91
107
|
}
|
|
92
108
|
|
|
@@ -42,17 +42,21 @@ so prefer it over writing artifacts by hand. Full list: `doctrina --help`.
|
|
|
42
42
|
existing code) · `--chore` (no-spec infra/docs/build)
|
|
43
43
|
- **Scaffold (manual creation stays available):** `doctrina spec new <cap>`
|
|
44
44
|
(`--bug`) · `doctrina decision new "<title>"` · `doctrina change new <id>`
|
|
45
|
-
· `doctrina contract new <id>` · `doctrina skill new <slug>`
|
|
45
|
+
· `doctrina contract new <id>` · `doctrina skill new <slug>` · `doctrina
|
|
46
|
+
intent add "<text>"` (new product anchor post-intake)
|
|
46
47
|
- **Advance / close:** `doctrina change apply|archive|diff <id>` · `doctrina
|
|
47
48
|
decision accept|land|supersede <n>` · `doctrina spec set <cap> [...]` ·
|
|
48
|
-
`doctrina close <id>` (whole close in one pass
|
|
49
|
+
`doctrina close <id>` (whole close in one pass, coverage scoped to the
|
|
50
|
+
change)
|
|
49
51
|
- **Gates (ground truth, not guesses):** `doctrina validate` (`--fix`) ·
|
|
50
52
|
`doctrina verify` · `doctrina review` (conformance vs specs/ADRs) ·
|
|
51
|
-
`doctrina coverage --strict`
|
|
52
|
-
|
|
53
|
+
`doctrina coverage --strict` (`--run` executes cited proof) ·
|
|
54
|
+
`doctrina trace --strict` · `doctrina contract check` ·
|
|
55
|
+
`doctrina clarify --all` · `doctrina analyze <id>`
|
|
53
56
|
|
|
54
57
|
Continuous: `doctrina watch` re-syncs and re-orients on every save.
|
|
55
58
|
Capture lessons: `doctrina skill suggest` surfaces skills worth writing.
|
|
59
|
+
After a CLI update: `doctrina upgrade --write` brings this project up.
|
|
56
60
|
|
|
57
61
|
## Stack and tooling
|
|
58
62
|
|
|
@@ -24,7 +24,15 @@ ADR 0006). Tag product.md success-criteria bullets with an anchor —
|
|
|
24
24
|
"- [SC1] ..." — and list the ones this spec delivers above. An "active"
|
|
25
25
|
spec with no Realizes: header warns under `doctrina validate`; if the
|
|
26
26
|
capability genuinely maps to no product anchor (internal tooling), keep
|
|
27
|
-
the header and write "n/a — <why>" to record that on purpose.
|
|
27
|
+
the header and write "n/a — <why>" to record that on purpose. Need a NEW
|
|
28
|
+
anchor for intent born after the intake? `doctrina intent add "<text>"`.
|
|
29
|
+
|
|
30
|
+
Optional: declare the sibling capabilities this spec builds on with a
|
|
31
|
+
machine-readable header — "**Depends on:** defects, decisions". It feeds
|
|
32
|
+
`doctrina why` (the dependency graph), `doctrina context <cap>` (the
|
|
33
|
+
dependencies join the read pack), and `doctrina review` (dependents of a
|
|
34
|
+
touched capability are flagged). Citing them only in prose keeps every
|
|
35
|
+
tool blind to the link.
|
|
28
36
|
-->
|
|
29
37
|
|
|
30
38
|
## Purpose
|