doctrina-cli 0.2.2 → 0.4.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.
@@ -3,9 +3,12 @@ import process from "node:process";
3
3
  import { readdirSync } from "node:fs";
4
4
  import { exists, isDir, isFile, lineCount, read, relPath, walk } from "../lib/fs-ops.js";
5
5
  import * as idx from "../lib/index-json.js";
6
+ import { SCHEMA_VERSION } from "../lib/index-json.js";
7
+ import { cliVersion } from "../lib/version.js";
6
8
  import { c } from "../lib/colors.js";
7
9
  import { parseFrontmatter } from "./skill.js";
8
10
  import { checkEars, isEarsSpec } from "../lib/ears.js";
11
+ import { specHeader, listHeader } from "../lib/scan.js";
9
12
 
10
13
  export async function run(_positional, _flags) {
11
14
  const projectRoot = process.cwd();
@@ -44,8 +47,20 @@ export async function run(_positional, _flags) {
44
47
  }
45
48
 
46
49
  if (index) {
47
- if (index.$schema_version !== "0.1.0") {
48
- warnings.push(`index.json $schema_version is "${index.$schema_version}" (expected "0.1.0")`);
50
+ if (index.$schema_version !== SCHEMA_VERSION) {
51
+ warnings.push(`index.json $schema_version is "${index.$schema_version}" (expected "${SCHEMA_VERSION}")`);
52
+ }
53
+ // framework_version stamp divergence (3.6). The index records which CLI
54
+ // manages it; a stamp behind (or absent vs.) the running CLI means the
55
+ // tree may predate the current schema. `index rebuild` migrates it.
56
+ const runningVersion = cliVersion();
57
+ if (!index.framework_version) {
58
+ warnings.push(`index.json has no framework_version (run \`doctrina index rebuild\` to stamp it)`);
59
+ } else if (index.framework_version !== runningVersion) {
60
+ warnings.push(
61
+ `index.json framework_version is "${index.framework_version}" but the running CLI is ` +
62
+ `${runningVersion} (run \`doctrina index rebuild\` to stamp/migrate)`,
63
+ );
49
64
  }
50
65
  if (!index.artifacts) {
51
66
  errors.push("index.json missing artifacts object");
@@ -57,6 +72,7 @@ export async function run(_positional, _flags) {
57
72
  for (const d of index.artifacts.decisions ?? []) allArtifactPaths.push(d.path);
58
73
  for (const ch of index.artifacts.changes ?? []) allArtifactPaths.push(ch.path);
59
74
  for (const ch of index.artifacts.changes_archive ?? []) allArtifactPaths.push(ch.path);
75
+ for (const ct of index.artifacts.contracts ?? []) allArtifactPaths.push(ct.path);
60
76
  for (const rel of allArtifactPaths) {
61
77
  const full = path.join(projectRoot, rel);
62
78
  if (!exists(full)) errors.push(`index.json references missing artifact: ${rel}`);
@@ -77,10 +93,20 @@ export async function run(_positional, _flags) {
77
93
 
78
94
  // 5. ADRs have parseable Status: header
79
95
  const adrDir = path.join(projectRoot, ".doctrina", "decisions");
96
+ // Track which files claim each NNNN so a merge-time collision (two branches
97
+ // both allocating the same number) becomes a loud error rather than one ADR
98
+ // silently shadowing the other (C2).
99
+ const adrByNumber = new Map();
80
100
  if (isDir(adrDir)) {
81
101
  for (const f of walk(adrDir)) {
82
102
  if (!f.endsWith(".md")) continue;
83
103
  const base = path.basename(f);
104
+ const numMatch = base.match(/^(\d{4})-/);
105
+ const num = numMatch ? numMatch[1] : null;
106
+ if (num) {
107
+ if (!adrByNumber.has(num)) adrByNumber.set(num, []);
108
+ adrByNumber.get(num).push(relPath(projectRoot, f));
109
+ }
84
110
  // ADR filenames must be NNNN-slug.md (four digits). The entire ADR
85
111
  // toolchain — decision new/accept/supersede, index derivation, and
86
112
  // the orphan check below — keys off this shape; a file like
@@ -99,6 +125,54 @@ export async function run(_positional, _flags) {
99
125
  `(expected a Markdown list item exactly like "- **Status:** accepted")`,
100
126
  );
101
127
  }
128
+
129
+ // 5b. ADR evidence drift. A decision is only true if reality reflects
130
+ // it; an accepted ADR that points at nothing — or at a file that
131
+ // no longer exists — is the classic "ADR decrees X, the code
132
+ // never did X". Two header fields anchor a decision to reality:
133
+ // **Evidence:** (cited at any point) and **Landed:** (the
134
+ // non-mutating "this is now implemented and verified here" stamp,
135
+ // written by `doctrina decision land`; 3.4). Both are checked for
136
+ // dangling citations; an accepted ADR is only flagged bare when
137
+ // BOTH are empty. ADRs that adopt neither header opt out entirely.
138
+ const evidence = listHeader(text, "Evidence");
139
+ const landed = listHeader(text, "Landed");
140
+ const isBare = (v) => {
141
+ const t = (v ?? "").trim();
142
+ return t === "" || t === "—" || t === "-";
143
+ };
144
+ for (const [field, value] of [["Evidence", evidence], ["Landed", landed]]) {
145
+ if (value === null || isBare(value)) continue;
146
+ for (const token of extractBacktickPaths(value)) {
147
+ const candidates = [path.resolve(path.dirname(f), token), path.resolve(projectRoot, token)];
148
+ if (!candidates.some(exists)) {
149
+ warnings.push(
150
+ `${relPath(projectRoot, f)}: ${field} cites \`${token}\` which is missing on disk — ` +
151
+ `the decision may have drifted from the code (update it or supersede the ADR)`,
152
+ );
153
+ }
154
+ }
155
+ }
156
+ const adrStatus = (listHeader(text, "Status") ?? "").toLowerCase();
157
+ if (adrStatus === "accepted" && evidence !== null && isBare(evidence) && isBare(landed)) {
158
+ warnings.push(
159
+ `${relPath(projectRoot, f)}: accepted ADR cites no implementation evidence ` +
160
+ `(link the file(s) that prove it, run \`doctrina decision land ${num ?? "NNNN"}\` once ` +
161
+ `it ships, or note "n/a — <why>"); a decision with nothing behind it may be drift ` +
162
+ `waiting to be superseded`,
163
+ );
164
+ }
165
+ }
166
+
167
+ // 5c. Duplicate ADR numbers (C2).
168
+ for (const [num, files] of adrByNumber) {
169
+ if (files.length > 1) {
170
+ errors.push(
171
+ `ADR number ${num} is claimed by ${files.length} files (${files.join(", ")}) — ` +
172
+ `two branches likely allocated it concurrently; renumber all but one ` +
173
+ `(the index keys decisions by number, so duplicates silently shadow each other)`,
174
+ );
175
+ }
102
176
  }
103
177
  }
104
178
 
@@ -145,6 +219,30 @@ export async function run(_positional, _flags) {
145
219
  warnings.push(`${relPath(projectRoot, specPath)}:${f.line} ${f.message}`);
146
220
  }
147
221
  }
222
+
223
+ // 8c. Capability-state honesty (two-axis status). An "active"
224
+ // document that records no built capability is an inventory
225
+ // claim — the gap the framework is meant to make visible.
226
+ // Warn when Status is active and Implementation is "planned"
227
+ // with no explanatory note after the state word. A note
228
+ // ("planned — deferred, see ADR 0007") is the deliberate-gap
229
+ // escape hatch. Specs with no Implementation header are off
230
+ // the axis and never warn.
231
+ const implRaw = specHeader(text, "Implementation");
232
+ if (implRaw) {
233
+ const docStatus = (specHeader(text, "Status") ?? "active").toLowerCase();
234
+ const implTokens = implRaw.trim().split(/\s+/);
235
+ const implWord = (implTokens[0] ?? "").replace(/[—-]+$/, "").toLowerCase();
236
+ const hasNote = implTokens.length > 1;
237
+ if (docStatus === "active" && implWord === "planned" && !hasNote) {
238
+ warnings.push(
239
+ `${relPath(projectRoot, specPath)}: Status is "active" but Implementation is ` +
240
+ `"planned" with no note — an active spec with no built capability is an ` +
241
+ `inventory claim (advance Implementation, set Status: draft, or add a note ` +
242
+ `after the value: "planned — <why deferred>")`,
243
+ );
244
+ }
245
+ }
148
246
  }
149
247
  }
150
248
  }
@@ -202,6 +300,44 @@ export async function run(_positional, _flags) {
202
300
  }
203
301
  }
204
302
  }
