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.
Files changed (36) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/completion.js +116 -0
  4. package/src/commands/constitution.js +138 -0
  5. package/src/commands/context.js +118 -22
  6. package/src/commands/coverage.js +41 -16
  7. package/src/commands/doctor.js +135 -0
  8. package/src/commands/handoff.js +94 -0
  9. package/src/commands/init.js +12 -1
  10. package/src/commands/next.js +32 -16
  11. package/src/commands/prime.js +118 -0
  12. package/src/commands/report.js +142 -0
  13. package/src/commands/show.js +173 -0
  14. package/src/commands/status.js +70 -40
  15. package/src/commands/trace.js +15 -2
  16. package/src/commands/validate.js +97 -1
  17. package/src/commands/why.js +132 -36
  18. package/src/index.js +12 -41
  19. package/src/lib/commands.js +115 -0
  20. package/src/lib/criteria.js +76 -0
  21. package/src/lib/index-json.js +1 -0
  22. package/src/lib/scan.js +7 -0
  23. package/templates/AGENTS.md.template +3 -2
  24. package/templates/README.md +7 -1
  25. package/templates/adapters/aider/CONVENTIONS.md.template +4 -3
  26. package/templates/adapters/claude/.claude/commands/doctrina-context.md.template +13 -0
  27. package/templates/adapters/claude/.claude/commands/doctrina-next.md.template +13 -0
  28. package/templates/adapters/claude/.claude/commands/doctrina-status.md.template +12 -0
  29. package/templates/adapters/claude/.claude/commands/doctrina-why.md.template +12 -0
  30. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +17 -0
  31. package/templates/adapters/cursor/.cursor/commands/doctrina-context.md.template +10 -0
  32. package/templates/adapters/cursor/.cursor/commands/doctrina-next.md.template +10 -0
  33. package/templates/adapters/cursor/.cursor/commands/doctrina-status.md.template +10 -0
  34. package/templates/adapters/cursor/.cursor/commands/doctrina-why.md.template +9 -0
  35. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +12 -0
  36. package/templates/doctrina/index.json.template +3 -0
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.0 — current release. Zero runtime dependencies
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -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
+ `;
@@ -0,0 +1,138 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { readdirSync } from "node:fs";
4
+ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
5
+ import { listHeader } from "../lib/scan.js";
6
+ import { c } from "../lib/colors.js";
7
+
8
+ // The project's standing rules in one read — Spec Kit parity for its
9
+ // `constitution.md`, but ASSEMBLED, not a new home for facts. Doctrina's
10
+ // constitution is its accepted ADRs (the immutable decisions that govern how
11
+ // the codebase evolves) plus the product's declared non-goals. Both already
12
+ // own those facts elsewhere; this is a read-only digest that gathers them so a
13
+ // human or agent (especially one migrating from Spec Kit, which expects a
14
+ // single constitution) can see "the rules" without reading the whole tree.
15
+ //
16
+ // To change a principle, supersede its ADR; to change a non-goal, edit
17
+ // product.md. The command never writes — it has nothing of its own to own.
18
+
19
+ export async function run(_positional, _flags) {
20
+ const projectRoot = process.cwd();
21
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
22
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
23
+ }
24
+
25
+ const project = projectName(projectRoot);
26
+ console.log(
27
+ c.bold("Doctrina constitution") +
28
+ c.gray(` — ${project} (standing rules: accepted decisions + non-goals)`),
29
+ );
30
+
31
+ // Principles — the accepted ADRs, by number. Every accepted decision is a
32
+ // binding rule; superseded/withdrawn/proposed ADRs are not yet (or no longer)
33
+ // in force and stay out.
34
+ console.log("");
35
+ console.log(c.bold(" Principles") + c.gray(" (immutable — supersede an ADR to change one)"));
36
+ const adrs = acceptedDecisions(projectRoot);
37
+ if (adrs.length === 0) {
38
+ console.log(` ${c.gray("no accepted ADRs yet — record decisions with `doctrina decision new`")}`);
39
+ } else {
40
+ for (const a of adrs) console.log(` ${c.cyan("ADR " + a.id)} ${a.title}`);
41
+ }
42
+
43
+ // Non-goals — the explicit "what this project will not be", from product.md.
44
+ console.log("");
45
+ console.log(c.bold(" Non-goals") + c.gray(" (.doctrina/product.md)"));
46
+ const nonGoals = productSection(projectRoot, "Non-goals");
47
+ if (nonGoals.length === 0) {
48
+ console.log(` ${c.gray("none declared — add a `## Non-goals` section to product.md")}`);
49
+ } else {
50
+ for (const g of nonGoals) console.log(` ${c.gray("•")} ${g}`);
51
+ }
52
+
53
+ console.log("");
54
+ console.log(
55
+ c.gray(
56
+ ` ${adrs.length} accepted decision${adrs.length === 1 ? "" : "s"} · ` +
57
+ `${nonGoals.length} non-goal${nonGoals.length === 1 ? "" : "s"} · read-only`,
58
+ ),
59
+ );
60
+ return 0;
61
+ }
62
+
63
+ function projectName(projectRoot) {
64
+ try {
65
+ return JSON.parse(read(path.join(projectRoot, ".doctrina", "index.json"))).project
66
+ ?? path.basename(projectRoot);
67
+ } catch {
68
+ return path.basename(projectRoot);
69
+ }
70
+ }
71
+
72
+ // Accepted ADRs (NNNN-slug.md with Status: accepted), oldest first, with the
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) {
76
+ const dir = path.join(projectRoot, ".doctrina", "decisions");
77
+ const out = [];
78
+ if (!isDir(dir)) return out;
79
+ for (const f of readdirSync(dir).sort()) {
80
+ const m = f.match(/^(\d{4})-.*\.md$/);
81
+ if (!m) continue;
82
+ const text = read(path.join(dir, f));
83
+ if ((listHeader(text, "Status") ?? "").toLowerCase() !== "accepted") continue;
84
+ const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
85
+ out.push({ id: m[1], title: titleMatch ? titleMatch[1].trim() : f });
86
+ }
87
+ return out;
88
+ }
89
+
90
+ // Bullets under a named `## <section>` of product.md, each accumulated across
91
+ // its continuation lines so a wrapped bullet reads as one rule. Exported for
92
+ // `prime`.
93
+ export function productSection(projectRoot, name) {
94
+ const p = path.join(projectRoot, ".doctrina", "product.md");
95
+ if (!isFile(p)) return [];
96
+ const lines = read(p).split(/\r?\n/);
97
+ let inSection = false;
98
+ const out = [];
99
+ let cur = null;
100
+ const flush = () => {
101
+ if (cur) out.push(cur.replace(/\s+/g, " ").trim());
102
+ cur = null;
103
+ };
104
+ for (const line of lines) {
105
+ if (/^##\s+/.test(line)) {
106
+ if (inSection) {
107
+ flush();
108
+ break;
109
+ }
110
+ if (new RegExp(`^##\\s+${name}\\b`, "i").test(line)) inSection = true;
111
+ continue;
112
+ }
113
+ if (!inSection) continue;
114
+ const m = line.match(/^\s*[-*]\s+(.+)$/);
115
+ if (m) {
116
+ flush();
117
+ cur = m[1];
118
+ } else if (cur && line.trim() !== "") {
119
+ cur += " " + line.trim();
120
+ } else if (line.trim() === "") {
121
+ flush();
122
+ }
123
+ }
124
+ flush();
125
+ return out;
126
+ }
127
+
128
+ export const help = `
129
+ Usage: doctrina constitution
130
+
131
+ Print the project's standing rules in one read: the accepted ADRs (the
132
+ immutable decisions that govern how the codebase evolves) and the product's
133
+ declared non-goals. Read-only — it assembles facts the ADRs and product.md
134
+ already own; it never writes.
135
+
136
+ This is the Spec Kit \`constitution.md\` analogue: a single place to see the
137
+ non-negotiables. To change one, supersede the ADR or edit product.md.
138
+ `;
@@ -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 { exists, isDir, isFile, lineCount, read, relPath, walk } from "../lib/fs-ops.js";
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,19 +36,46 @@ 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)) pack.push({ rel, lines: lineCount(full), note });
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
- // 3. The capability spec under work.
72
+ // 3. The capability spec(s) — the current truth. With a named capability,
73
+ // just that one. Without one, EVERY active spec: the default orientation
74
+ // read previously jumped from product.md straight to the ADRs, leaving
75
+ // the single source of truth out of the pack entirely. Specs come before
76
+ // open changes and ADRs, matching the documented read order.
40
77
  let specMissing = false;
