doctrina-cli 0.9.0 → 0.11.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 +1 -1
- package/src/commands/completion.js +116 -0
- package/src/commands/constitution.js +138 -0
- package/src/commands/context.js +118 -22
- package/src/commands/coverage.js +41 -16
- package/src/commands/doctor.js +135 -0
- package/src/commands/handoff.js +94 -0
- package/src/commands/init.js +12 -1
- package/src/commands/next.js +32 -16
- package/src/commands/prime.js +118 -0
- package/src/commands/report.js +142 -0
- package/src/commands/show.js +173 -0
- package/src/commands/status.js +70 -40
- package/src/commands/trace.js +15 -2
- package/src/commands/validate.js +97 -1
- package/src/commands/why.js +132 -36
- package/src/index.js +12 -41
- package/src/lib/commands.js +115 -0
- package/src/lib/criteria.js +76 -0
- package/src/lib/index-json.js +1 -0
- package/src/lib/scan.js +7 -0
- package/templates/AGENTS.md.template +3 -2
- package/templates/README.md +7 -1
- package/templates/adapters/aider/CONVENTIONS.md.template +4 -3
- package/templates/adapters/claude/.claude/commands/doctrina-context.md.template +13 -0
- package/templates/adapters/claude/.claude/commands/doctrina-next.md.template +13 -0
- package/templates/adapters/claude/.claude/commands/doctrina-status.md.template +12 -0
- package/templates/adapters/claude/.claude/commands/doctrina-why.md.template +12 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +17 -0
- package/templates/adapters/cursor/.cursor/commands/doctrina-context.md.template +10 -0
- package/templates/adapters/cursor/.cursor/commands/doctrina-next.md.template +10 -0
- package/templates/adapters/cursor/.cursor/commands/doctrina-status.md.template +10 -0
- package/templates/adapters/cursor/.cursor/commands/doctrina-why.md.template +9 -0
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +12 -0
- package/templates/doctrina/index.json.template +3 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
|
|
5
|
+
import { parseAcceptanceCriteria } from "../lib/criteria.js";
|
|
6
|
+
import { suggest } from "../lib/suggest.js";
|
|
7
|
+
import { c } from "../lib/colors.js";
|
|
8
|
+
|
|
9
|
+
// Point reads. An agent that needs ONE requirement re-reads a whole spec —
|
|
10
|
+
// hundreds of lines for a two-line fact. `show` resolves a compact reference
|
|
11
|
+
// to exactly the lines that own it:
|
|
12
|
+
//
|
|
13
|
+
// doctrina show cli-R12 twelfth requirement of the cli spec (file order)
|
|
14
|
+
// doctrina show gates-C3 acceptance criterion 3 (the spec's own numbering)
|
|
15
|
+
// doctrina show 0007 ADR 0007, whole decision (already a bounded read)
|
|
16
|
+
// doctrina show cli the spec's header block + Purpose only
|
|
17
|
+
//
|
|
18
|
+
// R-refs are positional (file order across the EARS sections), so they shift
|
|
19
|
+
// when a requirement is inserted above — cite them for point reads and
|
|
20
|
+
// conversation, not as immutable identifiers. C-refs use the criteria's own
|
|
21
|
+
// explicit numbers and are as stable as the spec keeps them.
|
|
22
|
+
|
|
23
|
+
export async function run(positional, _flags) {
|
|
24
|
+
const ref = positional[0];
|
|
25
|
+
const projectRoot = process.cwd();
|
|
26
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
27
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
28
|
+
}
|
|
29
|
+
if (!ref) {
|
|
30
|
+
console.error(c.red("error:") + " show requires a reference");
|
|
31
|
+
console.error(c.gray("hint: ") + "`doctrina show cli-R12` (requirement), `doctrina show cli-C3` (criterion), `doctrina show 0007` (ADR), `doctrina show cli` (spec header + purpose)");
|
|
32
|
+
return 2;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ADR by number.
|
|
36
|
+
if (/^\d{4}$/.test(ref)) return showAdr(projectRoot, ref);
|
|
37
|
+
|
|
38
|
+
const m = ref.match(/^([a-z][a-z0-9-]*)-([RC])(\d+)$/);
|
|
39
|
+
if (m) return showSpecItem(projectRoot, m[1], m[2], Number(m[3]));
|
|
40
|
+
|
|
41
|
+
if (/^[a-z][a-z0-9-]*$/.test(ref)) return showSpecHead(projectRoot, ref);
|
|
42
|
+
|
|
43
|
+
console.error(c.red("error:") + ` unrecognised reference "${ref}"`);
|
|
44
|
+
console.error(c.gray("hint: ") + "use <cap>-R<n>, <cap>-C<n>, an ADR number (0007), or a capability name");
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function showAdr(projectRoot, num) {
|
|
49
|
+
const dir = path.join(projectRoot, ".doctrina", "decisions");
|
|
50
|
+
const file = isDir(dir) ? readdirSync(dir).find((f) => f.startsWith(`${num}-`) && f.endsWith(".md")) : null;
|
|
51
|
+
if (!file) {
|
|
52
|
+
console.error(c.red("error:") + ` no ADR ${num} under .doctrina/decisions/`);
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
const full = path.join(dir, file);
|
|
56
|
+
console.log(c.gray(`— ${relPath(projectRoot, full)} —`));
|
|
57
|
+
console.log(read(full).trimEnd());
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function loadSpec(projectRoot, cap) {
|
|
62
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
63
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
64
|
+
if (isFile(specPath)) return { specPath, text: read(specPath) };
|
|
65
|
+
const known = isDir(specsDir) ? readdirSync(specsDir).filter((e) => isFile(path.join(specsDir, e, "spec.md"))) : [];
|
|
66
|
+
console.error(c.red("error:") + ` no spec for capability "${cap}"`);
|
|
67
|
+
const guess = suggest(cap, known);
|
|
68
|
+
if (guess) console.error(c.gray("hint: ") + `did you mean "${guess}"?`);
|
|
69
|
+
else if (known.length) console.error(c.gray("known: ") + known.sort().join(", "));
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function showSpecHead(projectRoot, cap) {
|
|
74
|
+
const spec = loadSpec(projectRoot, cap);
|
|
75
|
+
if (!spec) return 1;
|
|
76
|
+
const lines = spec.text.split(/\r?\n/);
|
|
77
|
+
const out = [];
|
|
78
|
+
let mode = "head"; // header block -> seek Purpose -> purpose body -> stop
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
const isH2 = /^##\s+/.test(line);
|
|
81
|
+
if (mode === "head" && isH2) mode = /^##\s+Purpose\b/i.test(line) ? "purpose" : "seek";
|
|
82
|
+
else if (mode === "seek" && isH2 && /^##\s+Purpose\b/i.test(line)) mode = "purpose";
|
|
83
|
+
else if (mode === "purpose" && isH2) break;
|
|
84
|
+
|
|
85
|
+
if (mode === "head" || (mode === "purpose")) out.push(line);
|
|
86
|
+
}
|
|
87
|
+
console.log(c.gray(`— ${relPath(projectRoot, spec.specPath)} (header + purpose; \`doctrina context ${cap}\` for the full spec) —`));
|
|
88
|
+
console.log(out.join("\n").trimEnd());
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function showSpecItem(projectRoot, cap, kind, n) {
|
|
93
|
+
const spec = loadSpec(projectRoot, cap);
|
|
94
|
+
if (!spec) return 1;
|
|
95
|
+
|
|
96
|
+
if (kind === "C") {
|
|
97
|
+
const criteria = parseAcceptanceCriteria(spec.text);
|
|
98
|
+
const crit = criteria.find((r) => r.n === n);
|
|
99
|
+
if (!crit) {
|
|
100
|
+
console.error(c.red("error:") + ` ${cap} has no acceptance criterion #${n} (it declares ${criteria.length})`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
console.log(c.gray(`— ${cap}-C${n} · ${relPath(projectRoot, spec.specPath)} · ## Acceptance criteria —`));
|
|
104
|
+
console.log(`${n}. ${crit.body}`);
|
|
105
|
+
if (crit.proofPaths.length > 0) {
|
|
106
|
+
console.log(c.gray(` evidence: ${crit.proofPaths.join(", ")}`));
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const reqs = parseRequirements(spec.text);
|
|
112
|
+
const req = reqs[n - 1];
|
|
113
|
+
if (!req) {
|
|
114
|
+
console.error(c.red("error:") + ` ${cap} has no requirement R${n} (it declares ${reqs.length})`);
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
console.log(c.gray(`— ${cap}-R${n} · ${relPath(projectRoot, spec.specPath)} · ${req.section} —`));
|
|
118
|
+
console.log(req.text);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Top-level bullets of the "## Requirements (EARS)" section, in file order,
|
|
123
|
+
// each with its `### <grammar>` sub-section name and continuation lines.
|
|
124
|
+
export function parseRequirements(text) {
|
|
125
|
+
const lines = text.split(/\r?\n/);
|
|
126
|
+
const out = [];
|
|
127
|
+
let inSection = false;
|
|
128
|
+
let sub = "";
|
|
129
|
+
let cur = null;
|
|
130
|
+
const flush = () => {
|
|
131
|
+
if (cur) out.push({ section: cur.section, text: cur.lines.join("\n").trimEnd() });
|
|
132
|
+
cur = null;
|
|
133
|
+
};
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
if (/^##\s+/.test(line)) {
|
|
136
|
+
flush();
|
|
137
|
+
inSection = /^##\s+Requirements\b/i.test(line);
|
|
138
|
+
sub = "";
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (!inSection) continue;
|
|
142
|
+
const subMatch = line.match(/^###\s+(.+)$/);
|
|
143
|
+
if (subMatch) {
|
|
144
|
+
flush();
|
|
145
|
+
sub = subMatch[1].trim();
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (/^-\s+/.test(line)) {
|
|
149
|
+
flush();
|
|
150
|
+
cur = { section: sub || "Requirements", lines: [line] };
|
|
151
|
+
} else if (cur && (line.startsWith(" ") || line.trim() === "")) {
|
|
152
|
+
cur.lines.push(line);
|
|
153
|
+
} else {
|
|
154
|
+
flush();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
flush();
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const help = `
|
|
162
|
+
Usage: doctrina show <ref>
|
|
163
|
+
|
|
164
|
+
Point-read one artifact fragment instead of a whole file:
|
|
165
|
+
|
|
166
|
+
doctrina show cli-R12 requirement 12 of the cli spec (file order)
|
|
167
|
+
doctrina show cli-C3 acceptance criterion 3 (the spec's numbering)
|
|
168
|
+
doctrina show 0007 ADR 0007 (the whole decision)
|
|
169
|
+
doctrina show cli the spec's header block + Purpose only
|
|
170
|
+
|
|
171
|
+
R-refs are positional (they shift when a requirement is inserted above);
|
|
172
|
+
C-refs use the criteria's own numbers. Read-only.
|
|
173
|
+
`;
|
package/src/commands/status.js
CHANGED
|
@@ -7,6 +7,7 @@ import { deriveIndex, indexesMatch, specHeader, listHeader } from "../lib/scan.j
|
|
|
7
7
|
import { cliVersion } from "../lib/version.js";
|
|
8
8
|
import { summarize as coverageSummary } from "./coverage.js";
|
|
9
9
|
import { summarize as traceSummary } from "./trace.js";
|
|
10
|
+
import { flagBool } from "../lib/args.js";
|
|
10
11
|
import { c } from "../lib/colors.js";
|
|
11
12
|
|
|
12
13
|
// One-glance project health (review 2026-06-27 passive-user feature #1): a
|
|
@@ -19,53 +20,47 @@ import { c } from "../lib/colors.js";
|
|
|
19
20
|
|
|
20
21
|
const CONFIG_REL = ".doctrina/verify.json";
|
|
21
22
|
|
|
22
|
-
export async function run(_positional,
|
|
23
|
+
export async function run(_positional, flags) {
|
|
23
24
|
const projectRoot = process.cwd();
|
|
24
25
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
25
26
|
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
const s = collectStatus(projectRoot);
|
|
30
|
+
|
|
31
|
+
if (flagBool(flags, "json", false)) {
|
|
32
|
+
console.log(JSON.stringify(s, null, 2));
|
|
33
|
+
return 0;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
const stamp = index?.framework_version ?? "—";
|
|
37
|
-
const cli = cliVersion();
|
|
38
|
-
console.log(c.bold("Doctrina status") + c.gray(` — ${project} (framework ${stamp} / CLI ${cli})`));
|
|
36
|
+
console.log(c.bold("Doctrina status") + c.gray(` — ${s.project} (framework ${s.stamp ?? "—"} / CLI ${s.cli})`));
|
|
39
37
|
console.log("");
|
|
40
38
|
|
|
41
39
|
// --- Gates ---
|
|
42
40
|
console.log(c.bold(" Gates"));
|
|
43
|
-
// Index drift (the silent rot validate now errors on).
|
|
44
41
|
let driftLine;
|
|
45
|
-
if (
|
|
42
|
+
if (s.indexState === "missing") {
|
|
46
43
|
driftLine = c.red("missing/unreadable") + c.gray(" — run `doctrina index rebuild`");
|
|
47
|
-
} else if (
|
|
44
|
+
} else if (s.indexState === "in-sync") {
|
|
48
45
|
driftLine = c.green("in sync");
|
|
49
46
|
} else {
|
|
50
47
|
driftLine = c.yellow("drifted") + c.gray(" — run `doctrina validate --fix`");
|
|
51
48
|
}
|
|
52
49
|
console.log(` ${"index".padEnd(11)} ${driftLine}`);
|
|
53
50
|
|
|
54
|
-
|
|
55
|
-
const stampLine = stamp === cli
|
|
51
|
+
const stampLine = s.stamp === s.cli
|
|
56
52
|
? c.green("current")
|
|
57
|
-
: c.yellow(`${stamp} (CLI ${cli})`) + c.gray(" — `doctrina index rebuild`");
|
|
53
|
+
: c.yellow(`${s.stamp ?? "—"} (CLI ${s.cli})`) + c.gray(" — `doctrina index rebuild`");
|
|
58
54
|
console.log(` ${"stamp".padEnd(11)} ${stampLine}`);
|
|
59
55
|
|
|
60
|
-
|
|
61
|
-
const cov = coverageSummary(projectRoot);
|
|
56
|
+
const cov = s.coverage;
|
|
62
57
|
const covExtra = cov.totalDangling + cov.totalConditional > 0
|
|
63
58
|
? c.yellow(` ${cov.totalDangling} dangling, ${cov.totalConditional} conditional`)
|
|
64
59
|
: "";
|
|
65
60
|
const covColor = cov.pct === 100 ? c.green : cov.pct >= 50 ? c.yellow : c.red;
|
|
66
61
|
console.log(` ${"coverage".padEnd(11)} ${covColor(`${cov.pct}%`)} ${c.gray(`(${cov.totalCovered}/${cov.totalCriteria} criteria)`)}${covExtra}`);
|
|
67
62
|
|
|
68
|
-
const tr =
|
|
63
|
+
const tr = s.trace;
|
|
69
64
|
const trExtra = tr.untraceable + tr.dropped + tr.dangling > 0
|
|
70
65
|
? c.yellow(` ${tr.dropped} dropped, ${tr.untraceable} untraceable`)
|
|
71
66
|
: "";
|
|
@@ -73,40 +68,72 @@ export async function run(_positional, _flags) {
|
|
|
73
68
|
const trText = tr.anchors === 0 ? "no anchors" : `${tr.realized}/${tr.anchors} anchors`;
|
|
74
69
|
console.log(` ${"trace".padEnd(11)} ${trColor(trText)}${trExtra}`);
|
|
75
70
|
|
|
76
|
-
// Verify config presence (running it is the slow, authoritative gate).
|
|
77
|
-
const verifyCfg = path.join(projectRoot, CONFIG_REL);
|
|
78
71
|
let verifyLine = c.gray("not configured") + c.gray(" — `doctrina verify --init`");
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
} catch {
|
|
85
|
-
verifyLine = c.red("invalid JSON");
|
|
86
|
-
}
|
|
72
|
+
if (s.verify.invalid) {
|
|
73
|
+
verifyLine = c.red("invalid JSON");
|
|
74
|
+
} else if (s.verify.configured) {
|
|
75
|
+
const n = s.verify.checks;
|
|
76
|
+
verifyLine = c.cyan(`${n} check${n === 1 ? "" : "s"}`) + c.gray(" — run `doctrina verify`");
|
|
87
77
|
}
|
|
88
78
|
console.log(` ${"verify".padEnd(11)} ${verifyLine}`);
|
|
89
79
|
|
|
90
80
|
// --- Work ---
|
|
91
81
|
console.log("");
|
|
92
82
|
console.log(c.bold(" Work"));
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
console.log(` ${"
|
|
96
|
-
console.log(` ${"changes".padEnd(11)} ${specs.openChanges} open`);
|
|
97
|
-
const adr = countDecisions(projectRoot);
|
|
83
|
+
const implBreak = Object.entries(s.specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
|
|
84
|
+
console.log(` ${"specs".padEnd(11)} ${s.specs.total}${implBreak ? c.gray(` (${implBreak})`) : ""}`);
|
|
85
|
+
console.log(` ${"changes".padEnd(11)} ${s.specs.openChanges} open`);
|
|
98
86
|
const adrNotes = [];
|
|
99
|
-
if (
|
|
100
|
-
if (
|
|
101
|
-
console.log(` ${"decisions".padEnd(11)} ${
|
|
102
|
-
|
|
103
|
-
console.log(` ${"skills".padEnd(11)} ${skills}`);
|
|
87
|
+
if (s.decisions.proposed > 0) adrNotes.push(c.yellow(`${s.decisions.proposed} proposed`));
|
|
88
|
+
if (s.decisions.bare > 0) adrNotes.push(c.yellow(`${s.decisions.bare} unproven`));
|
|
89
|
+
console.log(` ${"decisions".padEnd(11)} ${s.decisions.total}${adrNotes.length ? c.gray(" (") + adrNotes.join(c.gray(", ")) + c.gray(")") : ""}`);
|
|
90
|
+
console.log(` ${"skills".padEnd(11)} ${s.skills}`);
|
|
104
91
|
|
|
105
92
|
console.log("");
|
|
106
93
|
console.log(c.gray(" Full gates: `doctrina validate` · `doctrina verify`. Next step: `doctrina next`."));
|
|
107
94
|
return 0;
|
|
108
95
|
}
|
|
109
96
|
|
|
97
|
+
// Pure, render-free status snapshot — the data behind the dashboard, shared
|
|
98
|
+
// with `prime`, `handoff`, `doctor`, and the `--json` output so every consumer
|
|
99
|
+
// reads the same numbers.
|
|
100
|
+
export function collectStatus(projectRoot) {
|
|
101
|
+
let index = null;
|
|
102
|
+
try {
|
|
103
|
+
index = idx.load(projectRoot);
|
|
104
|
+
} catch {
|
|
105
|
+
index = null;
|
|
106
|
+
}
|
|
107
|
+
let indexState = "missing";
|
|
108
|
+
if (index) {
|
|
109
|
+
indexState = indexesMatch(deriveIndex(projectRoot, index), index) ? "in-sync" : "drifted";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const verifyCfg = path.join(projectRoot, CONFIG_REL);
|
|
113
|
+
let verify = { configured: false, checks: 0, invalid: false };
|
|
114
|
+
if (isFile(verifyCfg)) {
|
|
115
|
+
try {
|
|
116
|
+
const cfg = JSON.parse(read(verifyCfg));
|
|
117
|
+
verify = { configured: true, checks: Array.isArray(cfg?.checks) ? cfg.checks.length : 0, invalid: false };
|
|
118
|
+
} catch {
|
|
119
|
+
verify = { configured: true, checks: 0, invalid: true };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
project: index?.project ?? path.basename(projectRoot),
|
|
125
|
+
stamp: index?.framework_version ?? null,
|
|
126
|
+
cli: cliVersion(),
|
|
127
|
+
indexState,
|
|
128
|
+
coverage: coverageSummary(projectRoot),
|
|
129
|
+
trace: traceSummary(projectRoot),
|
|
130
|
+
verify,
|
|
131
|
+
specs: countSpecs(projectRoot),
|
|
132
|
+
decisions: countDecisions(projectRoot),
|
|
133
|
+
skills: countSkills(projectRoot),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
110
137
|
function countSpecs(projectRoot) {
|
|
111
138
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
112
139
|
const impl = {};
|
|
@@ -165,13 +192,16 @@ function countSkills(projectRoot) {
|
|
|
165
192
|
}
|
|
166
193
|
|
|
167
194
|
export const help = `
|
|
168
|
-
Usage: doctrina status
|
|
195
|
+
Usage: doctrina status [--json]
|
|
169
196
|
|
|
170
197
|
Print a one-glance health dashboard for the .doctrina/ project: the gate
|
|
171
198
|
signals (index drift, framework stamp, coverage %, trace anchors, whether
|
|
172
199
|
verify is configured) and the artifact counts (specs by implementation
|
|
173
200
|
state, open changes, decisions, skills). Read-only; always exits 0.
|
|
174
201
|
|
|
202
|
+
Flags:
|
|
203
|
+
--json Emit the snapshot as JSON (stable shape for agents and CI).
|
|
204
|
+
|
|
175
205
|
It is a fast summary, not the authoritative gate: run \`doctrina validate\`
|
|
176
206
|
and \`doctrina verify\` for the full structural and build checks, and
|
|
177
207
|
\`doctrina next\` for the recommended next action.
|
package/src/commands/trace.js
CHANGED
|
@@ -32,6 +32,7 @@ export async function run(_positional, flags) {
|
|
|
32
32
|
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
33
33
|
}
|
|
34
34
|
const strict = flagBool(flags, "strict", false);
|
|
35
|
+
const json = flagBool(flags, "json", false);
|
|
35
36
|
|
|
36
37
|
const anchors = collectAnchors(projectRoot); // [{ id }], in document order
|
|
37
38
|
const specs = collectSpecs(projectRoot); // [{ cap, status, realizes: [id] }]
|
|
@@ -41,6 +42,10 @@ export async function run(_positional, flags) {
|
|
|
41
42
|
|
|
42
43
|
// The feature is unused: do not nag a project that never opted in.
|
|
43
44
|
if (anchors.length === 0 && !anyRealizes) {
|
|
45
|
+
if (json) {
|
|
46
|
+
console.log(JSON.stringify({ anchors: [], dangling: [], untraceable: [], summary: summarize(projectRoot) }, null, 2));
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
44
49
|
console.log(
|
|
45
50
|
c.gray(
|
|
46
51
|
"no intent-provenance markers found — tag product.md bullets with `[SC1]` " +
|
|
@@ -69,6 +74,13 @@ export async function run(_positional, flags) {
|
|
|
69
74
|
.filter((s) => s.realizes === null && s.status === "active")
|
|
70
75
|
.map((s) => s.cap);
|
|
71
76
|
|
|
77
|
+
if (json) {
|
|
78
|
+
const rows = anchors.map((a) => ({ id: a.id, realizedBy: (realizedBy.get(a.id) ?? []).sort() }));
|
|
79
|
+
const clean = rows.every((r) => r.realizedBy.length > 0) && dangling.length === 0 && untraceable.length === 0;
|
|
80
|
+
console.log(JSON.stringify({ anchors: rows, dangling, untraceable, summary: summarize(projectRoot) }, null, 2));
|
|
81
|
+
return clean ? 0 : strict ? 1 : 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
72
84
|
console.log(c.bold("Trace") + c.gray(" — product intent → capability provenance:"));
|
|
73
85
|
console.log("");
|
|
74
86
|
|
|
@@ -183,6 +195,7 @@ trace reports three provenance breaks:
|
|
|
183
195
|
untraceable spec an active spec with no Realizes: header
|
|
184
196
|
|
|
185
197
|
Read-only. Exits 0 as a report; with --strict, exits 1 when any break
|
|
186
|
-
exists (CI gate).
|
|
187
|
-
that
|
|
198
|
+
exists (CI gate). --json emits anchors/dangling/untraceable + summary
|
|
199
|
+
as JSON. It checks that the link exists and is complete — not that a
|
|
200
|
+
criterion is faithful to the intent (that stays a human/LLM call).
|
|
188
201
|
`;
|
package/src/commands/validate.js
CHANGED
|
@@ -11,6 +11,13 @@ import { c } from "../lib/colors.js";
|
|
|
11
11
|
import { parseFrontmatter } from "./skill.js";
|
|
12
12
|
import { checkEars, isEarsSpec } from "../lib/ears.js";
|
|
13
13
|
import { specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
|
|
14
|
+
import { COMMAND_NAMES, referencedCommands } from "../lib/commands.js";
|
|
15
|
+
import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
|
|
16
|
+
|
|
17
|
+
// AGENTS.md is treated as a maintained doctrina-command catalog only once it
|
|
18
|
+
// documents at least this many real commands; below it, the file defers to
|
|
19
|
+
// `doctrina --help` and is never nagged about omissions.
|
|
20
|
+
const CATALOG_THRESHOLD = 8;
|
|
14
21
|
|
|
15
22
|
export async function run(_positional, flags) {
|
|
16
23
|
const projectRoot = process.cwd();
|
|
@@ -25,6 +32,46 @@ export async function run(_positional, flags) {
|
|
|
25
32
|
const lines = lineCount(agentsMd);
|
|
26
33
|
if (lines > 200) errors.push(`AGENTS.md is ${lines} lines (>200, hard limit)`);
|
|
27
34
|
else if (lines > 150) warnings.push(`AGENTS.md is ${lines} lines (>150 soft limit)`);
|
|
35
|
+
|
|
36
|
+
// 1c. AGENTS.md command-surface drift. AGENTS.md is the hub the agent reads
|
|
37
|
+
// first, so the doctrina commands it documents must match the real CLI.
|
|
38
|
+
// Two drifts are flagged: a reference to a command that does not exist
|
|
39
|
+
// (a typo or a removed command — the agent will try to run it and fail),
|
|
40
|
+
// and a maintained catalog that has fallen behind the CLI (commands the
|
|
41
|
+
// agent never discovers because the hub never names them). validate only
|
|
42
|
+
// checked AGENTS.md *size* before, so the hub could rot while staying
|
|
43
|
+
// green; this closes that gap.
|
|
44
|
+
const agentsText = read(agentsMd);
|
|
45
|
+
const referenced = referencedCommands(agentsText);
|
|
46
|
+
const known = new Set(COMMAND_NAMES);
|
|
47
|
+
// Reverse drift is always flagged — a dangling command reference misleads
|
|
48
|
+
// the agent regardless of how the file is structured.
|
|
49
|
+
for (const cmd of [...referenced].sort()) {
|
|
50
|
+
if (!known.has(cmd)) {
|
|
51
|
+
warnings.push(
|
|
52
|
+
`AGENTS.md references \`doctrina ${cmd}\` which is not a CLI command ` +
|
|
53
|
+
`(typo or removed command — agents reading the hub will try to run it)`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Forward drift (omitted commands) is flagged only when the file presents
|
|
58
|
+
// itself as an exhaustive catalog: it documents many commands AND does not
|
|
59
|
+
// defer to `doctrina --help`. A file that defers is declaring its list
|
|
60
|
+
// illustrative (the shipped template does this), so its omissions are
|
|
61
|
+
// intentional and stay silent.
|
|
62
|
+
const documented = [...referenced].filter((cmd) => known.has(cmd));
|
|
63
|
+
const defersToHelp = /doctrina(?:-cli)?\s+--help/.test(agentsText);
|
|
64
|
+
if (documented.length >= CATALOG_THRESHOLD && !defersToHelp) {
|
|
65
|
+
const missing = COMMAND_NAMES.filter((cmd) => !referenced.has(cmd));
|
|
66
|
+
if (missing.length > 0) {
|
|
67
|
+
warnings.push(
|
|
68
|
+
`AGENTS.md documents the doctrina command surface but omits ` +
|
|
69
|
+
`${missing.length} command${missing.length === 1 ? "" : "s"} ` +
|
|
70
|
+
`(${missing.join(", ")}) — agents reading the hub will not discover ` +
|
|
71
|
+
`them (add them, or defer to \`doctrina --help\` for the full list)`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
28
75
|
}
|
|
29
76
|
|
|
30
77
|
// 1b. Nested AGENTS.md files ("nearest wins" hierarchy) obey the same
|
|
@@ -306,6 +353,23 @@ export async function run(_positional, flags) {
|
|
|
306
353
|
}
|
|
307
354
|
}
|
|
308
355
|
}
|
|
356
|
+
|
|
357
|
+
// 8f. Self-certified acceptance criteria (honest gates, ADR 0008). A
|
|
358
|
+
// criterion that asserts [verified] but cites no proof path is a
|
|
359
|
+
// claim with nothing behind it — the dishonest-green case. `coverage`
|
|
360
|
+
// reports every bare criterion; this names the specific combo
|
|
361
|
+
// (claims verified, proves nothing) in the always-run gate. It reads
|
|
362
|
+
// through the shared multi-line parser, so a proof cited on a
|
|
363
|
+
// continuation line still counts (no false positive).
|
|
364
|
+
for (const crit of parseAcceptanceCriteria(text)) {
|
|
365
|
+
if (isVerified(crit) && crit.proofPaths.length === 0) {
|
|
366
|
+
warnings.push(
|
|
367
|
+
`${relPath(projectRoot, specPath)}: acceptance criterion #${crit.n} is marked ` +
|
|
368
|
+
`[verified] but cites no proof path — self-certified (cite the file/test in ` +
|
|
369
|
+
`backticks, or drop the marker; \`doctrina coverage\`)`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
309
373
|
}
|
|
310
374
|
}
|
|
311
375
|
}
|
|
@@ -435,7 +499,35 @@ export async function run(_positional, flags) {
|
|
|
435
499
|
}
|
|
436
500
|
}
|
|
437
501
|
|
|
502
|
+
// 16. Bilingual docs parity. Projects that keep an EN-source docs tree
|
|
503
|
+
// with a PT mirror (docs/en + docs/pt) get filename-parity checking:
|
|
504
|
+
// a file added in one language and missing in the other is invisible
|
|
505
|
+
// to every other gate, and the mirror silently falls behind. Only the
|
|
506
|
+
// deterministic half (file sets) is checked — content staleness stays
|
|
507
|
+
// a human judgement. Projects without both trees never see this.
|
|
508
|
+
const docsEn = path.join(projectRoot, "docs", "en");
|
|
509
|
+
const docsPt = path.join(projectRoot, "docs", "pt");
|
|
510
|
+
if (isDir(docsEn) && isDir(docsPt)) {
|
|
511
|
+
const mdSet = (dir) => new Set(readdirSync(dir).filter((f) => f.endsWith(".md")));
|
|
512
|
+
const enSet = mdSet(docsEn);
|
|
513
|
+
const ptSet = mdSet(docsPt);
|
|
514
|
+
for (const f of [...enSet].sort()) {
|
|
515
|
+
if (!ptSet.has(f)) {
|
|
516
|
+
warnings.push(`docs/en/${f} has no docs/pt/${f} counterpart (EN is the source; the PT mirror fell behind)`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
for (const f of [...ptSet].sort()) {
|
|
520
|
+
if (!enSet.has(f)) {
|
|
521
|
+
warnings.push(`docs/pt/${f} has no docs/en/${f} counterpart (PT translates an EN source, never leads it)`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
438
526
|
// Output
|
|
527
|
+
if (flagBool(flags, "json", false)) {
|
|
528
|
+
console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings }, null, 2));
|
|
529
|
+
return errors.length === 0 ? 0 : 1;
|
|
530
|
+
}
|
|
439
531
|
for (const w of warnings) console.log(c.yellow("warn: ") + w);
|
|
440
532
|
for (const e of errors) console.log(c.red("error: ") + e);
|
|
441
533
|
|
|
@@ -549,9 +641,13 @@ function isLikelyPath(s) {
|
|
|
549
641
|
}
|
|
550
642
|
|
|
551
643
|
export const help = `
|
|
552
|
-
Usage: doctrina validate
|
|
644
|
+
Usage: doctrina validate [--fix] [--json]
|
|
553
645
|
|
|
554
646
|
Run schema, artifact-existence, and structural checks against the
|
|
555
647
|
.doctrina/ tree in the current working directory. Exits 0 if no errors
|
|
556
648
|
(warnings allowed), 1 otherwise.
|
|
649
|
+
|
|
650
|
+
Flags:
|
|
651
|
+
--fix Rebuild index.json from the tree before checking (heals drift).
|
|
652
|
+
--json Emit { ok, errors, warnings } as JSON (stable shape for agents/CI).
|
|
557
653
|
`;
|