303
+ const indexedContractIds = new Set((index.artifacts.contracts ?? []).map((ct) => ct.id));
304
+ const contractsDir = path.join(projectRoot, ".doctrina", "contracts");
305
+ if (isDir(contractsDir)) {
306
+ for (const f of walk(contractsDir)) {
307
+ if (!f.endsWith(".md")) continue;
308
+ const id = path.basename(f, ".md");
309
+ if (!indexedContractIds.has(id)) {
310
+ warnings.push(`orphan contract ${relPath(projectRoot, f)} (id "${id}" not in index.json)`);
311
+ }
312
+ }
313
+ }
314
+ }
315
+
316
+ // 11. Archive ledger ↔ index cross-check. The history of archived
317
+ // changes is recorded twice — once human-facing in
318
+ // changes/archive/LEDGER.md, once machine-facing in
319
+ // index.json.changes_archive. Two sources of truth that silently
320
+ // disagree are worse than one: an incomplete ledger makes the
321
+ // history look shorter than it is. When the ledger exists, every
322
+ // archived change must appear in both, or validation fails.
323
+ const ledgerPath = path.join(changesDir, "archive", "LEDGER.md");
324
+ if (index?.artifacts && isFile(ledgerPath)) {
325
+ const ledgerIds = new Set();
326
+ for (const line of read(ledgerPath).split(/\r?\n/)) {
327
+ const m = line.match(/^-\s+\d{4}-\d{2}-\d{2}\s+[—-]\s+(\S+)\s+[—-]\s+/);
328
+ if (m) ledgerIds.add(m[1]);
329
+ }
330
+ const archiveIds = new Set((index.artifacts.changes_archive ?? []).map((ch) => ch.id));
331
+ for (const id of archiveIds) {
332
+ if (!ledgerIds.has(id)) {
333
+ errors.push(`archived change "${id}" is in index.json but missing from changes/archive/LEDGER.md (ledger and index disagree)`);
334
+ }
335
+ }
336
+ for (const id of ledgerIds) {
337
+ if (!archiveIds.has(id)) {
338
+ errors.push(`changes/archive/LEDGER.md lists "${id}" but index.json.changes_archive does not (ledger and index disagree)`);
339
+ }
340
+ }
205
341
  }
