doctrina-cli 0.10.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 +6 -4
- package/src/commands/context.js +108 -24
- 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 +33 -1
- package/src/commands/why.js +72 -9
- package/src/index.js +10 -41
- package/src/lib/commands.js +72 -3
- package/templates/README.md +7 -1
- package/templates/adapters/aider/CONVENTIONS.md.template +4 -3
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> CLI for the Doctrina framework. The package is published as
|
|
4
4
|
> `doctrina-cli`; the executable it installs is `doctrina`.
|
|
5
5
|
|
|
6
|
-
**Status:** v0.
|
|
6
|
+
**Status:** v0.11.0 — current release. Zero runtime dependencies
|
|
7
7
|
(Node.js standard library only).
|
|
8
8
|
|
|
9
9
|
## Install
|
package/package.json
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { OPERATIONS, COMMAND_NAMES } from "../lib/commands.js";
|
|
2
|
+
import { c } from "../lib/colors.js";
|
|
3
|
+
|
|
4
|
+
// Shell completions, generated from the same OPERATIONS catalog that feeds
|
|
5
|
+
// `--help` and the docs drift tests — so the completion script can never
|
|
6
|
+
// know a different surface than the CLI ships. Static output by design:
|
|
7
|
+
// paste it into your shell config once; regenerate after upgrading.
|
|
8
|
+
|
|
9
|
+
function subcommandMap() {
|
|
10
|
+
const map = new Map();
|
|
11
|
+
for (const [op] of OPERATIONS) {
|
|
12
|
+
const [cmd, sub] = op.split(" ");
|
|
13
|
+
if (!sub) continue;
|
|
14
|
+
if (!map.has(cmd)) map.set(cmd, []);
|
|
15
|
+
map.get(cmd).push(sub);
|
|
16
|
+
}
|
|
17
|
+
return map;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function bashScript() {
|
|
21
|
+
const subs = subcommandMap();
|
|
22
|
+
const cases = [...subs.entries()]
|
|
23
|
+
.map(([cmd, list]) => ` ${cmd}) COMPREPLY=( $(compgen -W "${list.join(" ")}" -- "$cur") );;`)
|
|
24
|
+
.join("\n");
|
|
25
|
+
return `# doctrina bash completion — generated by \`doctrina completion bash\`
|
|
26
|
+
# Install: doctrina completion bash >> ~/.bashrc (or into bash_completion.d)
|
|
27
|
+
_doctrina() {
|
|
28
|
+
local cur
|
|
29
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
30
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
31
|
+
COMPREPLY=( $(compgen -W "${COMMAND_NAMES.join(" ")}" -- "$cur") )
|
|
32
|
+
return
|
|
33
|
+
fi
|
|
34
|
+
if [ "$COMP_CWORD" -eq 2 ]; then
|
|
35
|
+
case "\${COMP_WORDS[1]}" in
|
|
36
|
+
${cases}
|
|
37
|
+
esac
|
|
38
|
+
fi
|
|
39
|
+
}
|
|
40
|
+
complete -F _doctrina doctrina
|
|
41
|
+
`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function zshScript() {
|
|
45
|
+
const subs = subcommandMap();
|
|
46
|
+
const cases = [...subs.entries()]
|
|
47
|
+
.map(([cmd, list]) => ` ${cmd}) _values 'subcommand' ${list.map((s) => `'${s}'`).join(" ")} ;;`)
|
|
48
|
+
.join("\n");
|
|
49
|
+
return `#compdef doctrina
|
|
50
|
+
# doctrina zsh completion — generated by \`doctrina completion zsh\`
|
|
51
|
+
# Install: doctrina completion zsh > "\${fpath[1]}/_doctrina" (then restart zsh)
|
|
52
|
+
_doctrina() {
|
|
53
|
+
if (( CURRENT == 2 )); then
|
|
54
|
+
_values 'command' ${COMMAND_NAMES.map((n) => `'${n}'`).join(" ")}
|
|
55
|
+
return
|
|
56
|
+
fi
|
|
57
|
+
case "\${words[2]}" in
|
|
58
|
+
${cases}
|
|
59
|
+
esac
|
|
60
|
+
}
|
|
61
|
+
_doctrina "$@"
|
|
62
|
+
`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pwshScript() {
|
|
66
|
+
const subs = subcommandMap();
|
|
67
|
+
const entries = [...subs.entries()]
|
|
68
|
+
.map(([cmd, list]) => ` "${cmd}" = @(${list.map((s) => `"${s}"`).join(", ")})`)
|
|
69
|
+
.join("\n");
|
|
70
|
+
return `# doctrina PowerShell completion — generated by \`doctrina completion pwsh\`
|
|
71
|
+
# Install: doctrina completion pwsh >> $PROFILE (then restart the shell)
|
|
72
|
+
$doctrinaCommands = @(${COMMAND_NAMES.map((n) => `"${n}"`).join(", ")})
|
|
73
|
+
$doctrinaSubcommands = @{
|
|
74
|
+
${entries}
|
|
75
|
+
}
|
|
76
|
+
Register-ArgumentCompleter -Native -CommandName doctrina -ScriptBlock {
|
|
77
|
+
param($wordToComplete, $commandAst, $cursorPosition)
|
|
78
|
+
$words = $commandAst.CommandElements | ForEach-Object { $_.ToString() }
|
|
79
|
+
if ($words.Count -le 1 -or ($words.Count -eq 2 -and $wordToComplete)) {
|
|
80
|
+
$doctrinaCommands | Where-Object { $_ -like "$wordToComplete*" } |
|
|
81
|
+
ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
$cmd = $words[1]
|
|
85
|
+
if ($doctrinaSubcommands.ContainsKey($cmd)) {
|
|
86
|
+
$doctrinaSubcommands[$cmd] | Where-Object { $_ -like "$wordToComplete*" } |
|
|
87
|
+
ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function run(positional, _flags) {
|
|
94
|
+
const shell = positional[0];
|
|
95
|
+
const generators = { bash: bashScript, zsh: zshScript, pwsh: pwshScript, powershell: pwshScript };
|
|
96
|
+
if (!shell || !generators[shell]) {
|
|
97
|
+
console.error(c.red("error:") + ` completion requires a shell: bash, zsh, or pwsh${shell ? ` (got "${shell}")` : ""}`);
|
|
98
|
+
console.error(c.gray("hint: ") + "`doctrina completion bash >> ~/.bashrc`");
|
|
99
|
+
return 2;
|
|
100
|
+
}
|
|
101
|
+
process.stdout.write(generators[shell]());
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const help = `
|
|
106
|
+
Usage: doctrina completion <bash|zsh|pwsh>
|
|
107
|
+
|
|
108
|
+
Print a shell-completion script for the doctrina CLI, generated from
|
|
109
|
+
the same operation catalog that feeds --help (commands and their
|
|
110
|
+
subcommands; flags are not completed). Static output — install once,
|
|
111
|
+
regenerate after upgrading:
|
|
112
|
+
|
|
113
|
+
doctrina completion bash >> ~/.bashrc
|
|
114
|
+
doctrina completion zsh > "\${fpath[1]}/_doctrina"
|
|
115
|
+
doctrina completion pwsh >> $PROFILE
|
|
116
|
+
`;
|
|
@@ -70,8 +70,9 @@ function projectName(projectRoot) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
// Accepted ADRs (NNNN-slug.md with Status: accepted), oldest first, with the
|
|
73
|
-
// title read from the `# ADR NNNN — <title>` heading.
|
|
74
|
-
|
|
73
|
+
// title read from the `# ADR NNNN — <title>` heading. Exported for `prime`,
|
|
74
|
+
// which digests the same standing rules into the session primer.
|
|
75
|
+
export function acceptedDecisions(projectRoot) {
|
|
75
76
|
const dir = path.join(projectRoot, ".doctrina", "decisions");
|
|
76
77
|
const out = [];
|
|
77
78
|
if (!isDir(dir)) return out;
|
|
@@ -87,8 +88,9 @@ function acceptedDecisions(projectRoot) {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
// Bullets under a named `## <section>` of product.md, each accumulated across
|
|
90
|
-
// its continuation lines so a wrapped bullet reads as one rule.
|
|
91
|
-
|
|
91
|
+
// its continuation lines so a wrapped bullet reads as one rule. Exported for
|
|
92
|
+
// `prime`.
|
|
93
|
+
export function productSection(projectRoot, name) {
|
|
92
94
|
const p = path.join(projectRoot, ".doctrina", "product.md");
|
|
93
95
|
if (!isFile(p)) return [];
|
|
94
96
|
const lines = read(p).split(/\r?\n/);
|
package/src/commands/context.js
CHANGED
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
|
-
import {
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
5
6
|
import { listHeader } from "../lib/scan.js";
|
|
6
7
|
import { parseFrontmatter } from "./skill.js";
|
|
7
|
-
import { flagBool } from "../lib/args.js";
|
|
8
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
9
10
|
|
|
10
11
|
// Materialise the AGENTS.md read order as a command: print the exact
|
|
11
12
|
// context pack for a task, in the order an agent should read it. This is
|
|
12
13
|
// the context-engineering thesis turned into tooling — selection over
|
|
13
14
|
// dumping, ranked by the documented read order.
|
|
15
|
+
//
|
|
16
|
+
// The pack now carries a token estimate per file (chars/4 — the standard
|
|
17
|
+
// rough heuristic; the thesis the product is built on is that token spend
|
|
18
|
+
// predicts performance, so the pack must be measurable in tokens, not only
|
|
19
|
+
// lines). `--budget <n>` turns the estimate into a gate, and `--diff <ref>`
|
|
20
|
+
// scopes the pack to what changed since a git ref — the resume-session read.
|
|
21
|
+
|
|
22
|
+
const TOKEN_DIVISOR = 4;
|
|
14
23
|
|
|
15
24
|
export async function run(positional, flags) {
|
|
16
25
|
const capability = positional[0] ?? null;
|
|
17
26
|
const concat = flagBool(flags, "concat", false);
|
|
27
|
+
const budgetRaw = flagString(flags, "budget");
|
|
28
|
+
const diffRef = flagString(flags, "diff");
|
|
18
29
|
const projectRoot = process.cwd();
|
|
19
30
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
20
31
|
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
@@ -25,22 +36,43 @@ export async function run(positional, flags) {
|
|
|
25
36
|
return 2;
|
|
26
37
|
}
|
|
27
38
|
|
|
39
|
+
let budget = null;
|
|
40
|
+
if (budgetRaw !== undefined) {
|
|
41
|
+
budget = Number.parseInt(budgetRaw, 10);
|
|
42
|
+
if (!Number.isFinite(budget) || budget <= 0) {
|
|
43
|
+
console.error(c.red("error:") + ` --budget expects a positive token count, got "${budgetRaw}"`);
|
|
44
|
+
return 2;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --diff <ref>: restrict the stable artifacts to those changed since the
|
|
49
|
+
// ref (a resuming session already carries the rest); open changes are
|
|
50
|
+
// always in — they ARE the work being resumed.
|
|
51
|
+
let changed = null;
|
|
52
|
+
if (diffRef !== undefined) {
|
|
53
|
+
changed = changedPaths(projectRoot, diffRef);
|
|
54
|
+
if (changed === null) return 1; // changedPaths printed the error
|
|
55
|
+
}
|
|
56
|
+
// git emits POSIX paths; relPath emits native separators on Windows.
|
|
57
|
+
const wanted = (rel) => changed === null || changed.has(rel.replaceAll("\\", "/"));
|
|
58
|
+
|
|
28
59
|
const pack = [];
|
|
29
60
|
const pushFile = (rel, note) => {
|
|
30
61
|
const full = path.join(projectRoot, rel);
|
|
31
|
-
if (isFile(full))
|
|
62
|
+
if (!isFile(full)) return;
|
|
63
|
+
const text = read(full);
|
|
64
|
+
pack.push({ rel, lines: text.split(/\r?\n/).length, tokens: Math.round(text.length / TOKEN_DIVISOR), note });
|
|
32
65
|
};
|
|
33
66
|
|
|
34
67
|
// 1-2. Root rules and product truth — always first. Displayed paths are
|
|
35
68
|
// POSIX strings (path.join would emit backslashes on Windows).
|
|
36
|
-
pushFile("AGENTS.md", "root rules");
|
|
37
|
-
pushFile(".doctrina/product.md", "product truth");
|
|
69
|
+
if (wanted("AGENTS.md")) pushFile("AGENTS.md", "root rules");
|
|
70
|
+
if (wanted(".doctrina/product.md")) pushFile(".doctrina/product.md", "product truth");
|
|
38
71
|
|
|
39
72
|
// 3. The capability spec(s) — the current truth. With a named capability,
|
|
40
73
|
// just that one. Without one, EVERY active spec: the default orientation
|
|
41
74
|
// read previously jumped from product.md straight to the ADRs, leaving
|
|
42
|
-
// the single source of truth out of the pack entirely
|
|
43
|
-
// question read got history but not current truth). Specs come before
|
|
75
|
+
// the single source of truth out of the pack entirely. Specs come before
|
|
44
76
|
// open changes and ADRs, matching the documented read order.
|
|
45
77
|
let specMissing = false;
|
|
46
78
|
const specsRoot = path.join(projectRoot, ".doctrina", "specs");
|
|
@@ -54,11 +86,11 @@ export async function run(positional, flags) {
|
|
|
54
86
|
} else if (isDir(specsRoot)) {
|
|
55
87
|
for (const cap of readdirSync(specsRoot).sort()) {
|
|
56
88
|
const specRel = `.doctrina/specs/${cap}/spec.md`;
|
|
57
|
-
if (isFile(path.join(projectRoot, specRel))) pushFile(specRel, `spec: ${cap}`);
|
|
89
|
+
if (wanted(specRel) && isFile(path.join(projectRoot, specRel))) pushFile(specRel, `spec: ${cap}`);
|
|
58
90
|
}
|
|
59
91
|
}
|
|
60
92
|
|
|
61
|
-
// 4. Open changes (their proposal, tasks, and deltas).
|
|
93
|
+
// 4. Open changes (their proposal, tasks, and deltas) — never diff-filtered.
|
|
62
94
|
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
63
95
|
if (isDir(changesDir)) {
|
|
64
96
|
for (const id of readdirSync(changesDir).sort()) {
|
|
@@ -75,11 +107,15 @@ export async function run(positional, flags) {
|
|
|
75
107
|
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
76
108
|
for (const f of walk(adrDir)) {
|
|
77
109
|
if (!f.endsWith(".md")) continue;
|
|
110
|
+
const rel = relPath(projectRoot, f);
|
|
111
|
+
if (!wanted(rel)) continue;
|
|
78
112
|
const status = (listHeader(read(f), "Status") ?? "").toLowerCase();
|
|
79
|
-
if (status === "accepted") pushFile(
|
|
113
|
+
if (status === "accepted") pushFile(rel, "accepted ADR");
|
|
80
114
|
}
|
|
81
115
|
|
|
82
|
-
// 6. Skills are on-demand: list name + description, never the
|
|
116
|
+
// 6. Skills are on-demand: list name + description + trigger, never the
|
|
117
|
+
// body. The `when:` line is what lets an agent fire the right skill
|
|
118
|
+
// without loading any of them.
|
|
83
119
|
const onDemand = [];
|
|
84
120
|
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
85
121
|
for (const f of walk(skillsDir)) {
|
|
@@ -89,9 +125,13 @@ export async function run(positional, flags) {
|
|
|
89
125
|
rel: relPath(projectRoot, f),
|
|
90
126
|
name: parseFrontmatter(text, "name") ?? path.basename(f, ".md"),
|
|
91
127
|
description: parseFrontmatter(text, "description") ?? "<missing description>",
|
|
128
|
+
when: parseFrontmatter(text, "when") ?? null,
|
|
92
129
|
});
|
|
93
130
|
}
|
|
94
131
|
|
|
132
|
+
const totalTokens = pack.reduce((sum, item) => sum + item.tokens, 0);
|
|
133
|
+
const totalLines = pack.reduce((sum, item) => sum + item.lines, 0);
|
|
134
|
+
|
|
95
135
|
if (concat) {
|
|
96
136
|
for (const item of pack) {
|
|
97
137
|
console.log(`===== ${item.rel} (${item.note}) =====`);
|
|
@@ -99,20 +139,26 @@ export async function run(positional, flags) {
|
|
|
99
139
|
console.log(read(path.join(projectRoot, item.rel)).trimEnd());
|
|
100
140
|
console.log("");
|
|
101
141
|
}
|
|
142
|
+
// Keep the pack pipeable: the budget verdict goes to stderr.
|
|
143
|
+
if (budget !== null) {
|
|
144
|
+
reportBudget(totalTokens, budget, (msg) => console.error(msg));
|
|
145
|
+
}
|
|
102
146
|
} else {
|
|
103
|
-
|
|
147
|
+
const scope = diffRef !== undefined ? c.gray(` (diff vs ${diffRef})`) : "";
|
|
148
|
+
console.log(c.bold("Context pack") + (capability ? c.gray(` (capability: ${capability})`) : "") + scope + c.gray(" — read in this order:"));
|
|
104
149
|
console.log("");
|
|
105
150
|
pack.forEach((item, i) => {
|
|
106
|
-
console.log(`${i + 1}. ${c.cyan(item.rel.padEnd(56))} ${String(item.lines).padStart(4)} lines ${c.gray(item.note)}`);
|
|
151
|
+
console.log(`${i + 1}. ${c.cyan(item.rel.padEnd(56))} ${String(item.lines).padStart(4)} lines ${c.gray(`~${String(item.tokens).padStart(5)} tok`)} ${c.gray(item.note)}`);
|
|
107
152
|
});
|
|
108
|
-
const total = pack.reduce((sum, item) => sum + item.lines, 0);
|
|
109
153
|
console.log("");
|
|
110
|
-
console.log(c.gray(`${pack.length} files, ${
|
|
154
|
+
console.log(c.gray(`${pack.length} files, ${totalLines} lines, ~${totalTokens} tokens total (chars/4) · archive excluded by design`));
|
|
155
|
+
if (budget !== null) reportBudget(totalTokens, budget, (msg) => console.log(msg));
|
|
111
156
|
if (onDemand.length > 0) {
|
|
112
157
|
console.log("");
|
|
113
|
-
console.log(c.bold("On-demand skills") + c.gray(" (load the body only when the
|
|
158
|
+
console.log(c.bold("On-demand skills") + c.gray(" (load the body only when the trigger fires):"));
|
|
114
159
|
for (const s of onDemand) {
|
|
115
|
-
console.log(` ${c.cyan(s.name.padEnd(
|
|
160
|
+
console.log(` ${c.cyan(s.name.padEnd(26))} ${s.description}`);
|
|
161
|
+
if (s.when) console.log(` ${" ".repeat(26)} ${c.gray(`when: ${s.when}`)}`);
|
|
116
162
|
}
|
|
117
163
|
}
|
|
118
164
|
}
|
|
@@ -121,21 +167,59 @@ export async function run(positional, flags) {
|
|
|
121
167
|
console.error("");
|
|
122
168
|
console.error(c.yellow("warn:") + ` no spec for capability "${capability}" — create one with \`doctrina spec new ${capability}\``);
|
|
123
169
|
}
|
|
170
|
+
if (budget !== null && totalTokens > budget) return 1;
|
|
124
171
|
return 0;
|
|
125
172
|
}
|
|
126
173
|
|
|
174
|
+
function reportBudget(totalTokens, budget, log) {
|
|
175
|
+
if (totalTokens > budget) {
|
|
176
|
+
log(c.red("over budget") + ` ~${totalTokens} tokens > ${budget} — narrow the pack: name a capability, use --diff <ref>, split oversized specs`);
|
|
177
|
+
} else {
|
|
178
|
+
log(c.green("within budget") + c.gray(` ~${totalTokens} of ${budget} tokens (${Math.round((totalTokens / budget) * 100)}%)`));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Repo-relative POSIX paths changed since <ref> (worktree vs ref, plus
|
|
183
|
+
// untracked files). Returns null (after printing the error) when git or the
|
|
184
|
+
// ref is unavailable — the caller exits rather than silently showing a full
|
|
185
|
+
// pack the user asked to narrow.
|
|
186
|
+
function changedPaths(projectRoot, ref) {
|
|
187
|
+
const run = (args) => spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
|
|
188
|
+
const diff = run(["diff", "--name-only", ref, "--"]);
|
|
189
|
+
if (diff.status !== 0) {
|
|
190
|
+
console.error(c.red("error:") + ` git diff against "${ref}" failed${diff.stderr ? `: ${diff.stderr.trim().split("\n")[0]}` : " (not a git repository?)"}`);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
const untracked = run(["ls-files", "--others", "--exclude-standard"]);
|
|
194
|
+
const out = new Set();
|
|
195
|
+
for (const chunk of [diff.stdout, untracked.status === 0 ? untracked.stdout : ""]) {
|
|
196
|
+
for (const line of chunk.split(/\r?\n/)) {
|
|
197
|
+
const p = line.trim();
|
|
198
|
+
if (p) out.add(p);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
127
204
|
export const help = `
|
|
128
|
-
Usage: doctrina context [<capability>] [--concat]
|
|
205
|
+
Usage: doctrina context [<capability>] [--concat] [--budget <tokens>] [--diff <ref>]
|
|
129
206
|
|
|
130
207
|
Print the exact context pack for a task, in the documented read
|
|
131
208
|
order: AGENTS.md, product.md, the capability spec (when given —
|
|
132
|
-
otherwise every active spec,
|
|
133
|
-
|
|
134
|
-
|
|
209
|
+
otherwise every active spec), open changes, accepted ADRs. Skills are
|
|
210
|
+
listed name + description + when-trigger only — they are on-demand by
|
|
211
|
+
design. The change archive is excluded. Every file carries a token
|
|
212
|
+
estimate (chars/4) and the pack reports its total.
|
|
135
213
|
|
|
136
214
|
Flags:
|
|
137
|
-
--concat
|
|
138
|
-
|
|
215
|
+
--concat Print the file contents (with separators) instead of
|
|
216
|
+
the list — ready to pipe into an agent. The budget
|
|
217
|
+
verdict (if any) goes to stderr, keeping stdout pure.
|
|
218
|
+
--budget <n> Token budget for the pack: prints over/under and
|
|
219
|
+
exits 1 when the estimate exceeds it (a context gate).
|
|
220
|
+
--diff <ref> Scope the stable artifacts (AGENTS.md, product.md,
|
|
221
|
+
specs, ADRs) to those changed since the git ref; open
|
|
222
|
+
changes are always included. The resume-session read.
|
|
139
223
|
|
|
140
|
-
Read-only;
|
|
224
|
+
Read-only; exits 0 (or 1 when over --budget).
|
|
141
225
|
`;
|
package/src/commands/coverage.js
CHANGED
|
@@ -26,21 +26,15 @@ export async function run(_positional, flags) {
|
|
|
26
26
|
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
27
27
|
}
|
|
28
28
|
const strict = flagBool(flags, "strict", false);
|
|
29
|
+
const json = flagBool(flags, "json", false);
|
|
29
30
|
|
|
30
|
-
const
|
|
31
|
-
const reports = [];
|
|
32
|
-
if (isDir(specsDir)) {
|
|
33
|
-
for (const cap of readdirSync(specsDir).sort()) {
|
|
34
|
-
const specPath = path.join(specsDir, cap, "spec.md");
|
|
35
|
-
if (!isFile(specPath)) continue;
|
|
36
|
-
const criteria = extractAcceptanceCriteria(read(specPath));
|
|
37
|
-
if (criteria.length === 0) continue;
|
|
38
|
-
const rows = criteria.map((crit, i) => classify(crit, i + 1, projectRoot, path.dirname(specPath)));
|
|
39
|
-
reports.push({ cap, specPath, rows });
|
|
40
|
-
}
|
|
41
|
-
}
|
|
31
|
+
const reports = collect(projectRoot);
|
|
42
32
|
|
|
43
33
|
if (reports.length === 0) {
|
|
34
|
+
if (json) {
|
|
35
|
+
console.log(JSON.stringify({ specs: [], summary: { criteria: 0, covered: 0, dangling: 0, conditional: 0, pct: 100 } }, null, 2));
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
44
38
|
console.log(c.gray("no acceptance criteria found under .doctrina/specs/"));
|
|
45
39
|
return 0;
|
|
46
40
|
}
|
|
@@ -49,6 +43,22 @@ export async function run(_positional, flags) {
|
|
|
49
43
|
let totalCovered = 0;
|
|
50
44
|
let totalDangling = 0;
|
|
51
45
|
let totalConditional = 0;
|
|
46
|
+
for (const rep of reports) {
|
|
47
|
+
totalCriteria += rep.rows.length;
|
|
48
|
+
totalCovered += rep.rows.filter((r) => r.kind === "covered").length;
|
|
49
|
+
totalDangling += rep.rows.filter((r) => r.kind === "dangling").length;
|
|
50
|
+
totalConditional += rep.rows.filter((r) => r.kind === "conditional").length;
|
|
51
|
+
}
|
|
52
|
+
const jsonPct = totalCriteria === 0 ? 100 : Math.round((totalCovered / totalCriteria) * 100);
|
|
53
|
+
const jsonClean = totalCovered === totalCriteria && totalDangling === 0 && totalConditional === 0;
|
|
54
|
+
|
|
55
|
+
if (json) {
|
|
56
|
+
console.log(JSON.stringify({
|
|
57
|
+
specs: reports.map((rep) => ({ capability: rep.cap, criteria: rep.rows })),
|
|
58
|
+
summary: { criteria: totalCriteria, covered: totalCovered, dangling: totalDangling, conditional: totalConditional, pct: jsonPct },
|
|
59
|
+
}, null, 2));
|
|
60
|
+
return jsonClean ? 0 : strict ? 1 : 0;
|
|
61
|
+
}
|
|
52
62
|
|
|
53
63
|
console.log(c.bold("Coverage") + c.gray(" — acceptance criteria with linked evidence:"));
|
|
54
64
|
console.log("");
|
|
@@ -57,10 +67,6 @@ export async function run(_positional, flags) {
|
|
|
57
67
|
const covered = rep.rows.filter((r) => r.kind === "covered").length;
|
|
58
68
|
const dangling = rep.rows.filter((r) => r.kind === "dangling").length;
|
|
59
69
|
const conditional = rep.rows.filter((r) => r.kind === "conditional").length;
|
|
60
|
-
totalCriteria += rep.rows.length;
|
|
61
|
-
totalCovered += covered;
|
|
62
|
-
totalDangling += dangling;
|
|
63
|
-
totalConditional += conditional;
|
|
64
70
|
|
|
65
71
|
const notes = [];
|
|
66
72
|
if (conditional > 0) notes.push(c.yellow(`${conditional} conditional`));
|
|
@@ -97,6 +103,24 @@ export async function run(_positional, flags) {
|
|
|
97
103
|
return strict ? 1 : 0;
|
|
98
104
|
}
|
|
99
105
|
|
|
106
|
+
// Per-spec criterion rows — the full classification behind both the report
|
|
107
|
+
// and the --json output. Each row: { n, kind, missing?, skipped? }.
|
|
108
|
+
export function collect(projectRoot) {
|
|
109
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
110
|
+
const reports = [];
|
|
111
|
+
if (isDir(specsDir)) {
|
|
112
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
113
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
114
|
+
if (!isFile(specPath)) continue;
|
|
115
|
+
const criteria = extractAcceptanceCriteria(read(specPath));
|
|
116
|
+
if (criteria.length === 0) continue;
|
|
117
|
+
const rows = criteria.map((crit, i) => classify(crit, i + 1, projectRoot, path.dirname(specPath)));
|
|
118
|
+
reports.push({ cap, specPath, rows });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return reports;
|
|
122
|
+
}
|
|
123
|
+
|
|
100
124
|
// Pure summary of coverage across the spec tree, for other commands
|
|
101
125
|
// (`status`, `review`) that need the numbers without the report output.
|
|
102
126
|
export function summarize(projectRoot) {
|
|
@@ -256,4 +280,5 @@ nothing is cited. Read-only.
|
|
|
256
280
|
Flags:
|
|
257
281
|
--strict Exit 1 when any criterion is bare, dangling, or conditional
|
|
258
282
|
(CI gate). Without it the command always exits 0 (a report).
|
|
283
|
+
--json Emit per-spec criterion rows + summary as JSON.
|
|
259
284
|
`;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { exists } from "../lib/fs-ops.js";
|
|
6
|
+
import { collectStatus } from "./status.js";
|
|
7
|
+
import { c } from "../lib/colors.js";
|
|
8
|
+
|
|
9
|
+
// Aggregate diagnostic: the one command to run when "something looks wrong"
|
|
10
|
+
// and you do not know which gate to ask. It sequences the existing checks —
|
|
11
|
+
// validate, index drift, clean-checkout lint, template shape — and reports
|
|
12
|
+
// each with its exact remediation command. A DRIVER over existing commands
|
|
13
|
+
// (like `close`): it adds no checks of its own, so it can never disagree
|
|
14
|
+
// with the authoritative gates it fronts.
|
|
15
|
+
|
|
16
|
+
const cliEntry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "index.js");
|
|
17
|
+
|
|
18
|
+
export async function run(_positional, _flags) {
|
|
19
|
+
const projectRoot = process.cwd();
|
|
20
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
21
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.log(c.bold("Doctrina doctor") + c.gray(" — aggregate diagnostic (read-only):"));
|
|
25
|
+
console.log("");
|
|
26
|
+
|
|
27
|
+
let failures = 0;
|
|
28
|
+
let warningsTotal = 0;
|
|
29
|
+
const row = (state, name, detail, fix) => {
|
|
30
|
+
const mark = state === "ok" ? c.green("ok ") : state === "warn" ? c.yellow("warn") : c.red("FAIL");
|
|
31
|
+
console.log(` ${mark} ${name.padEnd(16)} ${detail}`);
|
|
32
|
+
if (fix) console.log(` ${" ".repeat(16)} ${c.gray(`fix: ${fix}`)}`);
|
|
33
|
+
if (state === "fail") failures += 1;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// 1. Structural gate (validate), machine-read so the counts are exact.
|
|
37
|
+
const v = runSelf(["validate", "--json"], projectRoot);
|
|
38
|
+
let vParsed = null;
|
|
39
|
+
try {
|
|
40
|
+
vParsed = JSON.parse(v.stdout);
|
|
41
|
+
} catch {
|
|
42
|
+
vParsed = null;
|
|
43
|
+
}
|
|
44
|
+
if (!vParsed) {
|
|
45
|
+
row("fail", "validate", "did not produce a report", "doctrina validate");
|
|
46
|
+
} else if (!vParsed.ok) {
|
|
47
|
+
row("fail", "validate", `${vParsed.errors.length} error${vParsed.errors.length === 1 ? "" : "s"}, ${vParsed.warnings.length} warning${vParsed.warnings.length === 1 ? "" : "s"}`, "doctrina validate (then `doctrina validate --fix` for index drift)");
|
|
48
|
+
for (const e of vParsed.errors.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e}`);
|
|
49
|
+
} else if (vParsed.warnings.length > 0) {
|
|
50
|
+
warningsTotal += vParsed.warnings.length;
|
|
51
|
+
row("warn", "validate", `0 errors, ${vParsed.warnings.length} warning${vParsed.warnings.length === 1 ? "" : "s"}`, "doctrina validate (warnings listed there)");
|
|
52
|
+
} else {
|
|
53
|
+
row("ok", "validate", "all structural checks pass");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 2. Index ↔ tree drift.
|
|
57
|
+
const drift = runSelf(["index", "rebuild", "--check"], projectRoot);
|
|
58
|
+
if (drift.status === 0) row("ok", "index", "index.json matches the tree");
|
|
59
|
+
else row("fail", "index", "index.json has drifted from the tree", "doctrina validate --fix (or `doctrina index rebuild`)");
|
|
60
|
+
|
|
61
|
+
// 3. Evidence and provenance ratios (cheap summaries; strict gates confirm).
|
|
62
|
+
const s = collectStatus(projectRoot);
|
|
63
|
+
if (s.coverage.totalCriteria === 0) {
|
|
64
|
+
row("warn", "coverage", "no acceptance criteria declared yet", "write criteria with evidence — see `doctrina coverage`");
|
|
65
|
+
warningsTotal += 1;
|
|
66
|
+
} else if (s.coverage.pct === 100 && s.coverage.totalDangling === 0 && s.coverage.totalConditional === 0) {
|
|
67
|
+
row("ok", "coverage", `${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria} criteria)`);
|
|
68
|
+
} else {
|
|
69
|
+
row("warn", "coverage", `${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria}${s.coverage.totalDangling ? `, ${s.coverage.totalDangling} dangling` : ""}${s.coverage.totalConditional ? `, ${s.coverage.totalConditional} conditional` : ""})`, "doctrina coverage (cite the missing evidence)");
|
|
70
|
+
warningsTotal += 1;
|
|
71
|
+
}
|
|
72
|
+
if (s.trace.anchors === 0) {
|
|
73
|
+
row("warn", "trace", "no intent anchors declared in product.md", "tag bullets `- [SC1] ...`, add `**Realizes:**` to specs");
|
|
74
|
+
warningsTotal += 1;
|
|
75
|
+
} else if (s.trace.realized === s.trace.anchors && s.trace.untraceable === 0 && s.trace.dangling === 0) {
|
|
76
|
+
row("ok", "trace", `${s.trace.realized}/${s.trace.anchors} anchors realized`);
|
|
77
|
+
} else {
|
|
78
|
+
row("warn", "trace", `${s.trace.realized}/${s.trace.anchors} realized, ${s.trace.dropped} dropped, ${s.trace.untraceable} untraceable`, "doctrina trace");
|
|
79
|
+
warningsTotal += 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 4. Clean-checkout reproducibility lint (ADR 0008).
|
|
83
|
+
const clean = runSelf(["verify", "--clean"], projectRoot);
|
|
84
|
+
if (clean.status === 0) row("ok", "clean-checkout", "no reproducibility footguns detected");
|
|
85
|
+
else row("fail", "clean-checkout", "a fresh clone would not build/run as-is", "doctrina verify --clean (fix the listed package.json footguns)");
|
|
86
|
+
|
|
87
|
+
// 5. Template shape vs the running CLI's recommendation.
|
|
88
|
+
const tmpl = runSelf(["templates", "check"], projectRoot);
|
|
89
|
+
if (tmpl.status === 0) row("ok", "templates", "project follows the current template shape");
|
|
90
|
+
else {
|
|
91
|
+
row("warn", "templates", "recommended sections/fields are missing", "doctrina templates update (--write applies)");
|
|
92
|
+
warningsTotal += 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 6. Verify configuration (the real build gate must exist to be run).
|
|
96
|
+
if (s.verify.invalid) row("fail", "verify config", ".doctrina/verify.json is invalid JSON", "fix the JSON, then `doctrina verify`");
|
|
97
|
+
else if (!s.verify.configured) {
|
|
98
|
+
row("warn", "verify config", "no verify.json — the build gate is undeclared", "doctrina verify --init");
|
|
99
|
+
warningsTotal += 1;
|
|
100
|
+
} else {
|
|
101
|
+
row("ok", "verify config", `${s.verify.checks} check${s.verify.checks === 1 ? "" : "s"} declared (run \`doctrina verify\` to execute)`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log("");
|
|
105
|
+
if (failures > 0) {
|
|
106
|
+
console.log(c.red("fail") + ` ${failures} failing area${failures === 1 ? "" : "s"} — fix commands above, then re-run \`doctrina doctor\``);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
if (warningsTotal > 0) {
|
|
110
|
+
console.log(c.yellow("warn") + ` no failures; ${warningsTotal} advisory item${warningsTotal === 1 ? "" : "s"} above`);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
console.log(c.green("ok") + " every diagnostic is green — `doctrina verify` remains the build gate to execute");
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function runSelf(args, cwd) {
|
|
118
|
+
return spawnSync(process.execPath, [cliEntry, ...args], {
|
|
119
|
+
cwd,
|
|
120
|
+
encoding: "utf8",
|
|
121
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const help = `
|
|
126
|
+
Usage: doctrina doctor
|
|
127
|
+
|
|
128
|
+
Aggregate diagnostic: run the structural gate (validate), the index
|
|
129
|
+
drift check, the coverage/trace ratios, the clean-checkout lint
|
|
130
|
+
(verify --clean), the template-shape check, and the verify-config
|
|
131
|
+
presence — each reported with its exact remediation command.
|
|
132
|
+
|
|
133
|
+
A driver over the existing commands; it adds no checks of its own.
|
|
134
|
+
Read-only. Exits 1 when any area fails, 0 otherwise (warnings allowed).
|
|
135
|
+
`;
|