doctrina-cli 0.8.0 → 0.10.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 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.10.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.8.0",
3
+ "version": "0.10.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,136 @@
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.
74
+ function acceptedDecisions(projectRoot) {
75
+ const dir = path.join(projectRoot, ".doctrina", "decisions");
76
+ const out = [];
77
+ if (!isDir(dir)) return out;
78
+ for (const f of readdirSync(dir).sort()) {
79
+ const m = f.match(/^(\d{4})-.*\.md$/);
80
+ if (!m) continue;
81
+ const text = read(path.join(dir, f));
82
+ if ((listHeader(text, "Status") ?? "").toLowerCase() !== "accepted") continue;
83
+ const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
84
+ out.push({ id: m[1], title: titleMatch ? titleMatch[1].trim() : f });
85
+ }
86
+ return out;
87
+ }
88
+
89
+ // Bullets under a named `## <section>` of product.md, each accumulated across
90
+ // its continuation lines so a wrapped bullet reads as one rule.
91
+ function productSection(projectRoot, name) {
92
+ const p = path.join(projectRoot, ".doctrina", "product.md");
93
+ if (!isFile(p)) return [];
94
+ const lines = read(p).split(/\r?\n/);
95
+ let inSection = false;
96
+ const out = [];
97
+ let cur = null;
98
+ const flush = () => {
99
+ if (cur) out.push(cur.replace(/\s+/g, " ").trim());
100
+ cur = null;
101
+ };
102
+ for (const line of lines) {
103
+ if (/^##\s+/.test(line)) {
104
+ if (inSection) {
105
+ flush();
106
+ break;
107
+ }
108
+ if (new RegExp(`^##\\s+${name}\\b`, "i").test(line)) inSection = true;
109
+ continue;
110
+ }
111
+ if (!inSection) continue;
112
+ const m = line.match(/^\s*[-*]\s+(.+)$/);
113
+ if (m) {
114
+ flush();
115
+ cur = m[1];
116
+ } else if (cur && line.trim() !== "") {
117
+ cur += " " + line.trim();
118
+ } else if (line.trim() === "") {
119
+ flush();
120
+ }
121
+ }
122
+ flush();
123
+ return out;
124
+ }
125
+
126
+ export const help = `
127
+ Usage: doctrina constitution
128
+
129
+ Print the project's standing rules in one read: the accepted ADRs (the
130
+ immutable decisions that govern how the codebase evolves) and the product's
131
+ declared non-goals. Read-only — it assembles facts the ADRs and product.md
132
+ already own; it never writes.
133
+
134
+ This is the Spec Kit \`constitution.md\` analogue: a single place to see the
135
+ non-negotiables. To change one, supersede the ADR or edit product.md.
136
+ `;
@@ -36,8 +36,14 @@ export async function run(positional, flags) {
36
36
  pushFile("AGENTS.md", "root rules");
37
37
  pushFile(".doctrina/product.md", "product truth");
38
38
 
39
- // 3. The capability spec under work.
39
+ // 3. The capability spec(s) — the current truth. With a named capability,
40
+ // just that one. Without one, EVERY active spec: the default orientation
41
+ // read previously jumped from product.md straight to the ADRs, leaving
42
+ // the single source of truth out of the pack entirely (a review/debug/
43
+ // question read got history but not current truth). Specs come before
44
+ // open changes and ADRs, matching the documented read order.
40
45
  let specMissing = false;
46
+ const specsRoot = path.join(projectRoot, ".doctrina", "specs");
41
47
  if (capability) {
42
48
  const specRel = `.doctrina/specs/${capability}/spec.md`;
43
49
  if (isFile(path.join(projectRoot, specRel))) {
@@ -45,6 +51,11 @@ export async function run(positional, flags) {
45
51
  } else {
46
52
  specMissing = true;
47
53
  }
54
+ } else if (isDir(specsRoot)) {
55
+ for (const cap of readdirSync(specsRoot).sort()) {
56
+ const specRel = `.doctrina/specs/${cap}/spec.md`;
57
+ if (isFile(path.join(projectRoot, specRel))) pushFile(specRel, `spec: ${cap}`);
58
+ }
48
59
  }
49
60
 
50
61
  // 4. Open changes (their proposal, tasks, and deltas).
@@ -117,9 +128,10 @@ export const help = `
117
128
  Usage: doctrina context [<capability>] [--concat]
118
129
 
119
130
  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.
131
+ order: AGENTS.md, product.md, the capability spec (when given —
132
+ otherwise every active spec, so the current truth is never absent),
133
+ open changes, accepted ADRs. Skills are listed name + description only
134
+ — they are on-demand by design. The change archive is excluded.
123
135
 
124
136
  Flags:
125
137
  --concat Print the file contents (with separators) instead of the
@@ -1,11 +1,12 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { readdirSync } from "node:fs";
4
+ import { spawnSync } from "node:child_process";
4
5
  import { exists, isDir, isFile, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
5
6
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
6
7
  import * as idx from "../lib/index-json.js";
7
8
  import { today } from "../lib/dates.js";
8
- import { flagBool } from "../lib/args.js";
9
+ import { flagBool, flagString } from "../lib/args.js";
9
10
  import { c } from "../lib/colors.js";
10
11
  import { suggest } from "../lib/suggest.js";
11
12
 
@@ -30,57 +31,91 @@ export async function run(positional, flags) {
30
31
  // Surface (and optionally scaffold) skills worth capturing (review 2026-06-27
31
32
  // passive-user feature #6). A skill exists so a hard-won lesson is not relearned
32
33
  // by the next agent/session; the framework otherwise never pulls you toward
33
- // writing one. `skill suggest` scans the archived changes for fix-shaped work —
34
- // the textbook case for a skill — and lists candidates whose lesson is not yet
35
- // captured. With --write it scaffolds a stub per candidate, pre-seeded from the
36
- // change so the human/agent only fills the body. Deterministic pattern match on
37
- // the archived folder name — a hint, never a decision (ADR 0005).
34
+ // writing one. `skill suggest` scans for fix-shaped work — the textbook case for
35
+ // a skill — from two deterministic sources and lists candidates whose lesson is
36
+ // not yet captured: archived change proposals, and the git commit history
37
+ // (ADR 0013). The archive only fills if a team runs the `doctrina change`
38
+ // lifecycle, so without the git source `suggest` is inert in most repos. With
39
+ // --write it scaffolds a stub per candidate, pre-seeded from the source so the
40
+ // human/agent only fills the body. Both sources are a pattern match — a hint,
41
+ // never a decision (ADR 0005); skills are still authored, never generated.
38
42
  const FIX_SHAPED = /(?:^|-)(fix|bug|hotfix|patch|parse|parsing|tolerate|workaround|race|deadlock|flaky|retry|escape|sanitize|sanitise)(?:-|$)/;
39
43
 
44
+ // The same idea applied to a commit subject. Narrower than FIX_SHAPED on
45
+ // purpose: a deliberate folder slug tolerates more noise than a free-form
46
+ // subject line. Matches conventional fix-type prefixes (fix:, fix(scope):,
47
+ // bug:, hotfix:, patch:, and the free-form "Fix the…") plus the strongly
48
+ // fix-flavoured debugging keywords — never feat:/refactor:/docs:/chore:.
49
+ const FIX_SHAPED_SUBJECT =
50
+ /^(?:fix|bug|hotfix|patch)\b|\b(?:tolerate|workaround|deadlock|flaky|race condition|retry|sanitiz|sanitis)/i;
51
+
52
+ // How far back the git source looks when --since is not given, and how many
53
+ // candidates we print before collapsing the rest into a "+N more" line.
54
+ const GIT_LOG_LIMIT = 200;
55
+ const MAX_SHOWN = 20;
56
+
40
57
  function skillSuggest(args, flags) {
41
58
  const projectRoot = process.cwd();
42
59
  ensureDoctrinaProject(projectRoot);
43
60
  const doWrite = flagBool(flags, "write", false);
61
+ const since = flagString(flags, "since");
44
62
 
45
- const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
46
63
  const skillsDir = path.join(projectRoot, ".doctrina", "skills");
47
64
  const existing = new Set(
48
65
  isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")).map((f) => path.basename(f, ".md")) : [],
49
66
  );
50
67
 
68
+ // Two deterministic sources, deduped by slug. `seen` guards against a lesson
69
+ // appearing twice (e.g. a fix committed and later archived as a change).
51
70
  const candidates = [];
71
+ const seen = new Set();
72
+
73
+ // Source 1 — archived change proposals with a fix-shaped folder name.
74
+ const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
52
75
  if (isDir(archiveDir)) {
53
76
  for (const name of readdirSync(archiveDir).sort()) {
54
77
  if (!isDir(path.join(archiveDir, name))) continue;
55
78
  const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
56
79
  if (!FIX_SHAPED.test(id)) continue;
57
80
  const slug = skillSlug(id);
58
- if (existing.has(slug)) continue; // lesson already captured
81
+ if (existing.has(slug) || seen.has(slug)) continue; // captured or already queued
59
82
  const proposal = path.join(archiveDir, name, "proposal.md");
60
83
  const why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
61
- candidates.push({ id, slug, why, archive: name });
84
+ seen.add(slug);
85
+ candidates.push({ id, slug, why, source: "change", from: name });
62
86
  }
63
87
  }
64
88
 
89
+ // Source 2 — fix-shaped commits in the git history (ADR 0013). Archive wins a
90
+ // slug collision: a `## Why` paragraph is a richer seed than a subject line.
91
+ for (const cand of gitFixCommits(projectRoot, { since, limit: GIT_LOG_LIMIT })) {
92
+ if (existing.has(cand.slug) || seen.has(cand.slug)) continue;
93
+ seen.add(cand.slug);
94
+ candidates.push(cand);
95
+ }
96
+
65
97
  if (candidates.length === 0) {
66
- console.log(c.gray("no uncaptured fix-shaped lessons found in .doctrina/changes/archive/"));
67
- console.log(c.gray("(skills are written by humans; this only surfaces candidates — `doctrina skill new <slug>` to author one)"));
98
+ console.log(c.gray("no uncaptured fix-shaped lessons found in .doctrina/changes/archive/ or the git history"));
99
+ console.log(c.gray("(scans archived changes and fix-shaped commits — pass --since <ref> to widen the git window;"));
100
+ console.log(c.gray(" skills are written by humans, so this only surfaces candidates — `doctrina skill new <slug>` to author one)"));
68
101
  return 0;
69
102
  }
70
103
 
71
104
  if (!doWrite) {
72
105
  console.log(c.bold("Skill candidates") + c.gray(" — fix-shaped lessons not yet captured:"));
73
106
  console.log("");
74
- for (const cand of candidates) {
75
- console.log(` ${c.cyan(cand.slug.padEnd(28))} ${c.gray("from " + cand.archive)}`);
107
+ for (const cand of candidates.slice(0, MAX_SHOWN)) {
108
+ console.log(` ${c.cyan(cand.slug.padEnd(28))} ${c.gray(provenance(cand))}`);
76
109
  if (cand.why) console.log(` ${c.gray(cand.why.slice(0, 90))}`);
77
110
  }
111
+ const hidden = candidates.length - MAX_SHOWN;
112
+ if (hidden > 0) console.log(c.gray(` … +${hidden} more`));
78
113
  console.log("");
79
114
  console.log(c.gray(`${candidates.length} candidate${candidates.length === 1 ? "" : "s"}. Scaffold them: `) + c.cyan("doctrina skill suggest --write"));
80
115
  return 0;
81
116
  }
82
117
 
83
- // --write: scaffold a stub per candidate, pre-seeded from the change.
118
+ // --write: scaffold a stub per candidate, pre-seeded from its source.
84
119
  const templatesDir = locateTemplatesDir();
85
120
  const tplPath = path.join(templatesDir, "skill.md.template");
86
121
  const tpl = read(tplPath);
@@ -97,7 +132,7 @@ function skillSuggest(args, flags) {
97
132
  let body = substitute(tpl, { SKILL_NAME: cand.slug });
98
133
  if (cand.why) {
99
134
  // Drop the originating lesson into the body so authoring is "fill", not "start".
100
- body += `\n<!-- Candidate from change "${cand.id}". The lesson to capture:\n${cand.why}\n-->\n`;
135
+ body += `\n<!-- ${seedLabel(cand)}. The lesson to capture:\n${cand.why}\n-->\n`;
101
136
  }
102
137
  write(target, body, { force: false });
103
138
  idx.addSkill(index, {
@@ -118,6 +153,63 @@ function skillSuggest(args, flags) {
118
153
  return 0;
119
154
  }
120
155
 
156
+ // One-line origin shown next to a candidate slug.
157
+ function provenance(cand) {
158
+ return cand.source === "commit" ? `from commit ${cand.ref}` : `from ${cand.from}`;
159
+ }
160
+
161
+ // The label seeded into a scaffolded skill body so the author knows where the
162
+ // lesson came from.
163
+ function seedLabel(cand) {
164
+ return cand.source === "commit"
165
+ ? `Candidate from commit ${cand.ref}`
166
+ : `Candidate from change "${cand.id}"`;
167
+ }
168
+
169
+ // Source 2 for `skill suggest`: fix-shaped commits in the git history (ADR
170
+ // 0013). Deterministic — a regex on the subject, never a read of the diff
171
+ // (ADR 0005 semantic-fidelity ceiling). Returns [] silently when this is not a
172
+ // git repo, git is absent, or the --since ref is unknown, so the command
173
+ // degrades to archive-only rather than erroring.
174
+ function gitFixCommits(projectRoot, { since, limit }) {
175
+ // US (0x1f) between fields, RS (0x1e) between records — robust against any
176
+ // newline or punctuation inside a subject or body.
177
+ const format = "%H%x1f%s%x1f%b%x1e";
178
+ const args = ["log", "--no-merges", `--format=${format}`];
179
+ if (since) args.push(`${since}..HEAD`);
180
+ else args.push("-n", String(limit));
181
+
182
+ const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
183
+ if (r.error || r.status !== 0 || !r.stdout) return [];
184
+
185
+ const out = [];
186
+ const seen = new Set();
187
+ for (const record of r.stdout.split("\x1e")) {
188
+ const [sha, subject = "", body = ""] = record.replace(/^\r?\n/, "").split("\x1f");
189
+ if (!sha || !sha.trim()) continue;
190
+ if (!FIX_SHAPED_SUBJECT.test(subject)) continue;
191
+ const slug = commitSlug(subject);
192
+ if (!slug || seen.has(slug)) continue; // first commit wins a duplicate lesson
193
+ seen.add(slug);
194
+ const firstBodyLine = body.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
195
+ out.push({
196
+ id: slug,
197
+ slug,
198
+ source: "commit",
199
+ ref: sha.trim().slice(0, 9),
200
+ why: subject.trim() + (firstBodyLine ? ` — ${firstBodyLine}` : ""),
201
+ });
202
+ }
203
+ return out;
204
+ }
205
+
206
+ // Derive a skill slug from a commit subject: drop a conventional-commit
207
+ // "type(scope): " prefix, then reuse skillSlug for the lowercase/hyphenate/cap.
208
+ function commitSlug(subject) {
209
+ const stripped = subject.replace(/^[a-z]+(?:\([^)]*\))?!?\s*:\s*/i, "");
210
+ return skillSlug(stripped.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""));
211
+ }
212
+
121
213
  function skillSlug(id) {
122
214
  // Reuse the change id as the skill slug, trimmed to a sane length.
123
215
  const slug = id.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
@@ -282,10 +374,13 @@ Subcommands:
282
374
  sync Copy each skill's frontmatter description into
283
375
  .doctrina/index.json so the index never drifts
284
376
  from the frontmatter (single source of truth).
285
- suggest [--write]
286
- Surface fix-shaped lessons in the change archive whose
287
- skill is not yet captured. --write scaffolds a stub per
288
- candidate (pre-seeded from the change) to fill in.
377
+ suggest [--write] [--since <ref>]
378
+ Surface fix-shaped lessons not yet captured as skills,
379
+ from two sources: archived change proposals and the git
380
+ commit history (ADR 0013). --write scaffolds a stub per
381
+ candidate (pre-seeded from its source) to fill in.
382
+ --since <ref> scans commits in <ref>..HEAD instead of the
383
+ last 200 (e.g. --since v0.7.0).
289
384
 
290
385
  Skills are written by humans, not generated. See docs/en/skills.md
291
386
  for the design rationale and the distinction from specs / AGENTS.md /
@@ -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
  }
@@ -3,6 +3,8 @@ 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
5
  import { specHeader, listHeader } from "../lib/scan.js";
6
+ import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
7
+ import * as idx from "../lib/index-json.js";
6
8
  import { c } from "../lib/colors.js";
7
9
  import { suggest } from "../lib/suggest.js";
8
10
 
@@ -66,16 +68,35 @@ export async function run(positional, _flags) {
66
68
  const purpose = sectionParagraph(text, "Purpose");
67
69
  if (purpose) console.log(" " + purpose.replace(/\s+/g, " ").trim());
68
70
 
69
- // 3. The proof (acceptance criteria with cited evidence).
71
+ // 3. The proof (acceptance criteria with cited evidence). Read through the
72
+ // shared multi-line parser so the marks here agree with `doctrina
73
+ // coverage` — a criterion that cites its proof on a continuation line is
74
+ // no longer mis-reported as "no evidence cited". The mark distinguishes
75
+ // the three honest states, including the one that used to read as a false
76
+ // green: a [verified] claim that cites nothing.
70
77
  console.log("");
71
78
  console.log(c.bold(" Proof"));
72
- const criteria = acceptanceWithEvidence(text);
79
+ const criteria = parseAcceptanceCriteria(text);
73
80
  if (criteria.length === 0) {
74
81
  console.log(` ${c.gray("no acceptance criteria declared")}`);
75
82
  } else {
76
83
  for (const cr of criteria) {
77
- const proof = cr.proof.length ? c.gray(cr.proof.map((p) => `\`${p}\``).join(", ")) : c.yellow("no evidence cited");
78
- console.log(` ${cr.mark} #${cr.n} ${proof}`);
84
+ const paths = cr.proofPaths.map((p) => `\`${p}\``).join(", ");
85
+ let mark, note;
86
+ if (isVerified(cr) && cr.proofPaths.length > 0) {
87
+ mark = c.green("✓");
88
+ note = c.gray(paths);
89
+ } else if (isVerified(cr)) {
90
+ mark = c.yellow("~");
91
+ note = c.yellow("marked verified but cites no proof");
92
+ } else if (cr.marker !== null) {
93
+ mark = c.yellow("○");
94
+ note = cr.proofPaths.length ? c.gray(paths) : c.gray(`[${cr.marker}]`);
95
+ } else {
96
+ mark = c.gray("·");
97
+ note = cr.proofPaths.length ? c.gray(paths) : c.yellow("no evidence cited");
98
+ }
99
+ console.log(` ${mark} #${cr.n} ${note}`);
79
100
  }
80
101
  }
81
102
 
@@ -89,9 +110,44 @@ export async function run(positional, _flags) {
89
110
  for (const a of adrs) console.log(` ${c.cyan("ADR " + a.id)} ${a.title}`);
90
111
  }
91
112
 
113
+ // 5. History — the archived changes that built this capability. The default
114
+ // context pack excludes changes/archive/ by design, so "what shaped this
115
+ // spec?" otherwise loses the change trail. The index ledger already
116
+ // records each archived change's affected capabilities (specs_affected);
117
+ // surface them here so the provenance chain reaches back to the work that
118
+ // delivered the spec, not only the intent and decisions in front of it.
119
+ console.log("");
120
+ console.log(c.bold(" History"));
121
+ const history = archivedChangesFor(projectRoot, cap);
122
+ if (history.length === 0) {
123
+ console.log(` ${c.gray("no archived change recorded against this capability")}`);
124
+ } else {
125
+ for (const ch of history) {
126
+ console.log(` ${c.cyan(ch.applied ?? "—")} ${ch.title} ${c.gray(`(${ch.op})`)}`);
127
+ }
128
+ }
129
+
92
130
  return 0;
93
131
  }
94
132
 
133
+ // Archived changes whose recorded specs_affected include this capability,
134
+ // oldest first. Read from the index ledger (the same data `index rebuild`
135
+ // derives from each change's spec deltas); absent or unreadable index → none.
136
+ function archivedChangesFor(projectRoot, cap) {
137
+ let index;
138
+ try {
139
+ index = idx.load(projectRoot);
140
+ } catch {
141
+ return [];
142
+ }
143
+ const out = [];
144
+ for (const ch of index.artifacts?.changes_archive ?? []) {
145
+ const hit = (ch.specs_affected ?? []).find((s) => s.capability === cap);
146
+ if (hit) out.push({ applied: ch.applied, title: ch.title, op: hit.operation ?? "MODIFIED" });
147
+ }
148
+ return out.sort((a, b) => String(a.applied ?? "").localeCompare(String(b.applied ?? "")));
149
+ }
150
+
95
151
  // Map of anchor id -> the product.md bullet text that declares it.
96
152
  function productAnchors(projectRoot) {
97
153
  const out = new Map();
@@ -126,30 +182,6 @@ function sectionParagraph(text, name) {
126
182
  return buf.join(" ");
127
183
  }
128
184
 
129
- // Numbered acceptance criteria with their [mark] and any backtick proof paths.
130
- function acceptanceWithEvidence(text) {
131
- const lines = text.split(/\r?\n/);
132
- let inSection = false;
133
- const out = [];
134
- for (const line of lines) {
135
- if (/^##\s+/.test(line)) {
136
- inSection = /^##\s+Acceptance criteria\b/i.test(line);
137
- continue;
138
- }
139
- if (!inSection) continue;
140
- const m = line.match(/^\s*(\d+)\.\s+(.*)$/);
141
- if (!m) continue;
142
- const body = m[2];
143
- const markMatch = body.match(/^\[([^\]]+)\]/);
144
- const mark = markMatch
145
- ? (/^verified/i.test(markMatch[1]) ? c.green("✓") : c.yellow("○"))
146
- : c.gray("·");
147
- const proof = [...body.matchAll(/`([^`]+)`/g)].map((x) => x[1]).filter((s) => s.includes("/") || /\.[a-z0-9]{1,8}$/i.test(s));
148
- out.push({ n: m[1], mark, proof });
149
- }
150
- return out;
151
- }
152
-
153
185
  // Accepted ADRs whose body names the capability (word-ish boundary).
154
186
  function decisionsMentioning(projectRoot, cap) {
155
187
  const adrDir = path.join(projectRoot, ".doctrina", "decisions");
@@ -175,7 +207,8 @@ Usage: doctrina why <capability>
175
207
  Explain a capability's provenance by assembling the chain Doctrina already
176
208
  records into one read: the product intent it **Realizes:** ([SC1] anchors),
177
209
  the capability's purpose and status, the acceptance criteria that prove it
178
- (with cited evidence), and the accepted ADRs that name it. Read-only.
210
+ (with cited evidence), the accepted ADRs that name it, and the archived
211
+ changes that built it (from the index ledger). Read-only.
179
212
 
180
213
  Builds on the intent provenance (ADR 0006/0011) — answer "why was X built,
181
214
  and built this way?" without grepping the tree by hand.
package/src/index.js CHANGED
@@ -31,12 +31,13 @@ import * as close from "./commands/close.js";
31
31
  import * as review from "./commands/review.js";
32
32
  import * as watch from "./commands/watch.js";
33
33
  import * as why from "./commands/why.js";
34
+ import * as constitution from "./commands/constitution.js";
34
35
 
35
36
  const COMMANDS = {
36
37
  init, spec, change, decision, validate, hooks, analyze, clarify,
37
38
  templates, skill, index: indexCmd, next, metrics, context, search,
38
39
  intake, work, coverage, verify, contract, trace,
39
- status, close, review, watch, why,
40
+ status, close, review, watch, why, constitution,
40
41
  };
41
42
 
42
43
  const TOP_HELP = `
@@ -75,6 +76,7 @@ Commands:
75
76
  close Run the whole close sequence for a change in one pass
76
77
  status One-glance project health dashboard
77
78
  why Explain a capability's provenance (intent → proof → ADRs)
79
+ constitution Print the standing rules: accepted ADRs + product non-goals
78
80
  templates list List the templates shipped by the installed CLI
79
81
  templates check Compare the project against the recommended template shape
80
82
  hooks install Install the pre-commit hook
@@ -0,0 +1,46 @@
1
+ // Single canonical list of top-level `doctrina` commands.
2
+ //
3
+ // This is the one place the command surface is named. `src/index.js` builds
4
+ // its dispatch table for these names (an execution-based test asserts every
5
+ // name here resolves to a real handler, so the two never drift), and
6
+ // `doctrina validate` compares it against the commands AGENTS.md documents —
7
+ // the hub the agent reads first — so a stale command catalog or a reference
8
+ // to a command that no longer exists becomes a loud signal instead of silent
9
+ // rot (the "AGENTS.md is the source of truth but nothing keeps it fresh" gap).
10
+ //
11
+ // Keep this sorted by the workflow order used in `--help`, not alphabetically.
12
+ export const COMMAND_NAMES = [
13
+ // bootstrap / day-to-day
14
+ "init", "intake", "work",
15
+ // authoring
16
+ "spec", "change", "contract", "decision", "skill",
17
+ // read / orient
18
+ "context", "search", "status", "next", "why", "constitution",
19
+ // gates
20
+ "analyze", "clarify", "validate", "coverage", "trace", "review", "verify", "close",
21
+ // maintenance
22
+ "templates", "hooks", "index", "watch", "metrics",
23
+ ];
24
+
25
+ // Extract the set of top-level `doctrina <command>` references that appear in
26
+ // CODE context (fenced blocks or inline backtick spans) of a Markdown file —
27
+ // AGENTS.md, typically. Restricting to code context avoids matching prose like
28
+ // "the doctrina framework", which is not a command reference. Both the bare
29
+ // `doctrina <cmd>` and the `npx doctrina-cli <cmd>` forms are recognised.
30
+ export function referencedCommands(markdown) {
31
+ const out = new Set();
32
+ const chunks = [];
33
+ // Pull fenced blocks first and strip them out, so the inline-span scan below
34
+ // cannot mis-pair backticks across a fence boundary (a fence's own backticks
35
+ // would otherwise shift which inline spans pair up).
36
+ const rest = markdown.replace(/```[\s\S]*?```/g, (block) => {
37
+ chunks.push(block);
38
+ return "\n";
39
+ });
40
+ for (const m of rest.matchAll(/`[^`]+`/g)) chunks.push(m[0]);
41
+ const hay = chunks.join("\n");
42
+ for (const m of hay.matchAll(/(?:npx\s+doctrina-cli|doctrina)\s+([a-z][a-z-]+)/g)) {
43
+ out.add(m[1]);
44
+ }
45
+ return out;
46
+ }
@@ -0,0 +1,76 @@
1
+ // Shared parser for the "## Acceptance criteria" section of a spec.
2
+ //
3
+ // A criterion is a numbered item that MAY span several lines (continuation
4
+ // prose), and its evidence is cited as a backtick path span on ANY of those
5
+ // lines. A single-line parser silently misses proof sitting on a continuation
6
+ // line — the bug that made `doctrina why` print "no evidence cited" for a
7
+ // criterion `doctrina coverage` counted as covered. Both commands (and the
8
+ // validate honest-gate) read criteria through this one parser so they can
9
+ // never disagree about what a criterion cites.
10
+ //
11
+ // Returns one row per criterion: { n, marker, body, proofPaths } where marker
12
+ // is the lowercased `[tag]` (e.g. "verified", "unverified") or null, and
13
+ // proofPaths is the de-duplicated list of file-path-looking backtick tokens.
14
+
15
+ export function parseAcceptanceCriteria(text) {
16
+ const lines = text.split(/\r?\n/);
17
+ let inSection = false;
18
+ const rows = [];
19
+ let cur = null;
20
+ const flush = () => {
21
+ if (cur) {
22
+ cur.proofPaths = backtickPaths(cur.body);
23
+ rows.push(cur);
24
+ }
25
+ cur = null;
26
+ };
27
+ for (const line of lines) {
28
+ if (/^##\s+/.test(line)) {
29
+ if (inSection) {
30
+ flush();
31
+ inSection = false;
32
+ }
33
+ if (/^##\s+Acceptance criteria\b/i.test(line)) inSection = true;
34
+ continue;
35
+ }
36
+ if (!inSection) continue;
37
+ const m = line.match(/^\s*(\d+)\.\s+(.*)$/);
38
+ if (m) {
39
+ flush();
40
+ const body = m[2];
41
+ const markMatch = body.match(/^\[([^\]]+)\]/);
42
+ cur = { n: Number(m[1]), marker: markMatch ? markMatch[1].toLowerCase() : null, body };
43
+ } else if (cur) {
44
+ cur.body += (line.trim() === "" ? " " : " " + line.trim());
45
+ }
46
+ }
47
+ flush();
48
+ return rows.filter((r) => r.body.trim().length > 0);
49
+ }
50
+
51
+ // True when a criterion asserts it has been verified (the `[verified...]` tag).
52
+ export function isVerified(row) {
53
+ return row.marker !== null && /^verified/.test(row.marker);
54
+ }
55
+
56
+ function backtickPaths(text) {
57
+ const out = new Set();
58
+ for (const m of text.matchAll(/`([^`]+)`/g)) {
59
+ const token = m[1].trim();
60
+ if (looksLikePath(token)) out.add(token);
61
+ }
62
+ return [...out];
63
+ }
64
+
65
+ // Mirrors the coverage heuristic so the two commands agree: a token is a path
66
+ // if it has a slash or a file extension and is not a URL/placeholder.
67
+ function looksLikePath(s) {
68
+ if (!s) return false;
69
+ if (/^(https?:|mailto:|ftp:|#|@)/i.test(s)) return false;
70
+ if (/[\s<>*?{}]/.test(s)) return false;
71
+ const hasSlash = s.includes("/");
72
+ const hasExt = /\.[a-z0-9]{1,8}$/i.test(s);
73
+ if (!hasSlash && !hasExt) return false;
74
+ if (s.startsWith("-")) return false;
75
+ return true;
76
+ }
@@ -39,6 +39,7 @@ export function blank(projectName, date) {
39
39
  framework_version: cliVersion(),
40
40
  last_updated: date,
41
41
  artifacts: {
42
+ entrypoint: { path: "AGENTS.md" },
42
43
  product: {
43
44
  path: ".doctrina/product.md",
44
45
  status: "active",
package/src/lib/scan.js CHANGED
@@ -192,6 +192,13 @@ export function deriveIndex(projectRoot, current) {
192
192
  });
193
193
  }
194
194
 
195
+ // Entrypoint — the root AGENTS.md the agent reads first (the hub the
196
+ // AGENTS.md-drift gate in `validate` keeps honest). Registering it closes
197
+ // the machine-readable graph in both directions: a tool that enumerates
198
+ // index.json finds the hub itself, not only the artifacts the hub points at.
199
+ // Path-only, so the field never churns the index.
200
+ out.artifacts.entrypoint = { path: "AGENTS.md" };
201
+
195
202
  return out;
196
203
  }
197
204
 
@@ -35,8 +35,9 @@ so prefer it over writing artifacts by hand. Full list: `doctrina --help`.
35
35
 
36
36
  - **Read / orient (any task, first & last):** `doctrina status` (health) ·
37
37
  `doctrina context [<cap>] --concat` (read pack) · `doctrina next` (what's
38
- next) · `doctrina why <cap>` (provenance) · `doctrina spec list` ·
39
- `doctrina decision list` · `doctrina search <query>`
38
+ next) · `doctrina why <cap>` (provenance) · `doctrina constitution`
39
+ (standing rules) · `doctrina spec list` · `doctrina decision list` ·
40
+ `doctrina search <query>`
40
41
  - **Start:** `doctrina work "<prompt>"` · `--from-diff` (backfill from
41
42
  existing code) · `--chore` (no-spec infra/docs/build)
42
43
  - **Scaffold (manual creation stays available):** `doctrina spec new <cap>`
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Load the Doctrina read pack for a task, in read order
3
+ argument-hint: [capability]
4
+ ---
5
+
6
+ Assemble the context for **{{PROJECT_NAME}}** before doing any work.
7
+
8
+ Run `doctrina context $ARGUMENTS --concat` and read the output top to bottom:
9
+ AGENTS.md → product.md → the capability spec(s) → open changes → accepted ADRs.
10
+ With no capability it includes every active spec. It excludes the change
11
+ archive by design — run `doctrina why <capability>` when you need history.
12
+
13
+ Do this for ANY task (review, debug, a question), not only new work.
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Show and act on the next recommended Doctrina workflow step
3
+ ---
4
+
5
+ Decide the next step for **{{PROJECT_NAME}}** from the framework's own signals.
6
+
7
+ 1. Run `doctrina next` and `doctrina status` to see open work and gate health.
8
+ 2. Do the single highest-priority action it names (resume a change, apply a
9
+ delta, archive, accept an ADR, fix index drift, …).
10
+ 3. Re-run `doctrina next` to confirm the item cleared.
11
+
12
+ Use the read-only commands first; never guess what is pending — let the CLI
13
+ report it.
@@ -0,0 +1,12 @@
1
+ ---
2
+ description: One-glance Doctrina project health and gate status
3
+ ---
4
+
5
+ Report the health of **{{PROJECT_NAME}}**.
6
+
7
+ Run `doctrina status` for the dashboard (index drift, framework stamp,
8
+ coverage %, trace anchors, verify config, artifact counts). For the
9
+ authoritative gates run `doctrina validate` and `doctrina verify`, and
10
+ `doctrina next` for the recommended action.
11
+
12
+ Summarise what is green, what is not, and the single next step.
@@ -0,0 +1,12 @@
1
+ ---
2
+ description: Explain a capability's provenance (intent → proof → ADRs → history)
3
+ argument-hint: <capability>
4
+ ---
5
+
6
+ Explain why a capability of **{{PROJECT_NAME}}** exists and is shaped as it is.
7
+
8
+ Run `doctrina why $ARGUMENTS` and relay the provenance chain it prints: the
9
+ product intent it Realizes, its purpose and status, the acceptance criteria
10
+ that prove it (with cited evidence), the accepted ADRs that decided it, and
11
+ the archived changes that built it. Read-only — answer from the artifacts,
12
+ don't grep the tree by hand.
@@ -0,0 +1,17 @@
1
+ ---
2
+ description: Turn a request into a Doctrina change and drive it through the gates
3
+ argument-hint: <prompt describing the change>
4
+ ---
5
+
6
+ Drive the Doctrina spec-driven workflow for **{{PROJECT_NAME}}**.
7
+
8
+ 1. `doctrina work "$ARGUMENTS"` — scaffolds the change and prints the playbook.
9
+ (No prompt, but you already edited code? Use `doctrina work --from-diff`.)
10
+ 2. Execute the printed playbook in order: read the pack with
11
+ `doctrina context <capability> --concat`, write the spec delta, fill
12
+ `tasks.md`, implement, then `doctrina analyze <id>` → `doctrina change apply <id>`.
13
+ 3. Close it: `doctrina close <id>` (verify → coverage → archive → validate) and
14
+ resolve every `error:` before reporting the work done.
15
+
16
+ Reach for the CLI instead of hand-authoring artifacts. Ask the human only on
17
+ genuine ambiguity.
@@ -0,0 +1,10 @@
1
+ # /doctrina-context — read pack for {{PROJECT_NAME}}
2
+
3
+ Assemble the context before doing any work.
4
+
5
+ Run `doctrina context <capability> --concat` and read top to bottom:
6
+ AGENTS.md → product.md → the capability spec(s) → open changes → accepted ADRs.
7
+ With no capability it includes every active spec. The change archive is
8
+ excluded by design — run `doctrina why <capability>` when you need history.
9
+
10
+ Do this for ANY task (review, debug, a question), not only new work.
@@ -0,0 +1,10 @@
1
+ # /doctrina-next — next step for {{PROJECT_NAME}}
2
+
3
+ Decide the next step from the framework's own signals.
4
+
5
+ 1. Run `doctrina next` and `doctrina status` to see open work and gate health.
6
+ 2. Do the single highest-priority action it names (resume a change, apply a
7
+ delta, archive, accept an ADR, fix index drift, …).
8
+ 3. Re-run `doctrina next` to confirm the item cleared.
9
+
10
+ Use the read-only commands first; never guess what is pending.
@@ -0,0 +1,10 @@
1
+ # /doctrina-status — health of {{PROJECT_NAME}}
2
+
3
+ Report the project's health and gate status.
4
+
5
+ Run `doctrina status` for the dashboard (index drift, framework stamp,
6
+ coverage %, trace anchors, verify config, artifact counts). For the
7
+ authoritative gates run `doctrina validate` and `doctrina verify`, and
8
+ `doctrina next` for the recommended action.
9
+
10
+ Summarise what is green, what is not, and the single next step.
@@ -0,0 +1,9 @@
1
+ # /doctrina-why — provenance for {{PROJECT_NAME}}
2
+
3
+ Explain why a capability exists and is shaped as it is.
4
+
5
+ Run `doctrina why <capability>` and relay the provenance chain it prints: the
6
+ product intent it Realizes, its purpose and status, the acceptance criteria
7
+ that prove it (with cited evidence), the accepted ADRs that decided it, and
8
+ the archived changes that built it. Read-only — answer from the artifacts,
9
+ don't grep the tree by hand.
@@ -0,0 +1,12 @@
1
+ # /doctrina-work — drive a change for {{PROJECT_NAME}}
2
+
3
+ Turn the request into a Doctrina change and drive it through the gates.
4
+
5
+ 1. Run `doctrina work "<the request>"` (or `doctrina work --from-diff` if you
6
+ already edited code) and follow the printed playbook.
7
+ 2. `doctrina context <capability> --concat` → write the spec delta → fill
8
+ `tasks.md` → implement → `doctrina analyze <id>` → `doctrina change apply <id>`.
9
+ 3. `doctrina close <id>` (verify → coverage → archive → validate); resolve
10
+ every `error:` before reporting the work done.
11
+
12
+ Prefer the CLI over hand-authoring artifacts. Ask only on genuine ambiguity.
@@ -4,6 +4,9 @@
4
4
  "framework_version": "{{FRAMEWORK_VERSION}}",
5
5
  "last_updated": "{{DATE}}",
6
6
  "artifacts": {
7
+ "entrypoint": {
8
+ "path": "AGENTS.md"
9
+ },
7
10
  "product": {
8
11
  "path": ".doctrina/product.md",
9
12
  "status": "active",