206
342
 
207
343
  // 12-14. Skills validation
@@ -290,6 +426,17 @@ function extractPathReferences(text) {
290
426
  return [...out];
291
427
  }
292
428
 
429
+ // Path-like tokens inside backtick spans — the convention for citing
430
+ // evidence (ADR **Evidence:**, acceptance-criteria links).
431
+ function extractBacktickPaths(text) {
432
+ const out = new Set();
433
+ for (const m of text.matchAll(/`([^`]+)`/g)) {
434
+ const token = m[1].trim();
435
+ if (isLikelyPath(token)) out.add(stripFragment(token));
436
+ }
437
+ return [...out];
438
+ }
439
+
293
440
  function stripFragment(s) {
294
441
  // Remove an in-page anchor (#section) or query string from a path token.
295
442
  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
+ `;
@@ -4,14 +4,16 @@ import { readdirSync } from "node:fs";
4
4
  import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
5
5
  import { flagString } from "../lib/args.js";
6
6
  import { c } from "../lib/colors.js";
7
+ import { assessBrief } from "../lib/clarity.js";
7
8
  import { changeNew } from "./change.js";
8
9
 
9
10
  // `work` is the second half of the no-ceremony path (ADR 0005): a brief
10
11
  // prompt ("add login") becomes a fully scaffolded change plus a playbook
11
12
  // 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.
13
+ // then the analyze → apply → verify → archive → validate close (verify =
14
+ // `doctrina verify` + `doctrina coverage`, since archive now refuses
15
+ // unproven verification). The CLI's own language processing stops at
16
+ // slugging and term counting; everything semantic is the agent's job.
15
17
 
16
18
  export async function run(positional, flags) {
17
19
  const prompt = positional.join(" ").trim();
@@ -57,9 +59,10 @@ export async function run(positional, flags) {
57
59
 
58
60
  const matches = pinned ? [] : rankCapabilities(projectRoot, prompt);
59
61
  const capability = pinned ?? matches[0]?.id ?? null;
62
+ const clarity = assessBrief(prompt, { kind: "prompt" });
60
63
 
61
64
  console.log("");
62
- printPlaybook(projectRoot, { id, prompt, pinned, matches, capability });
65
+ printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity });
63
66
  return 0;
64
67
  }
