doctrina-cli 0.2.1 → 0.3.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/change.js +62 -1
- package/src/commands/contract.js +254 -0
- package/src/commands/coverage.js +179 -0
- package/src/commands/intake.js +16 -7
- package/src/commands/skill.js +4 -1
- package/src/commands/spec.js +16 -5
- package/src/commands/templates.js +1 -1
- package/src/commands/validate.js +127 -3
- package/src/commands/verify.js +153 -0
- package/src/commands/work.js +21 -10
- package/src/index.js +9 -2
- package/src/lib/index-json.js +9 -0
- package/src/lib/scan.js +41 -7
- package/templates/AGENTS.md.template +28 -0
- package/templates/change/proposal.md.template +10 -1
- package/templates/contract.md.template +58 -0
- package/templates/decision.md.template +10 -0
- package/templates/doctrina/product.md.template +12 -0
- package/templates/spec.md.template +41 -3
package/src/commands/validate.js
CHANGED
|
@@ -6,6 +6,7 @@ import * as idx from "../lib/index-json.js";
|
|
|
6
6
|
import { c } from "../lib/colors.js";
|
|
7
7
|
import { parseFrontmatter } from "./skill.js";
|
|
8
8
|
import { checkEars, isEarsSpec } from "../lib/ears.js";
|
|
9
|
+
import { specHeader, listHeader } from "../lib/scan.js";
|
|
9
10
|
|
|
10
11
|
export async function run(_positional, _flags) {
|
|
11
12
|
const projectRoot = process.cwd();
|
|
@@ -57,6 +58,7 @@ export async function run(_positional, _flags) {
|
|
|
57
58
|
for (const d of index.artifacts.decisions ?? []) allArtifactPaths.push(d.path);
|
|
58
59
|
for (const ch of index.artifacts.changes ?? []) allArtifactPaths.push(ch.path);
|
|
59
60
|
for (const ch of index.artifacts.changes_archive ?? []) allArtifactPaths.push(ch.path);
|
|
61
|
+
for (const ct of index.artifacts.contracts ?? []) allArtifactPaths.push(ct.path);
|
|
60
62
|
for (const rel of allArtifactPaths) {
|
|
61
63
|
const full = path.join(projectRoot, rel);
|
|
62
64
|
if (!exists(full)) errors.push(`index.json references missing artifact: ${rel}`);
|
|
@@ -67,7 +69,7 @@ export async function run(_positional, _flags) {
|
|
|
67
69
|
for (const s of index.artifacts.specs ?? []) {
|
|
68
70
|
const full = path.join(projectRoot, s.path);
|
|
69
71
|
if (!isFile(full)) continue;
|
|
70
|
-
const m = read(full).match(
|
|
72
|
+
const m = read(full).match(/^(?:-\s+)?\*\*Version:\*\*\s+(\S+)/m);
|
|
71
73
|
if (m && s.version && m[1] !== s.version) {
|
|
72
74
|
warnings.push(`${s.path} declares version ${m[1]} but index.json records ${s.version} (sync the index)`);
|
|
73
75
|
}
|
|
@@ -80,9 +82,55 @@ export async function run(_positional, _flags) {
|
|
|
80
82
|
if (isDir(adrDir)) {
|
|
81
83
|
for (const f of walk(adrDir)) {
|
|
82
84
|
if (!f.endsWith(".md")) continue;
|
|
85
|
+
const base = path.basename(f);
|
|
86
|
+
// ADR filenames must be NNNN-slug.md (four digits). The entire ADR
|
|
87
|
+
// toolchain — decision new/accept/supersede, index derivation, and
|
|
88
|
+
// the orphan check below — keys off this shape; a file like
|
|
89
|
+
// "ADR-001-foo.md" is silently invisible to all of them.
|
|
90
|
+
if (base !== "README.md" && !/^\d{4}-.+\.md$/.test(base)) {
|
|
91
|
+
errors.push(
|
|
92
|
+
`${relPath(projectRoot, f)} has a non-canonical ADR filename ` +
|
|
93
|
+
`(expected four digits + slug, e.g. "0001-jwt-algorithm.md"; ` +
|
|
94
|
+
`the decision and index commands silently ignore other shapes)`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
83
97
|
const text = read(f);
|
|
84
98
|
if (!/^-\s+\*\*Status:\*\*\s+\S+/m.test(text)) {
|
|
85
|
-
errors.push(
|
|
99
|
+
errors.push(
|
|
100
|
+
`${relPath(projectRoot, f)} missing or malformed Status: header ` +
|
|
101
|
+
`(expected a Markdown list item exactly like "- **Status:** accepted")`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 5b. ADR evidence drift. A decision is only true if reality reflects
|
|
106
|
+
// it; an accepted ADR that points at nothing — or at a file that
|
|
107
|
+
// no longer exists — is the classic "ADR decrees X, the code
|
|
108
|
+
// never did X". Only ADRs that adopt the **Evidence:** header are
|
|
109
|
+
// checked, so legacy/process ADRs that opt out stay quiet.
|
|
110
|
+
const evidence = listHeader(text, "Evidence");
|
|
111
|
+
if (evidence !== null) {
|
|
112
|
+
const adrStatus = (listHeader(text, "Status") ?? "").toLowerCase();
|
|
113
|
+
const trimmed = evidence.trim();
|
|
114
|
+
const bare = trimmed === "—" || trimmed === "-" || trimmed === "";
|
|
115
|
+
if (bare) {
|
|
116
|
+
if (adrStatus === "accepted") {
|
|
117
|
+
warnings.push(
|
|
118
|
+
`${relPath(projectRoot, f)}: accepted ADR cites no implementation evidence ` +
|
|
119
|
+
`(link the file(s) that prove it, or note "n/a — <why>"); a decision with ` +
|
|
120
|
+
`nothing behind it may be drift waiting to be superseded`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
for (const token of extractBacktickPaths(evidence)) {
|
|
125
|
+
const candidates = [path.resolve(path.dirname(f), token), path.resolve(projectRoot, token)];
|
|
126
|
+
if (!candidates.some(exists)) {
|
|
127
|
+
warnings.push(
|
|
128
|
+
`${relPath(projectRoot, f)}: Evidence cites \`${token}\` which is missing on disk — ` +
|
|
129
|
+
`the decision may have drifted from the code (update the evidence or supersede the ADR)`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
86
134
|
}
|
|
87
135
|
}
|
|
88
136
|
}
|
|
@@ -105,7 +153,10 @@ export async function run(_positional, _flags) {
|
|
|
105
153
|
if (entry === "archive" || entry.startsWith(".")) continue;
|
|
106
154
|
const proposal = path.join(changesDir, entry, "proposal.md");
|
|
107
155
|
if (!isFile(proposal)) {
|
|
108
|
-
errors.push(
|
|
156
|
+
errors.push(
|
|
157
|
+
`open change "${entry}" missing proposal.md ` +
|
|
158
|
+
`(every dir under .doctrina/changes/ needs a proposal.md — that exact filename)`,
|
|
159
|
+
);
|
|
109
160
|
}
|
|
110
161
|
}
|
|
111
162
|
}
|
|
@@ -127,6 +178,30 @@ export async function run(_positional, _flags) {
|
|
|
127
178
|
warnings.push(`${relPath(projectRoot, specPath)}:${f.line} ${f.message}`);
|
|
128
179
|
}
|
|
129
180
|
}
|
|
181
|
+
|
|
182
|
+
// 8c. Capability-state honesty (two-axis status). An "active"
|
|
183
|
+
// document that records no built capability is an inventory
|
|
184
|
+
// claim — the gap the framework is meant to make visible.
|
|
185
|
+
// Warn when Status is active and Implementation is "planned"
|
|
186
|
+
// with no explanatory note after the state word. A note
|
|
187
|
+
// ("planned — deferred, see ADR 0007") is the deliberate-gap
|
|
188
|
+
// escape hatch. Specs with no Implementation header are off
|
|
189
|
+
// the axis and never warn.
|
|
190
|
+
const implRaw = specHeader(text, "Implementation");
|
|
191
|
+
if (implRaw) {
|
|
192
|
+
const docStatus = (specHeader(text, "Status") ?? "active").toLowerCase();
|
|
193
|
+
const implTokens = implRaw.trim().split(/\s+/);
|
|
194
|
+
const implWord = (implTokens[0] ?? "").replace(/[—-]+$/, "").toLowerCase();
|
|
195
|
+
const hasNote = implTokens.length > 1;
|
|
196
|
+
if (docStatus === "active" && implWord === "planned" && !hasNote) {
|
|
197
|
+
warnings.push(
|
|
198
|
+
`${relPath(projectRoot, specPath)}: Status is "active" but Implementation is ` +
|
|
199
|
+
`"planned" with no note — an active spec with no built capability is an ` +
|
|
200
|
+
`inventory claim (advance Implementation, set Status: draft, or add a note ` +
|
|
201
|
+
`after the value: "planned — <why deferred>")`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
130
205
|
}
|
|
131
206
|
}
|
|
132
207
|
}
|
|
@@ -184,6 +259,44 @@ export async function run(_positional, _flags) {
|
|
|
184
259
|
}
|
|
185
260
|
}
|
|
186
261
|
}
|
|
262
|
+
const indexedContractIds = new Set((index.artifacts.contracts ?? []).map((ct) => ct.id));
|
|
263
|
+
const contractsDir = path.join(projectRoot, ".doctrina", "contracts");
|
|
264
|
+
if (isDir(contractsDir)) {
|
|
265
|
+
for (const f of walk(contractsDir)) {
|
|
266
|
+
if (!f.endsWith(".md")) continue;
|
|
267
|
+
const id = path.basename(f, ".md");
|
|
268
|
+
if (!indexedContractIds.has(id)) {
|
|
269
|
+
warnings.push(`orphan contract ${relPath(projectRoot, f)} (id "${id}" not in index.json)`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 11. Archive ledger ↔ index cross-check. The history of archived
|
|
276
|
+
// changes is recorded twice — once human-facing in
|
|
277
|
+
// changes/archive/LEDGER.md, once machine-facing in
|
|
278
|
+
// index.json.changes_archive. Two sources of truth that silently
|
|
279
|
+
// disagree are worse than one: an incomplete ledger makes the
|
|
280
|
+
// history look shorter than it is. When the ledger exists, every
|
|
281
|
+
// archived change must appear in both, or validation fails.
|
|
282
|
+
const ledgerPath = path.join(changesDir, "archive", "LEDGER.md");
|
|
283
|
+
if (index?.artifacts && isFile(ledgerPath)) {
|
|
284
|
+
const ledgerIds = new Set();
|
|
285
|
+
for (const line of read(ledgerPath).split(/\r?\n/)) {
|
|
286
|
+
const m = line.match(/^-\s+\d{4}-\d{2}-\d{2}\s+[—-]\s+(\S+)\s+[—-]\s+/);
|
|
287
|
+
if (m) ledgerIds.add(m[1]);
|
|
288
|
+
}
|
|
289
|
+
const archiveIds = new Set((index.artifacts.changes_archive ?? []).map((ch) => ch.id));
|
|
290
|
+
for (const id of archiveIds) {
|
|
291
|
+
if (!ledgerIds.has(id)) {
|
|
292
|
+
errors.push(`archived change "${id}" is in index.json but missing from changes/archive/LEDGER.md (ledger and index disagree)`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const id of ledgerIds) {
|
|
296
|
+
if (!archiveIds.has(id)) {
|
|
297
|
+
errors.push(`changes/archive/LEDGER.md lists "${id}" but index.json.changes_archive does not (ledger and index disagree)`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
187
300
|
}
|
|
188
301
|
|
|
189
302
|
// 12-14. Skills validation
|
|
@@ -272,6 +385,17 @@ function extractPathReferences(text) {
|
|
|
272
385
|
return [...out];
|
|
273
386
|
}
|
|
274
387
|
|
|
388
|
+
// Path-like tokens inside backtick spans — the convention for citing
|
|
389
|
+
// evidence (ADR **Evidence:**, acceptance-criteria links).
|
|
390
|
+
function extractBacktickPaths(text) {
|
|
391
|
+
const out = new Set();
|
|
392
|
+
for (const m of text.matchAll(/`([^`]+)`/g)) {
|
|
393
|
+
const token = m[1].trim();
|
|
394
|
+
if (isLikelyPath(token)) out.add(stripFragment(token));
|
|
395
|
+
}
|
|
396
|
+
return [...out];
|
|
397
|
+
}
|
|
398
|
+
|
|
275
399
|
function stripFragment(s) {
|
|
276
400
|
// Remove an in-page anchor (#section) or query string from a path token.
|
|
277
401
|
return s.split(/[#?]/)[0];
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { exists, isFile, read, write, relPath } from "../lib/fs-ops.js";
|
|
5
|
+
import { flagBool } from "../lib/args.js";
|
|
6
|
+
import { c } from "../lib/colors.js";
|
|
7
|
+
|
|
8
|
+
// The build/verify gate `validate` is not. `validate` checks the shape of
|
|
9
|
+
// the artifact tree; it never runs the project, so a repo that does not
|
|
10
|
+
// compile still "passes". `verify` closes that hole: it executes the
|
|
11
|
+
// project-declared checks (typecheck / test / build) and fails when any
|
|
12
|
+
// of them fails. Doctrina is language-agnostic and ships zero runtime
|
|
13
|
+
// deps, so it cannot know your build — you declare it in
|
|
14
|
+
// `.doctrina/verify.json`, and `verify` runs exactly that, in order.
|
|
15
|
+
|
|
16
|
+
const CONFIG_REL = ".doctrina/verify.json";
|
|
17
|
+
|
|
18
|
+
const STARTER = {
|
|
19
|
+
$comment:
|
|
20
|
+
"doctrina verify runs each check below, in order, through your shell. " +
|
|
21
|
+
"Replace the example with your real typecheck/test/build commands. A " +
|
|
22
|
+
"non-zero exit fails the gate. This file is the project's executable " +
|
|
23
|
+
"definition of 'the code actually works'.",
|
|
24
|
+
checks: [
|
|
25
|
+
{ name: "example", run: "exit 1" },
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export async function run(_positional, flags) {
|
|
30
|
+
const projectRoot = process.cwd();
|
|
31
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
32
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const configPath = path.join(projectRoot, CONFIG_REL);
|
|
36
|
+
|
|
37
|
+
// --init scaffolds a starter config so the gate is one edit away.
|
|
38
|
+
if (flagBool(flags, "init", false)) {
|
|
39
|
+
const force = flagBool(flags, "force", false);
|
|
40
|
+
if (isFile(configPath) && !force) {
|
|
41
|
+
console.error(c.red("error:") + ` ${CONFIG_REL} already exists (pass --force to overwrite)`);
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
write(configPath, JSON.stringify(STARTER, null, 2) + "\n", { force: true });
|
|
45
|
+
console.log(c.green("created") + ` ${CONFIG_REL}`);
|
|
46
|
+
console.log(c.gray("Edit the `checks` array with your real commands, then run `doctrina verify`."));
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!isFile(configPath)) {
|
|
51
|
+
// A verify gate with nothing configured would pass vacuously — exactly
|
|
52
|
+
// the false confidence this command exists to prevent. Fail loudly.
|
|
53
|
+
console.error(c.red("error:") + ` no ${CONFIG_REL} — nothing to verify`);
|
|
54
|
+
console.error(c.gray("hint: ") + "scaffold one with `doctrina verify --init`, then declare your typecheck/test/build commands");
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let config;
|
|
59
|
+
try {
|
|
60
|
+
config = JSON.parse(read(configPath));
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error(c.red("error:") + ` ${CONFIG_REL} is not valid JSON: ${err.message}`);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
const checks = Array.isArray(config?.checks) ? config.checks : null;
|
|
66
|
+
if (!checks) {
|
|
67
|
+
console.error(c.red("error:") + ` ${CONFIG_REL} must contain a "checks" array of { name, run }`);
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
for (const ch of checks) {
|
|
71
|
+
if (!ch || typeof ch.run !== "string" || ch.run.trim() === "") {
|
|
72
|
+
console.error(c.red("error:") + ` every check in ${CONFIG_REL} needs a non-empty "run" string`);
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (checks.length === 0) {
|
|
77
|
+
console.error(c.red("error:") + ` ${CONFIG_REL} declares zero checks — a verify gate with no checks proves nothing`);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --list prints the configured checks without running them.
|
|
82
|
+
if (flagBool(flags, "list", false)) {
|
|
83
|
+
console.log(c.bold("Verify checks") + c.gray(` (from ${CONFIG_REL}):`));
|
|
84
|
+
console.log("");
|
|
85
|
+
checks.forEach((ch, i) => {
|
|
86
|
+
console.log(` ${i + 1}. ${c.cyan((ch.name ?? "check").padEnd(16))} ${c.gray(ch.run)}`);
|
|
87
|
+
});
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log(c.bold("doctrina verify") + c.gray(` — ${checks.length} check${checks.length === 1 ? "" : "s"} from ${CONFIG_REL}`));
|
|
92
|
+
const results = [];
|
|
93
|
+
for (const ch of checks) {
|
|
94
|
+
const name = ch.name ?? "check";
|
|
95
|
+
console.log("");
|
|
96
|
+
const where = ch.cwd ? c.gray(` [${ch.cwd}]`) : "";
|
|
97
|
+
console.log(c.gray(`──── ${name}: `) + ch.run + where);
|
|
98
|
+
// Run through the shell so declared commands can use pipes, &&, env,
|
|
99
|
+
// and platform builtins. stdio is inherited so the operator sees the
|
|
100
|
+
// real output — verify shows the truth, it does not summarise it away.
|
|
101
|
+
// An optional per-check `cwd` (relative to the project root) lets a
|
|
102
|
+
// monorepo target a sub-package, e.g. { run: "node --test", cwd:
|
|
103
|
+
// "packages/api" } — the multi-service shape the contract artifact
|
|
104
|
+
// also serves.
|
|
105
|
+
const res = spawnSync(ch.run, {
|
|
106
|
+
cwd: ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot,
|
|
107
|
+
shell: true,
|
|
108
|
+
stdio: "inherit",
|
|
109
|
+
});
|
|
110
|
+
const ok = !res.error && res.status === 0;
|
|
111
|
+
if (res.error) {
|
|
112
|
+
console.log(c.red(`✗ ${name}`) + c.gray(` — could not run: ${res.error.message}`));
|
|
113
|
+
} else {
|
|
114
|
+
console.log(ok ? c.green(`✓ ${name}`) : c.red(`✗ ${name} (exit ${res.status})`));
|
|
115
|
+
}
|
|
116
|
+
results.push({ name, ok });
|
|
117
|
+
// Fail fast is tempting, but running every check surfaces all breakage
|
|
118
|
+
// in one pass — more useful for an agent fixing the gap.
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const passed = results.filter((r) => r.ok).length;
|
|
122
|
+
console.log("");
|
|
123
|
+
if (passed === results.length) {
|
|
124
|
+
console.log(c.green("ok") + ` ${passed}/${results.length} checks passed`);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
const failed = results.filter((r) => !r.ok).map((r) => r.name).join(", ");
|
|
128
|
+
console.log(c.red("fail") + ` ${passed}/${results.length} checks passed — failed: ${failed}`);
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const help = `
|
|
133
|
+
Usage: doctrina verify [--list] [--init] [--force]
|
|
134
|
+
|
|
135
|
+
Run the project-declared build/verify checks from ${CONFIG_REL} in order,
|
|
136
|
+
streaming their output, and exit non-zero if any check fails. This is the
|
|
137
|
+
real "does the code work" gate, separate from the structural \`validate\`.
|
|
138
|
+
|
|
139
|
+
${CONFIG_REL} shape (an optional per-check "cwd", relative to the project
|
|
140
|
+
root, targets a sub-package in a monorepo):
|
|
141
|
+
{
|
|
142
|
+
"checks": [
|
|
143
|
+
{ "name": "typecheck", "run": "tsc --noEmit", "cwd": "packages/api" },
|
|
144
|
+
{ "name": "test", "run": "npm test" },
|
|
145
|
+
{ "name": "build", "run": "npm run build" }
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
Flags:
|
|
150
|
+
--init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force)
|
|
151
|
+
--list Print the configured checks without running them
|
|
152
|
+
--force With --init, overwrite an existing config
|
|
153
|
+
`;
|
package/src/commands/work.js
CHANGED
|
@@ -9,9 +9,10 @@ import { changeNew } from "./change.js";
|
|
|
9
9
|
// `work` is the second half of the no-ceremony path (ADR 0005): a brief
|
|
10
10
|
// prompt ("add login") becomes a fully scaffolded change plus a playbook
|
|
11
11
|
// the host agent executes — context, spec deltas, tasks, implementation,
|
|
12
|
-
// then the analyze → apply → archive → validate close
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// then the analyze → apply → verify → archive → validate close (verify =
|
|
13
|
+
// `doctrina verify` + `doctrina coverage`, since archive now refuses
|
|
14
|
+
// unproven verification). The CLI's own language processing stops at
|
|
15
|
+
// slugging and term counting; everything semantic is the agent's job.
|
|
15
16
|
|
|
16
17
|
export async function run(positional, flags) {
|
|
17
18
|
const prompt = positional.join(" ").trim();
|
|
@@ -183,18 +184,27 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability })
|
|
|
183
184
|
console.log(c.gray(" **Operation:** ADDED | MODIFIED | REMOVED"));
|
|
184
185
|
console.log(c.gray(" **Target spec on apply:** `.doctrina/specs/<capability>/spec.md`"));
|
|
185
186
|
console.log(c.gray(" ---"));
|
|
186
|
-
console.log(c.gray(" <EARS
|
|
187
|
+
console.log(c.gray(" <EARS body. Keep the two axes honest (Status vs Implementation),"));
|
|
188
|
+
console.log(c.gray(" keep aspiration under ## Maturity → Future, and cite the proof per"));
|
|
189
|
+
console.log(c.gray(" criterion: \"1. [unverified] <signal> — verified by `path/to/test`\">"));
|
|
187
190
|
console.log("");
|
|
188
191
|
console.log(`4. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
|
|
189
192
|
console.log(" with small, checkable implementation tasks (a few hours each, max).");
|
|
190
193
|
console.log("");
|
|
191
|
-
console.log("5. Implement task by task, checking each box as it lands.
|
|
192
|
-
console.log("
|
|
194
|
+
console.log("5. Implement task by task, checking each box as it lands. Advance the");
|
|
195
|
+
console.log(" spec's Implementation: planned → partial → implemented as code lands.");
|
|
196
|
+
console.log(" If the prompt is genuinely ambiguous, ask the user before assuming.");
|
|
193
197
|
console.log("");
|
|
194
198
|
console.log(`6. ${c.cyan(`doctrina analyze ${id}`)} — fix every ✗ before applying.`);
|
|
195
199
|
console.log(`7. ${c.cyan(`doctrina change apply ${id}`)} (MODIFIED deltas are merged by hand).`);
|
|
196
|
-
console.log(
|
|
197
|
-
console.log(`
|
|
200
|
+
console.log("8. Prove it before declaring done (archive refuses unchecked boxes):");
|
|
201
|
+
console.log(` ${c.cyan("doctrina verify")} — the project's typecheck/test/build gate`);
|
|
202
|
+
console.log(` ${c.cyan("doctrina coverage")} — each acceptance criterion cites a real test`);
|
|
203
|
+
console.log(" Then check the proposal's ## Verification boxes and every task,");
|
|
204
|
+
console.log(" closing steps included, and bump Implementation to verified.");
|
|
205
|
+
console.log("");
|
|
206
|
+
console.log(`9. ${c.cyan(`doctrina change archive ${id}`)}`);
|
|
207
|
+
console.log(`10. ${c.cyan("doctrina validate")}, then ${c.cyan("doctrina next")} for the follow-up.`);
|
|
198
208
|
}
|
|
199
209
|
|
|
200
210
|
export const help = `
|
|
@@ -205,8 +215,9 @@ host AI agent executes (see ADR 0005). The CLI derives a sequential
|
|
|
205
215
|
change id (NNNN-<slug>) from the prompt, opens the change folder,
|
|
206
216
|
records the prompt under the proposal's "## Why", ranks existing specs
|
|
207
217
|
by term overlap as a capability hint, and prints the ordered steps:
|
|
208
|
-
context → spec delta → tasks → implement → analyze → apply →
|
|
209
|
-
validate. No natural-language
|
|
218
|
+
context → spec delta → tasks → implement → analyze → apply → verify
|
|
219
|
+
(verify + coverage) → archive → validate. No natural-language
|
|
220
|
+
interpretation happens in the CLI.
|
|
210
221
|
|
|
211
222
|
Options:
|
|
212
223
|
--capability <cap> Pin the capability instead of ranking matches
|
package/src/index.js
CHANGED
|
@@ -24,11 +24,14 @@ import * as context from "./commands/context.js";
|
|
|
24
24
|
import * as search from "./commands/search.js";
|
|
25
25
|
import * as intake from "./commands/intake.js";
|
|
26
26
|
import * as work from "./commands/work.js";
|
|
27
|
+
import * as coverage from "./commands/coverage.js";
|
|
28
|
+
import * as verify from "./commands/verify.js";
|
|
29
|
+
import * as contract from "./commands/contract.js";
|
|
27
30
|
|
|
28
31
|
const COMMANDS = {
|
|
29
32
|
init, spec, change, decision, validate, hooks, analyze, clarify,
|
|
30
33
|
templates, skill, index: indexCmd, next, metrics, context, search,
|
|
31
|
-
intake, work,
|
|
34
|
+
intake, work, coverage, verify, contract,
|
|
32
35
|
};
|
|
33
36
|
|
|
34
37
|
const TOP_HELP = `
|
|
@@ -44,6 +47,8 @@ Commands:
|
|
|
44
47
|
change apply Apply spec deltas (ADDED/REMOVED auto, MODIFIED manual)
|
|
45
48
|
change archive Archive an applied change
|
|
46
49
|
change diff Preview spec deltas (line diff for MODIFIED)
|
|
50
|
+
contract new Own the integration surface (ports, env, interfaces)
|
|
51
|
+
contract check Verify port collisions, env drift, referenced specs
|
|
47
52
|
decision new Create the next sequentially numbered ADR
|
|
48
53
|
decision accept Flip a proposed ADR to accepted
|
|
49
54
|
decision supersede Create a new ADR that supersedes an existing one
|
|
@@ -56,6 +61,8 @@ Commands:
|
|
|
56
61
|
context Print the context pack for a task in read order
|
|
57
62
|
search Search the artifact tree, grouped by category
|
|
58
63
|
validate Run schema and structural checks
|
|
64
|
+
coverage Report acceptance criteria with linked evidence (--strict gates)
|
|
65
|
+
verify Run project-declared typecheck/test/build checks (the real gate)
|
|
59
66
|
templates list List the templates shipped by the installed CLI
|
|
60
67
|
templates check Compare the project against the recommended template shape
|
|
61
68
|
hooks install Install the pre-commit hook
|
|
@@ -70,7 +77,7 @@ Global flags:
|
|
|
70
77
|
|
|
71
78
|
async function main(argv) {
|
|
72
79
|
const { positional, flags } = parseArgs(argv, {
|
|
73
|
-
boolean: ["help", "h", "version", "v", "force", "non-interactive", "check", "save", "bug", "write", "all", "concat", "archive"],
|
|
80
|
+
boolean: ["help", "h", "version", "v", "force", "non-interactive", "check", "save", "bug", "write", "all", "concat", "archive", "strict", "list", "init"],
|
|
74
81
|
});
|
|
75
82
|
|
|
76
83
|
if (flags.get("version") || flags.get("v")) {
|
package/src/lib/index-json.js
CHANGED
|
@@ -44,6 +44,7 @@ export function blank(projectName, date) {
|
|
|
44
44
|
changes: [],
|
|
45
45
|
changes_archive: [],
|
|
46
46
|
skills: [],
|
|
47
|
+
contracts: [],
|
|
47
48
|
},
|
|
48
49
|
};
|
|
49
50
|
}
|
|
@@ -56,6 +57,14 @@ export function addSkill(index, entry) {
|
|
|
56
57
|
return index;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
export function addContract(index, entry) {
|
|
61
|
+
if (!index.artifacts.contracts) index.artifacts.contracts = [];
|
|
62
|
+
if (!index.artifacts.contracts.some((s) => s.id === entry.id)) {
|
|
63
|
+
index.artifacts.contracts.push(entry);
|
|
64
|
+
}
|
|
65
|
+
return index;
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
export function removeSkill(index, id) {
|
|
60
69
|
if (!index.artifacts.skills) return index;
|
|
61
70
|
index.artifacts.skills = index.artifacts.skills.filter((s) => s.id !== id);
|
package/src/lib/scan.js
CHANGED
|
@@ -5,15 +5,23 @@ import { today } from "./dates.js";
|
|
|
5
5
|
import { parseFrontmatter } from "../commands/skill.js";
|
|
6
6
|
import { parseOperation, parseCapabilityFromDelta } from "../commands/change.js";
|
|
7
7
|
|
|
8
|
-
// Parse a
|
|
8
|
+
// Parse a metadata header line, tolerating BOTH conventions:
|
|
9
|
+
// **Name:** value (spec style)
|
|
10
|
+
// - **Name:** value (ADR / proposal / intake style)
|
|
11
|
+
// The leading "- " is optional so a one-character convention slip (an
|
|
12
|
+
// agent hand-authoring an ADR with the bare spec form, or vice versa)
|
|
13
|
+
// still parses instead of silently falling back to a default value.
|
|
14
|
+
const HEADER_PREFIX = "(?:-\\s+)?";
|
|
15
|
+
|
|
16
|
+
// Parse a spec-style header line: **Name:** value (dash optional).
|
|
9
17
|
export function specHeader(text, name) {
|
|
10
|
-
const m = text.match(new RegExp(
|
|
18
|
+
const m = text.match(new RegExp(`^${HEADER_PREFIX}\\*\\*${name}:\\*\\*\\s+(.+)$`, "m"));
|
|
11
19
|
return m ? m[1].trim() : null;
|
|
12
20
|
}
|
|
13
21
|
|
|
14
|
-
// Parse an ADR/proposal-style header line: - **Name:** value
|
|
22
|
+
// Parse an ADR/proposal-style header line: - **Name:** value (dash optional).
|
|
15
23
|
export function listHeader(text, name) {
|
|
16
|
-
const m = text.match(new RegExp(
|
|
24
|
+
const m = text.match(new RegExp(`^${HEADER_PREFIX}\\*\\*${name}:\\*\\*\\s+(.+)$`, "m"));
|
|
17
25
|
return m ? m[1].trim() : null;
|
|
18
26
|
}
|
|
19
27
|
|
|
@@ -47,6 +55,7 @@ export function deriveIndex(projectRoot, current) {
|
|
|
47
55
|
changes: [],
|
|
48
56
|
changes_archive: [],
|
|
49
57
|
skills: [],
|
|
58
|
+
contracts: [],
|
|
50
59
|
},
|
|
51
60
|
};
|
|
52
61
|
|
|
@@ -57,13 +66,22 @@ export function deriveIndex(projectRoot, current) {
|
|
|
57
66
|
if (!isFile(p)) continue;
|
|
58
67
|
const text = read(p);
|
|
59
68
|
const prev = (cur.specs ?? []).find((s) => s.id === cap);
|
|
60
|
-
|
|
69
|
+
const entry = {
|
|
61
70
|
id: cap,
|
|
62
71
|
path: `.doctrina/specs/${cap}/spec.md`,
|
|
63
72
|
status: specHeader(text, "Status") ?? prev?.status ?? "active",
|
|
64
73
|
version: specHeader(text, "Version") ?? prev?.version ?? "0.1.0",
|
|
65
74
|
last_updated: specHeader(text, "Last updated") ?? prev?.last_updated ?? date,
|
|
66
|
-
}
|
|
75
|
+
};
|
|
76
|
+
// Implementation is the capability axis (planned -> partial ->
|
|
77
|
+
// implemented -> verified), independent of the document Status. Only
|
|
78
|
+
// record it when the spec declares it or the index already tracked it,
|
|
79
|
+
// so specs that never opted in (e.g. bug specs) stay off the axis.
|
|
80
|
+
// The stored value keeps any explanatory note after the state word.
|
|
81
|
+
const implRaw = specHeader(text, "Implementation");
|
|
82
|
+
const impl = implRaw ? implRaw.trim() : prev?.implementation;
|
|
83
|
+
if (impl) entry.implementation = impl;
|
|
84
|
+
out.artifacts.specs.push(entry);
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
// Decisions — NNNN-slug.md files; Status/Date headers win.
|
|
@@ -135,6 +153,22 @@ export function deriveIndex(projectRoot, current) {
|
|
|
135
153
|
});
|
|
136
154
|
}
|
|
137
155
|
|
|
156
|
+
// Contracts — the integration/runtime surface (ports, env, interfaces)
|
|
157
|
+
// that no single capability owns. Headers inside each contract win.
|
|
158
|
+
const contractsDir = path.join(dot, "contracts");
|
|
159
|
+
for (const f of walk(contractsDir)) {
|
|
160
|
+
if (!f.endsWith(".md")) continue;
|
|
161
|
+
const id = path.basename(f, ".md");
|
|
162
|
+
const text = read(f);
|
|
163
|
+
const prev = (cur.contracts ?? []).find((s) => s.id === id);
|
|
164
|
+
out.artifacts.contracts.push({
|
|
165
|
+
id,
|
|
166
|
+
path: `.doctrina/contracts/${id}.md`,
|
|
167
|
+
status: specHeader(text, "Status") ?? prev?.status ?? "active",
|
|
168
|
+
last_updated: specHeader(text, "Last updated") ?? prev?.last_updated ?? date,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
138
172
|
// Skills — frontmatter description wins.
|
|
139
173
|
const skillsDir = path.join(dot, "skills");
|
|
140
174
|
for (const f of walk(skillsDir)) {
|
|
@@ -175,7 +209,7 @@ export function indexesMatch(a, b) {
|
|
|
175
209
|
const clone = { ...x };
|
|
176
210
|
delete clone.last_updated;
|
|
177
211
|
const arts = { ...(clone.artifacts ?? {}) };
|
|
178
|
-
for (const cat of ["specs", "decisions", "changes", "changes_archive", "skills"]) {
|
|
212
|
+
for (const cat of ["specs", "decisions", "changes", "changes_archive", "skills", "contracts"]) {
|
|
179
213
|
arts[cat] = arts[cat] ?? [];
|
|
180
214
|
}
|
|
181
215
|
clone.artifacts = arts;
|
|
@@ -56,6 +56,34 @@ native multi-agent development. The full framework artifacts live under
|
|
|
56
56
|
- Archived changes in `.doctrina/changes/archive/` are out of the default
|
|
57
57
|
read path; consult only when explicitly debugging history.
|
|
58
58
|
|
|
59
|
+
## Artifact invariants (verbatim — `doctrina validate` enforces these)
|
|
60
|
+
|
|
61
|
+
Scaffold artifacts with the CLI (`doctrina decision new`, `doctrina work`),
|
|
62
|
+
which writes from the canonical templates. Do NOT hand-author these files
|
|
63
|
+
from memory. If you must write one by hand, match these exactly:
|
|
64
|
+
|
|
65
|
+
- **Metadata headers come in two forms — do not mix them up:**
|
|
66
|
+
- ADRs, change proposals, and the intake use **list items**:
|
|
67
|
+
`- **Status:** accepted`, `- **Date:** 2026-06-13`.
|
|
68
|
+
- Specs use **bare bold** (no leading `- `):
|
|
69
|
+
`**Status:** active`, `**Version:** 0.1.0`.
|
|
70
|
+
Match the template for the artifact you are writing.
|
|
71
|
+
- **ADR filename** must be `NNNN-slug.md` — four digits, e.g.
|
|
72
|
+
`0001-jwt-algorithm.md`. `ADR-001-...md` or `1-...md` is invisible to
|
|
73
|
+
`doctrina decision accept`, the index, and the orphan check.
|
|
74
|
+
- **Change folder** at `.doctrina/changes/<id>/` must contain a file named
|
|
75
|
+
exactly `proposal.md` (not `change.md`, not `README.md`). `tasks.md` and
|
|
76
|
+
`design.md` are optional siblings.
|
|
77
|
+
- **Spec** lives at `.doctrina/specs/<capability>/spec.md` and its
|
|
78
|
+
`**Version:**` header must equal the `version` recorded for it in
|
|
79
|
+
`.doctrina/index.json`.
|
|
80
|
+
- **Every artifact on disk** must be registered in `.doctrina/index.json`,
|
|
81
|
+
and every path in `index.json` must exist on disk. Update the index in
|
|
82
|
+
the same change that adds or moves a file.
|
|
83
|
+
|
|
84
|
+
Always finish by running `doctrina validate` and resolving every `error:`
|
|
85
|
+
before considering work done.
|
|
86
|
+
|
|
59
87
|
## How to read context efficiently
|
|
60
88
|
|
|
61
89
|
1. Read this `AGENTS.md`.
|
|
@@ -19,7 +19,16 @@
|
|
|
19
19
|
|
|
20
20
|
## Verification
|
|
21
21
|
|
|
22
|
-
<!--
|
|
22
|
+
<!--
|
|
23
|
+
How you will know the change is correctly applied. Use checkboxes: every
|
|
24
|
+
box here is a claim that must be PROVEN before the change is done.
|
|
25
|
+
`doctrina change archive` refuses to archive while any box below is
|
|
26
|
+
unchecked (pass --force to archive anyway and record the gap). Distinguish
|
|
27
|
+
"task marked done" from "verification passed" — link the evidence.
|
|
28
|
+
-->
|
|
29
|
+
|
|
30
|
+
- [ ] Automated checks pass (`doctrina verify`, or the project's typecheck/test/build).
|
|
31
|
+
- [ ] The affected spec's acceptance criteria are met and cite their evidence (`doctrina coverage`).
|
|
23
32
|
|
|
24
33
|
## Open questions
|
|
25
34
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Contract — {{CONTRACT_NAME}}
|
|
2
|
+
|
|
3
|
+
**Contract:** {{CONTRACT_NAME}}
|
|
4
|
+
**Status:** active
|
|
5
|
+
**Last updated:** {{DATE}}
|
|
6
|
+
|
|
7
|
+
## Purpose
|
|
8
|
+
|
|
9
|
+
<!--
|
|
10
|
+
The integration/runtime surface this contract OWNS. No single capability
|
|
11
|
+
spec owns the seams between services and front-ends — this artifact does.
|
|
12
|
+
If a login response shape, a WebSocket path, or a Kafka topic falls in the
|
|
13
|
+
gap between two specs, it belongs here.
|
|
14
|
+
-->
|
|
15
|
+
|
|
16
|
+
## Ports
|
|
17
|
+
|
|
18
|
+
<!--
|
|
19
|
+
The single source of truth for who listens where. `doctrina contract check`
|
|
20
|
+
fails when two rows claim the same port (the "front points at the wrong
|
|
21
|
+
port" class of bug). Front-ends and clients read their targets from here.
|
|
22
|
+
-->
|
|
23
|
+
|
|
24
|
+
| Service | Port | Protocol |
|
|
25
|
+
|---------|------|----------|
|
|
26
|
+
| gateway | 8080 | http |
|
|
27
|
+
|
|
28
|
+
## Environment
|
|
29
|
+
|
|
30
|
+
<!--
|
|
31
|
+
Variables the running system requires, named IDENTICALLY across code,
|
|
32
|
+
`.env.example`, and infra (compose/helm). `doctrina contract check` warns
|
|
33
|
+
when a variable here is missing from `.env.example` — the DATABASE_URL /
|
|
34
|
+
KAFKA_BROKER drift class.
|
|
35
|
+
-->
|
|
36
|
+
|
|
37
|
+
| Variable | Required | Example |
|
|
38
|
+
|--------------|----------|-------------------------------------------|
|
|
39
|
+
| DATABASE_URL | yes | postgres://user:pass@localhost:5432/app |
|
|
40
|
+
|
|
41
|
+
## Interfaces
|
|
42
|
+
|
|
43
|
+
<!--
|
|
44
|
+
API / WebSocket / event-stream contracts: endpoints, message shapes, event
|
|
45
|
+
schemas, and the exact response shape each consumer can rely on (e.g. login
|
|
46
|
+
returns `{ token, user }`, not a bare `TokenPair`).
|
|
47
|
+
-->
|
|
48
|
+
|
|
49
|
+
-
|
|
50
|
+
|
|
51
|
+
## References
|
|
52
|
+
|
|
53
|
+
<!--
|
|
54
|
+
The capability specs that must conform to this contract. List each as
|
|
55
|
+
`specs/<capability>`; `doctrina contract check` verifies each spec exists.
|
|
56
|
+
-->
|
|
57
|
+
|
|
58
|
+
- `specs/<capability>`
|