78
+ const specsRoot = path.join(projectRoot, ".doctrina", "specs");
41
79
  if (capability) {
42
80
  const specRel = `.doctrina/specs/${capability}/spec.md`;
43
81
  if (isFile(path.join(projectRoot, specRel))) {
@@ -45,9 +83,14 @@ export async function run(positional, flags) {
45
83
  } else {
46
84
  specMissing = true;
47
85
  }
86
+ } else if (isDir(specsRoot)) {
87
+ for (const cap of readdirSync(specsRoot).sort()) {
88
+ const specRel = `.doctrina/specs/${cap}/spec.md`;
89
+ if (wanted(specRel) && isFile(path.join(projectRoot, specRel))) pushFile(specRel, `spec: ${cap}`);
90
+ }
48
91
  }
49
92
 
50
- // 4. Open changes (their proposal, tasks, and deltas).
93
+ // 4. Open changes (their proposal, tasks, and deltas) — never diff-filtered.
51
94
  const changesDir = path.join(projectRoot, ".doctrina", "changes");
52
95
  if (isDir(changesDir)) {
53
96
  for (const id of readdirSync(changesDir).sort()) {
@@ -64,11 +107,15 @@ export async function run(positional, flags) {
64
107
  const adrDir = path.join(projectRoot, ".doctrina", "decisions");
65
108
  for (const f of walk(adrDir)) {
66
109
  if (!f.endsWith(".md")) continue;
110
+ const rel = relPath(projectRoot, f);
111
+ if (!wanted(rel)) continue;
67
112
  const status = (listHeader(read(f), "Status") ?? "").toLowerCase();
68
- if (status === "accepted") pushFile(relPath(projectRoot, f), "accepted ADR");
113
+ if (status === "accepted") pushFile(rel, "accepted ADR");
69
114
  }
70
115
 
71
- // 6. Skills are on-demand: list name + description, never the body.
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.
72
119
  const onDemand = [];
73
120
  const skillsDir = path.join(projectRoot, ".doctrina", "skills");
74
121
  for (const f of walk(skillsDir)) {
@@ -78,9 +125,13 @@ export async function run(positional, flags) {
78
125
  rel: relPath(projectRoot, f),
79
126
  name: parseFrontmatter(text, "name") ?? path.basename(f, ".md"),
80
127
  description: parseFrontmatter(text, "description") ?? "<missing description>",
128
+ when: parseFrontmatter(text, "when") ?? null,
81
129
  });
82
130
  }
83
131
 
132
+ const totalTokens = pack.reduce((sum, item) => sum + item.tokens, 0);
133
+ const totalLines = pack.reduce((sum, item) => sum + item.lines, 0);
134
+
84
135
  if (concat) {
85
136
  for (const item of pack) {
86
137
  console.log(`===== ${item.rel} (${item.note}) =====`);
@@ -88,20 +139,26 @@ export async function run(positional, flags) {
88
139
  console.log(read(path.join(projectRoot, item.rel)).trimEnd());
89
140
  console.log("");
90
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
+ }
91
146
  } else {
92
- console.log(c.bold("Context pack") + (capability ? c.gray(` (capability: ${capability})`) : "") + c.gray(" — read in this order:"));
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:"));
93
149
  console.log("");
94
150
  pack.forEach((item, i) => {
95
- 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)}`);
96
152
  });
97
- const total = pack.reduce((sum, item) => sum + item.lines, 0);
98
153
  console.log("");
99
- console.log(c.gray(`${pack.length} files, ${total} lines total · archive excluded by design`));
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));
100
156
  if (onDemand.length > 0) {
101
157
  console.log("");
102
- console.log(c.bold("On-demand skills") + c.gray(" (load the body only when the task matches):"));
158
+ console.log(c.bold("On-demand skills") + c.gray(" (load the body only when the trigger fires):"));
103
159
  for (const s of onDemand) {
104
- console.log(` ${c.cyan(s.name.padEnd(24))} ${s.description} ${c.gray(s.rel)}`);
160
+ console.log(` ${c.cyan(s.name.padEnd(26))} ${s.description}`);
161
+ if (s.when) console.log(` ${" ".repeat(26)} ${c.gray(`when: ${s.when}`)}`);
105
162
  }
106
163
  }
107
164
  }
@@ -110,20 +167,59 @@ export async function run(positional, flags) {
110
167
  console.error("");
111
168
  console.error(c.yellow("warn:") + ` no spec for capability "${capability}" — create one with \`doctrina spec new ${capability}\``);
112
169
  }
170
+ if (budget !== null && totalTokens > budget) return 1;
113
171
  return 0;
114
172
  }
115
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
+
116
204
  export const help = `
117
- Usage: doctrina context [<capability>] [--concat]
205
+ Usage: doctrina context [<capability>] [--concat] [--budget <tokens>] [--diff <ref>]
118
206
 
119
207
  Print the exact context pack for a task, in the documented read
120
- order: AGENTS.md, product.md, the capability spec (when given), open
121
- changes, accepted ADRs. Skills are listed name + description only —
122
- they are on-demand by design. The change archive is excluded.
208
+ order: AGENTS.md, product.md, the capability spec (when given —
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.
123
213
 
124
214
  Flags:
125
- --concat Print the file contents (with separators) instead of the
126
- file list — ready to pipe into an agent.
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.
127
223
 
128
- Read-only; always exits 0.
224
+ Read-only; exits 0 (or 1 when over --budget).
129
225
  `;
@@ -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 specsDir = path.join(projectRoot, ".doctrina", "specs");
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
  `;