doctrina-cli 0.9.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 +1 -1
- package/package.json +1 -1
- package/src/commands/constitution.js +136 -0
- package/src/commands/context.js +16 -4
- package/src/commands/validate.js +64 -0
- package/src/commands/why.js +62 -29
- package/src/index.js +3 -1
- package/src/lib/commands.js +46 -0
- package/src/lib/criteria.js +76 -0
- package/src/lib/index-json.js +1 -0
- package/src/lib/scan.js +7 -0
- package/templates/AGENTS.md.template +3 -2
- package/templates/adapters/claude/.claude/commands/doctrina-context.md.template +13 -0
- package/templates/adapters/claude/.claude/commands/doctrina-next.md.template +13 -0
- package/templates/adapters/claude/.claude/commands/doctrina-status.md.template +12 -0
- package/templates/adapters/claude/.claude/commands/doctrina-why.md.template +12 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +17 -0
- package/templates/adapters/cursor/.cursor/commands/doctrina-context.md.template +10 -0
- package/templates/adapters/cursor/.cursor/commands/doctrina-next.md.template +10 -0
- package/templates/adapters/cursor/.cursor/commands/doctrina-status.md.template +10 -0
- package/templates/adapters/cursor/.cursor/commands/doctrina-why.md.template +9 -0
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +12 -0
- package/templates/doctrina/index.json.template +3 -0
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.10.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,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
|
+
`;
|
package/src/commands/context.js
CHANGED
|
@@ -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
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
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
|
package/src/commands/validate.js
CHANGED
|
@@ -11,6 +11,13 @@ import { c } from "../lib/colors.js";
|
|
|
11
11
|
import { parseFrontmatter } from "./skill.js";
|
|
12
12
|
import { checkEars, isEarsSpec } from "../lib/ears.js";
|
|
13
13
|
import { specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
|
|
14
|
+
import { COMMAND_NAMES, referencedCommands } from "../lib/commands.js";
|
|
15
|
+
import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
|
|
16
|
+
|
|
17
|
+
// AGENTS.md is treated as a maintained doctrina-command catalog only once it
|
|
18
|
+
// documents at least this many real commands; below it, the file defers to
|
|
19
|
+
// `doctrina --help` and is never nagged about omissions.
|
|
20
|
+
const CATALOG_THRESHOLD = 8;
|
|
14
21
|
|
|
15
22
|
export async function run(_positional, flags) {
|
|
16
23
|
const projectRoot = process.cwd();
|
|
@@ -25,6 +32,46 @@ export async function run(_positional, flags) {
|
|
|
25
32
|
const lines = lineCount(agentsMd);
|
|
26
33
|
if (lines > 200) errors.push(`AGENTS.md is ${lines} lines (>200, hard limit)`);
|
|
27
34
|
else if (lines > 150) warnings.push(`AGENTS.md is ${lines} lines (>150 soft limit)`);
|
|
35
|
+
|
|
36
|
+
// 1c. AGENTS.md command-surface drift. AGENTS.md is the hub the agent reads
|
|
37
|
+
// first, so the doctrina commands it documents must match the real CLI.
|
|
38
|
+
// Two drifts are flagged: a reference to a command that does not exist
|
|
39
|
+
// (a typo or a removed command — the agent will try to run it and fail),
|
|
40
|
+
// and a maintained catalog that has fallen behind the CLI (commands the
|
|
41
|
+
// agent never discovers because the hub never names them). validate only
|
|
42
|
+
// checked AGENTS.md *size* before, so the hub could rot while staying
|
|
43
|
+
// green; this closes that gap.
|
|
44
|
+
const agentsText = read(agentsMd);
|
|
45
|
+
const referenced = referencedCommands(agentsText);
|
|
46
|
+
const known = new Set(COMMAND_NAMES);
|
|
47
|
+
// Reverse drift is always flagged — a dangling command reference misleads
|
|
48
|
+
// the agent regardless of how the file is structured.
|
|
49
|
+
for (const cmd of [...referenced].sort()) {
|
|
50
|
+
if (!known.has(cmd)) {
|
|
51
|
+
warnings.push(
|
|
52
|
+
`AGENTS.md references \`doctrina ${cmd}\` which is not a CLI command ` +
|
|
53
|
+
`(typo or removed command — agents reading the hub will try to run it)`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Forward drift (omitted commands) is flagged only when the file presents
|
|
58
|
+
// itself as an exhaustive catalog: it documents many commands AND does not
|
|
59
|
+
// defer to `doctrina --help`. A file that defers is declaring its list
|
|
60
|
+
// illustrative (the shipped template does this), so its omissions are
|
|
61
|
+
// intentional and stay silent.
|
|
62
|
+
const documented = [...referenced].filter((cmd) => known.has(cmd));
|
|
63
|
+
const defersToHelp = /doctrina(?:-cli)?\s+--help/.test(agentsText);
|
|
64
|
+
if (documented.length >= CATALOG_THRESHOLD && !defersToHelp) {
|
|
65
|
+
const missing = COMMAND_NAMES.filter((cmd) => !referenced.has(cmd));
|
|
66
|
+
if (missing.length > 0) {
|
|
67
|
+
warnings.push(
|
|
68
|
+
`AGENTS.md documents the doctrina command surface but omits ` +
|
|
69
|
+
`${missing.length} command${missing.length === 1 ? "" : "s"} ` +
|
|
70
|
+
`(${missing.join(", ")}) — agents reading the hub will not discover ` +
|
|
71
|
+
`them (add them, or defer to \`doctrina --help\` for the full list)`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
28
75
|
}
|
|
29
76
|
|
|
30
77
|
// 1b. Nested AGENTS.md files ("nearest wins" hierarchy) obey the same
|
|
@@ -306,6 +353,23 @@ export async function run(_positional, flags) {
|
|
|
306
353
|
}
|
|
307
354
|
}
|
|
308
355
|
}
|
|
356
|
+
|
|
357
|
+
// 8f. Self-certified acceptance criteria (honest gates, ADR 0008). A
|
|
358
|
+
// criterion that asserts [verified] but cites no proof path is a
|
|
359
|
+
// claim with nothing behind it — the dishonest-green case. `coverage`
|
|
360
|
+
// reports every bare criterion; this names the specific combo
|
|
361
|
+
// (claims verified, proves nothing) in the always-run gate. It reads
|
|
362
|
+
// through the shared multi-line parser, so a proof cited on a
|
|
363
|
+
// continuation line still counts (no false positive).
|
|
364
|
+
for (const crit of parseAcceptanceCriteria(text)) {
|
|
365
|
+
if (isVerified(crit) && crit.proofPaths.length === 0) {
|
|
366
|
+
warnings.push(
|
|
367
|
+
`${relPath(projectRoot, specPath)}: acceptance criterion #${crit.n} is marked ` +
|
|
368
|
+
`[verified] but cites no proof path — self-certified (cite the file/test in ` +
|
|
369
|
+
`backticks, or drop the marker; \`doctrina coverage\`)`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
309
373
|
}
|
|
310
374
|
}
|
|
311
375
|
}
|
package/src/commands/why.js
CHANGED
|
@@ -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 =
|
|
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
|
|
78
|
-
|
|
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),
|
|
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
|
+
}
|
package/src/lib/index-json.js
CHANGED
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
|
|
39
|
-
`doctrina
|
|
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.
|