65
68
 
@@ -149,12 +152,21 @@ export function rankCapabilities(projectRoot, prompt) {
149
152
  return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, 3);
150
153
  }
151
154
 
152
- function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability }) {
155
+ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity }) {
153
156
  const capToken = capability ?? "<capability>";
154
157
  console.log(c.bold(`Work playbook — change ${id}`) + c.gray(" — agent-executed (ADR 0005)."));
155
158
  console.log("");
156
159
  console.log(`Prompt: "${prompt}"`);
157
160
 
161
+ // Clarification gate (review Topic A): a thin prompt is the moment to ask
162
+ // the user, not to invent a spec. Advisory — the change is still scaffolded
163
+ // (it is a draft), but the agent is told to resolve the gaps first.
164
+ if (clarity?.thin) {
165
+ console.log("");
166
+ console.log(c.yellow("⚠ thin prompt — clarify with the user before writing spec deltas:"));
167
+ for (const r of clarity.reasons) console.log(` - ${r}`);
168
+ }
169
+
158
170
  if (pinned) {
159
171
  const hasSpec = isFile(path.join(projectRoot, ".doctrina", "specs", pinned, "spec.md"));
160
172
  console.log(`Capability (pinned): ${c.cyan(pinned)}` +
@@ -183,18 +195,27 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability })
183
195
  console.log(c.gray(" **Operation:** ADDED | MODIFIED | REMOVED"));
184
196
  console.log(c.gray(" **Target spec on apply:** `.doctrina/specs/<capability>/spec.md`"));
185
197
  console.log(c.gray(" ---"));
186
- console.log(c.gray(" <EARS requirements: full spec body for ADDED, fragment for MODIFIED>"));
198
+ console.log(c.gray(" <EARS body. Keep the two axes honest (Status vs Implementation),"));
199
+ console.log(c.gray(" keep aspiration under ## Maturity → Future, and cite the proof per"));
200
+ console.log(c.gray(" criterion: \"1. [unverified] <signal> — verified by `path/to/test`\">"));
187
201
  console.log("");
188
202
  console.log(`4. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
189
203
  console.log(" with small, checkable implementation tasks (a few hours each, max).");
190
204
  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.");
205
+ console.log("5. Implement task by task, checking each box as it lands. Advance the");
206
+ console.log(" spec's Implementation: planned → partial → implemented as code lands.");
207
+ console.log(" If the prompt is genuinely ambiguous, ask the user before assuming.");
193
208
  console.log("");
194
209
  console.log(`6. ${c.cyan(`doctrina analyze ${id}`)} — fix every ✗ before applying.`);
195
210
  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.`);
211
+ console.log("8. Prove it before declaring done (archive refuses unchecked boxes):");
212
+ console.log(` ${c.cyan("doctrina verify")} — the project's typecheck/test/build gate`);
213
+ console.log(` ${c.cyan("doctrina coverage")} — each acceptance criterion cites a real test`);
214
+ console.log(" Then check the proposal's ## Verification boxes and every task,");
215
+ console.log(" closing steps included, and bump Implementation to verified.");
216
+ console.log("");
217
+ console.log(`9. ${c.cyan(`doctrina change archive ${id}`)}`);
218
+ console.log(`10. ${c.cyan("doctrina validate")}, then ${c.cyan("doctrina next")} for the follow-up.`);
198
219
  }
199
220
 
