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.
@@ -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(/^\*\*Version:\*\*\s+(\S+)/m);
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(`${relPath(projectRoot, f)} missing or malformed Status: header`);
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(`open change "${entry}" missing proposal.md`);
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
+ `;
@@ -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. The CLI's own
13
- // language processing stops at slugging and term counting; everything
14
- // semantic is the agent's job.
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 requirements: full spec body for ADDED, fragment for MODIFIED>"));
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. If the");
192
- console.log(" prompt is genuinely ambiguous, ask the user before assuming.");
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(`8. ${c.cyan(`doctrina change archive ${id}`)}`);
197
- console.log(`9. ${c.cyan("doctrina validate")}, then ${c.cyan("doctrina next")} for the follow-up.`);
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 → archive →
209
- validate. No natural-language interpretation happens in the CLI.
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")) {
@@ -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 spec-style header line: **Name:** value
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(`^\\*\\*${name}:\\*\\*\\s+(.+)$`, "m"));
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(`^-\\s+\\*\\*${name}:\\*\\*\\s+(.+)$`, "m"));
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
- out.artifacts.specs.push({
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
- <!-- How you will know the change is correctly applied. -->
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>`