200
221
  export const help = `
@@ -204,9 +225,11 @@ Turn a brief prompt into a scaffolded change plus the work playbook the
204
225
  host AI agent executes (see ADR 0005). The CLI derives a sequential
205
226
  change id (NNNN-<slug>) from the prompt, opens the change folder,
206
227
  records the prompt under the proposal's "## Why", ranks existing specs
207
- 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.
228
+ by term overlap as a capability hint, flags a thin/under-specified prompt
229
+ so you clarify before writing deltas (advisory), and prints the steps:
230
+ context → spec delta → tasks → implement → analyze → apply → verify
231
+ (verify + coverage) → archive → validate. No natural-language
232
+ interpretation happens in the CLI.
210
233
 
211
234
  Options:
212
235
  --capability <cap> Pin the capability instead of ranking matches
package/src/index.js CHANGED
@@ -1,11 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import process from "node:process";
3
- import path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { readFileSync } from "node:fs";
6
3
  import { parseArgs } from "./lib/args.js";
7
4
  import { c } from "./lib/colors.js";
8
5
  import { suggest } from "./lib/suggest.js";
6
+ import { cliVersion } from "./lib/version.js";
9
7
 
10
8
  import * as init from "./commands/init.js";
11
9
  import * as spec from "./commands/spec.js";
@@ -24,11 +22,15 @@ import * as context from "./commands/context.js";
24
22
  import * as search from "./commands/search.js";
25
23
  import * as intake from "./commands/intake.js";
26
24
  import * as work from "./commands/work.js";
25
+ import * as coverage from "./commands/coverage.js";
26
+ import * as verify from "./commands/verify.js";
27
+ import * as contract from "./commands/contract.js";
28
+ import * as trace from "./commands/trace.js";
27
29
 
28
30
  const COMMANDS = {
29
31
  init, spec, change, decision, validate, hooks, analyze, clarify,
30
32
  templates, skill, index: indexCmd, next, metrics, context, search,
31
- intake, work,
33
+ intake, work, coverage, verify, contract, trace,
32
34
  };
33
35
 
34
36
  const TOP_HELP = `
@@ -44,8 +46,11 @@ Commands:
44
46
  change apply Apply spec deltas (ADDED/REMOVED auto, MODIFIED manual)
45
47
  change archive Archive an applied change
46
48
  change diff Preview spec deltas (line diff for MODIFIED)
49
+ contract new Own the integration surface (ports, env, interfaces)
50
+ contract check Verify port collisions, env drift, referenced specs
47
51
  decision new Create the next sequentially numbered ADR
48
52
  decision accept Flip a proposed ADR to accepted
53
+ decision land Record that an accepted ADR is now implemented (non-mutating)
49
54
  decision supersede Create a new ADR that supersedes an existing one
50
55
  decision list List ADRs with status, date, and title
51
56
  skill new Scaffold an on-demand procedural memory skill
@@ -56,6 +61,9 @@ 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
+ trace Report intent provenance: product intent → specs (--strict gates)
66
+ verify Run project-declared typecheck/test/build checks (the real gate)
59
67
  templates list List the templates shipped by the installed CLI
60
68
  templates check Compare the project against the recommended template shape
61
69
  hooks install Install the pre-commit hook
@@ -70,11 +78,11 @@ Global flags:
70
78
 
71
79
  async function main(argv) {
72
80
  const { positional, flags } = parseArgs(argv, {
73
- boolean: ["help", "h", "version", "v", "force", "non-interactive", "check", "save", "bug", "write", "all", "concat", "archive"],
81
+ boolean: ["help", "h", "version", "v", "force", "non-interactive", "check", "save", "bug", "write", "all", "concat", "archive", "strict", "list", "init"],
74
82
  });
75
83
 
76
84
  if (flags.get("version") || flags.get("v")) {
77
- console.log(readVersion());
85
+ console.log(cliVersion());
78
86
  return 0;
79
87
  }
80
88
 
@@ -112,14 +120,4 @@ async function main(argv) {
112
120
  }
113
121
  }
114
122
 
115
- function readVersion() {
116
- const here = path.dirname(fileURLToPath(import.meta.url));
117
- const pkgPath = path.resolve(here, "..", "package.json");
118
- try {
119
- return JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0";
120
- } catch {
121
- return "0.0.0";
122
- }
123
- }
124
-
125
123
  main(process.argv.slice(2)).then((code) => process.exit(code ?? 0));
@@ -0,0 +1,49 @@
1
+ // Deterministic under-specification detector for free-text briefs and
2
+ // intakes — the clarification gate (review Topic A). It does NOT judge
3
+ // meaning (that stays the agent's job, ADR 0005); it flags input that is
4
+ // structurally too thin to spec from, so `intake` and `work` prompt the
5
+ // agent to clarify with the user before speculating a spec or change out of
6
+ // too little. Advisory only: it surfaces the gap, it does not block.
7
+
8
+ const WEASEL = /\b(might|could|perhaps|maybe|probably|somehow|approximately|roughly|etc)\b/gi;
9
+ const VAGUE = /\b(some|several|various|appropriate|robust|scalable|nice|good|better|fast|simple|stuff|things?)\b/gi;
10
+
11
+ // Generic verbs/nouns that carry no capability information on their own. A
12
+ // brief made only of these ("add the feature", "do the thing") names nothing
13
+ // concrete to build.
14
+ const FILLER = new Set([
15
+ "the", "and", "for", "with", "this", "that", "something", "anything",
16
+ "make", "add", "create", "build", "implement", "feature", "thing",
17
+ "things", "stuff", "task", "work", "please", "just", "some", "any",
18
+ ]);
19
+
20
+ // Assess a brief/intake. `kind` tunes the thresholds: a one-line work prompt
21
+ // is held to a lower bar than a full project intake.
22
+ export function assessBrief(text, { kind = "prompt" } = {}) {
23
+ const clean = String(text ?? "").replace(/\s+/g, " ").trim();
24
+ const tokens = clean ? clean.split(" ") : [];
25
+ const words = tokens.length;
26
+ const vague = (clean.match(VAGUE) ?? []).length + (clean.match(WEASEL) ?? []).length;
27
+ const meaningful = tokens
28
+ .map((t) => t.toLowerCase().replace(/[^a-z0-9]/g, ""))
29
+ .filter((t) => t.length >= 3 && !FILLER.has(t));
30
+
31
+ const minWords = kind === "intake" ? 20 : 3;
32
+ const maxVague = kind === "intake" ? 4 : 2;
33
+
34
+ const reasons = [];
35
+ if (words < minWords) {
36
+ reasons.push(
37
+ `only ${words} word${words === 1 ? "" : "s"} — say what the behaviour is, ` +
38
+ `what the inputs are, and how you'll know it's done`,
39
+ );
40
+ }
41
+ if (words > 0 && meaningful.length === 0) {
42
+ reasons.push("no concrete terms once filler words are removed — name the actual capability or behaviour");
43
+ }
44
+ if (vague >= maxVague) {
45
+ reasons.push(`${vague} vague/weasel term${vague === 1 ? "" : "s"} — replace with definite, checkable wording`);
46
+ }
47
+
48
+ return { thin: reasons.length > 0, words, reasons };
49
+ }
@@ -1,7 +1,8 @@
1
1
  import path from "node:path";
2
2
  import { read, write, exists } from "./fs-ops.js";
3
+ import { cliVersion } from "./version.js";
3
4
 
4
- const SCHEMA_VERSION = "0.1.0";
5
+ export const SCHEMA_VERSION = "0.1.0";
5
6
 
6
7
  export function indexPath(projectRoot) {
7
8
  return path.join(projectRoot, ".doctrina", "index.json");
@@ -21,6 +22,11 @@ export function load(projectRoot) {
21
22
  }
22
23
 
23
24
  export function save(projectRoot, index) {
25
+ // Stamp the managing framework version on every write (3.6): the index
26
+ // records which CLI last wrote it, so a stale stamp (an index written by an
27
+ // older CLI) is detectable by `doctrina validate` and migrated by
28
+ // `doctrina index rebuild`.
29
+ index.framework_version = cliVersion();
24
30
  const p = indexPath(projectRoot);
25
31
  const text = JSON.stringify(index, null, 2) + "\n";
26
32
  write(p, text, { force: true });
@@ -30,7 +36,7 @@ export function blank(projectName, date) {
30
36
  return {
31
37
  $schema_version: SCHEMA_VERSION,
32
38
  project: projectName,
33
- framework_version: "0.0.0",
39
+ framework_version: cliVersion(),
34
40
  last_updated: date,
35
41
  artifacts: {
36
42
  product: {
@@ -44,6 +50,7 @@ export function blank(projectName, date) {
44
50
  changes: [],
45
51
  changes_archive: [],
46
52
  skills: [],
53
+ contracts: [],
47
54
  },
48
55
  };
49
56
  }
@@ -56,6 +63,14 @@ export function addSkill(index, entry) {
56
63
  return index;
57
64
  }
58
65
 
66
+ export function addContract(index, entry) {
67
+ if (!index.artifacts.contracts) index.artifacts.contracts = [];
68
+ if (!index.artifacts.contracts.some((s) => s.id === entry.id)) {
69
+ index.artifacts.contracts.push(entry);
70
+ }
71
+ return index;
72
+ }
73
+
59
74
  export function removeSkill(index, id) {
60
75
  if (!index.artifacts.skills) return index;
61
76
  index.artifacts.skills = index.artifacts.skills.filter((s) => s.id !== id);