doctrina-cli 0.3.0 → 0.6.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,10 +3,11 @@ import process from "node:process";
3
3
  import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
4
4
  import { locateTemplatesDir, loadTemplateTree, materialiseEntry, substitute } from "../lib/templates.js";
5
5
  import { today } from "../lib/dates.js";
6
+ import { cliVersion } from "../lib/version.js";
6
7
  import { flagBool, flagString } from "../lib/args.js";
7
8
  import { c } from "../lib/colors.js";
8
9
  import { ask } from "../lib/prompt.js";
9
- import { writeIntakeFile, printBootstrapPlaybook } from "./intake.js";
10
+ import { writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake } from "./intake.js";
10
11
 
11
12
  const SUPPORTED_AGENTS = [
12
13
  "claude",
@@ -90,6 +91,10 @@ export async function run(_positional, flags) {
90
91
  PROJECT_DESCRIPTION: description,
91
92
  DATE: date,
92
93
  AGENTS_MD_PATH: "AGENTS.md",
94
+ // Stamp the framework version into the scaffolded index.json. init writes
95
+ // it straight from the template (bypassing idx.save), so the token is the
96
+ // stamp point here. (3.6)
97
+ FRAMEWORK_VERSION: cliVersion(),
93
98
  };
94
99
 
95
100
  // Root AGENTS.md — with optional --from base content prepended.
@@ -154,6 +159,7 @@ export async function run(_positional, flags) {
154
159
  console.log(`${c.cyan(".doctrina/product.md")} and capability specs. Any AGENTS.md-aware agent`);
155
160
  console.log(`runs it on its own; reprint anytime with ${c.cyan("doctrina intake")}.`);
156
161
  console.log("");
162
+ warnIfThinIntake(intakeBody);
157
163
  printBootstrapPlaybook(projectRoot);
158
164
  } else {
159
165
  console.log(`Next: edit ${c.cyan("AGENTS.md")} and ${c.cyan(".doctrina/product.md")} for your project.`);
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
4
4
  import { listHeader } from "../lib/scan.js";
5
+ import { assessBrief } from "../lib/clarity.js";
5
6
  import * as idx from "../lib/index-json.js";
6
7
  import { today } from "../lib/dates.js";
7
8
  import { flagBool, flagString } from "../lib/args.js";
@@ -69,10 +70,23 @@ export async function run(positional, flags) {
69
70
  writeIntakeFile(projectRoot, { body, source, projectName, date: today(), force });
70
71
  console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)}`);
71
72
  console.log("");
73
+ warnIfThinIntake(body);
72
74
  printBootstrapPlaybook(projectRoot);
73
75
  return 0;
74
76
  }
75
77
 
78
+ // Clarification gate (review Topic A). A description too thin to spec from is
79
+ // the moment to ask the user, not to let the agent invent requirements. Prints
80
+ // the specific gaps before the bootstrap playbook; advisory, never blocking —
81
+ // the intake is still captured verbatim (the playbook step 6 resolves it).
82
+ export function warnIfThinIntake(body) {
83
+ const assessment = assessBrief(body, { kind: "intake" });
84
+ if (!assessment.thin) return;
85
+ console.log(c.yellow("⚠ thin intake — clarify with the user before converting to specs:"));
86
+ for (const reason of assessment.reasons) console.log(` - ${reason}`);
87
+ console.log("");
88
+ }
89
+
76
90
  // Shared with `init --intake`. Writes the intake verbatim under a small
77
91
  // status header; the agent flips Status to converted at the end of the
78
92
  // playbook, which is how `next` knows the bootstrap is done.
@@ -158,7 +172,8 @@ Store the full project description verbatim at .doctrina/intake.md and
158
172
  print the bootstrap playbook — the ordered instruction sequence the host
159
173
  AI agent executes to convert the intake into product.md content and
160
174
  capability specs (see ADR 0005). The CLI itself does no natural-language
161
- interpretation.
175
+ interpretation. A thin/under-specified description is flagged so the agent
176
+ clarifies with the user before converting (advisory, never blocking).
162
177
 
163
178
  Forms:
164
179
  doctrina intake <file> Ingest a description file
@@ -6,7 +6,14 @@ import { c } from "../lib/colors.js";
6
6
 
7
7
  // Category-aware full-text search across the artifact tree. Answers
8
8
  // "where is X specified / decided / proposed?" without knowing the
9
- // layout. Plain substring matching, every term must hit (AND).
9
+ // layout. Plain substring matching, every term must hit (AND) on a line.
10
+ //
11
+ // Results are ranked, not dumped in file order: each hit is scored (a match
12
+ // in a heading or metadata header, or an adjacent full-phrase match, outranks
13
+ // a bare body line), files are scored by their hits plus a filename bonus, and
14
+ // files within a category print best-first. This mirrors the framework's own
15
+ // "ranked selection over dumping" thesis — at scale the agent reads the most
16
+ // relevant hit first instead of scanning an unordered list.
10
17
 
11
18
  const MAX_MATCHES_PER_FILE = 5;
12
19
 
@@ -16,6 +23,7 @@ export async function run(positional, flags) {
16
23
  console.error(c.red("error:") + " search requires at least one term");
17
24
  return 2;
18
25
  }
26
+ const phrase = terms.join(" ");
19
27
  const includeArchive = flagBool(flags, "archive", false);
20
28
  const projectRoot = process.cwd();
21
29
  if (!exists(path.join(projectRoot, ".doctrina"))) {
@@ -48,16 +56,22 @@ export async function run(positional, flags) {
48
56
  const lines = read(f).split("\n");
49
57
  const hits = [];
50
58
  for (let i = 0; i < lines.length; i++) {
51
- const low = lines[i].toLowerCase();
59
+ const raw = lines[i];
60
+ const low = raw.toLowerCase();
52
61
  if (terms.every((t) => low.includes(t))) {
53
- hits.push({ line: i + 1, text: lines[i].trim() });
54
- if (hits.length === MAX_MATCHES_PER_FILE) break;
62
+ hits.push({ line: i + 1, text: raw.trim(), score: lineScore(raw, low, phrase) });
55
63
  }
56
64
  }
57
- if (hits.length > 0) findings.push({ rel, hits });
65
+ if (hits.length === 0) continue;
66
+ // Keep the highest-scoring lines (best-first), not merely the first few.
67
+ hits.sort((a, b) => b.score - a.score || a.line - b.line);
68
+ const fileScore = hits.reduce((s, h) => s + h.score, 0) + filenameBonus(rel, terms);
69
+ findings.push({ rel, hits: hits.slice(0, MAX_MATCHES_PER_FILE), score: fileScore });
58
70
  }
59
71
 
60
72
  if (findings.length === 0) continue;
73
+ // Most relevant file first; stable tie-break by path.
74
+ findings.sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
61
75
  console.log(c.bold(cat.name));
62
76
  for (const file of findings) {
63
77
  filesWithMatches += 1;
@@ -81,13 +95,34 @@ function truncate(s, n) {
81
95
  return s.length > n ? s.slice(0, n - 1) + "…" : s;
82
96
  }
83
97
 
98
+ // Relevance of a single matching line. A heading or metadata-header line
99
+ // names the topic; an adjacent full-phrase match means the terms occur
100
+ // together, not scattered. Both outrank a bare body line.
101
+ function lineScore(raw, low, phrase) {
102
+ let s = 1;
103
+ if (/^\s*#{1,6}\s/.test(raw)) s += 2;
104
+ if (/\*\*[^*]+:\*\*/.test(raw)) s += 1;
105
+ if (phrase && low.includes(phrase)) s += 2;
106
+ return s;
107
+ }
108
+
109
+ // A file whose name carries the query is almost always the right one;
110
+ // reward a full filename match more than a partial one.
111
+ function filenameBonus(rel, terms) {
112
+ const base = rel.toLowerCase();
113
+ if (terms.every((t) => base.includes(t))) return 3;
114
+ return terms.some((t) => base.includes(t)) ? 1 : 0;
115
+ }
116
+
84
117
  export const help = `
85
118
  Usage: doctrina search <term> [<term> ...] [--archive]
86
119
 
87
120
  Case-insensitive substring search across the artifact tree, grouped
88
121
  by category (specs, decisions, changes, skills, product, AGENTS.md).
89
- Every term must match on the same line. The change archive is
90
- excluded unless --archive is passed.
122
+ Every term must match on the same line. Within each category, files are
123
+ ranked best-first (heading / metadata / full-phrase / filename matches
124
+ score higher), and the highest-scoring lines per file are shown. The
125
+ change archive is excluded unless --archive is passed.
91
126
 
92
127
  Exits 0 when matches are found, 1 otherwise. Read-only.
93
128
  `;
@@ -3,14 +3,15 @@ import process from "node:process";
3
3
  import { readdirSync } from "node:fs";
4
4
  import { exists, isDir, isFile, lineCount, read, relPath, write } from "../lib/fs-ops.js";
5
5
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
6
- import { specHeader } from "../lib/scan.js";
6
+ import { specHeader, deriveIndex } from "../lib/scan.js";
7
+ import { setHeader, bumpVersion, setCriterionMark } from "../lib/spec-ops.js";
7
8
  import * as idx from "../lib/index-json.js";
8
9
  import { today } from "../lib/dates.js";
9
- import { flagBool } from "../lib/args.js";
10
+ import { flagBool, flagString } from "../lib/args.js";
10
11
  import { c } from "../lib/colors.js";
11
12
  import { suggest } from "../lib/suggest.js";
12
13
 
13
- const SUBCOMMANDS = ["new", "list"];
14
+ const SUBCOMMANDS = ["new", "list", "set"];
14
15
 
15
16
  export async function run(positional, flags) {
16
17
  const sub = positional[0];
@@ -23,6 +24,7 @@ export async function run(positional, flags) {
23
24
  return 2;
24
25
  }
25
26
  if (sub === "list") return specList();
27
+ if (sub === "set") return specSet(positional.slice(1), flags);
26
28
 
27
29
  const capability = positional[1];
28
30
  if (!capability || !/^[a-z][a-z0-9-]*$/.test(capability)) {
@@ -109,6 +111,82 @@ function specList() {
109
111
  return 0;
110
112
  }
111
113
 
114
+ // Edit a spec's bookkeeping headers (and a criterion mark) and resync the
115
+ // index in one step — the inverse drift footgun the review hit (G8): a manual
116
+ // "bump the version in the spec, then bump it again in index.json" lockstep.
117
+ // Reuses the same structured ops as `change apply` (ADR 0007), so the header
118
+ // and the index can never diverge after a `spec set`.
119
+ function specSet(args, flags) {
120
+ const capability = args[0];
121
+ if (!capability || !/^[a-z][a-z0-9-]*$/.test(capability)) {
122
+ console.error(c.red("error:") + " spec set requires a <capability> (lowercase letters, digits, hyphens)");
123
+ return 2;
124
+ }
125
+ const projectRoot = process.cwd();
126
+ ensureDoctrinaProject(projectRoot);
127
+
128
+ const specPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
129
+ if (!isFile(specPath)) {
130
+ console.error(c.red("error:") + ` no spec at ${relPath(projectRoot, specPath)} (create it with \`doctrina spec new ${capability}\`)`);
131
+ return 1;
132
+ }
133
+
134
+ const impl = flagString(flags, "implementation");
135
+ const status = flagString(flags, "status");
136
+ const bump = flagString(flags, "bump");
137
+ const criterion = flagString(flags, "criterion");
138
+ if (impl === undefined && status === undefined && bump === undefined && criterion === undefined) {
139
+ console.error(c.red("error:") + " spec set needs at least one of --implementation, --status, --bump, --criterion");
140
+ console.error(c.gray("hint: ") + `example: doctrina spec set ${capability} --implementation "verified — \`src/x.js\`" --bump minor`);
141
+ return 2;
142
+ }
143
+
144
+ let text = read(specPath);
145
+ const applied = [];
146
+ const errors = [];
147
+ const apply = (res) => {
148
+ if (res.error) errors.push(res.error);
149
+ else { text = res.text; applied.push(res.summary); }
150
+ };
151
+ // Order: headers, then version, then criterion — independent edits, all or
152
+ // none (a failed op leaves the spec untouched, like `change apply`).
153
+ if (status !== undefined) apply(setHeader(text, "Status", status));
154
+ if (impl !== undefined) apply(setHeader(text, "Implementation", impl));
155
+ if (bump !== undefined) {
156
+ if (!["major", "minor", "patch"].includes(bump)) errors.push(`--bump needs major|minor|patch (got "${bump}")`);
157
+ else apply(bumpVersion(text, bump));
158
+ }
159
+ if (criterion !== undefined) {
160
+ const m = String(criterion).match(/^(\d+)\s*:\s*(.+)$/);
161
+ if (!m) errors.push(`--criterion needs "<n>:<mark>" (got "${criterion}")`);
162
+ else apply(setCriterionMark(text, Number(m[1]), m[2].trim()));
163
+ }
164
+
165
+ if (errors.length > 0) {
166
+ console.error(c.red("error:") + ` ${errors.length} operation error${errors.length === 1 ? "" : "s"} — spec left untouched:`);
167
+ for (const e of errors) console.error(` - ${e}`);
168
+ return 1;
169
+ }
170
+
171
+ // Stamp Last updated when the header exists (best-effort, never an error).
172
+ const date = today();
173
+ const lu = setHeader(text, "Last updated", date);
174
+ if (!lu.error) text = lu.text;
175
+
176
+ write(specPath, text, { force: true });
177
+ console.log(c.green("updated") + ` ${relPath(projectRoot, specPath)}`);
178
+ for (const s of applied) console.log(c.gray(` · ${s}`));
179
+
180
+ // Single source of truth: regenerate the index from the now-updated tree so
181
+ // the spec headers and the index can never drift (no manual lockstep — G8).
182
+ const index = idx.load(projectRoot);
183
+ const derived = deriveIndex(projectRoot, index);
184
+ derived.last_updated = date;
185
+ idx.save(projectRoot, derived);
186
+ console.log(c.green("indexed") + ` spec "${capability}" synced`);
187
+ return 0;
188
+ }
189
+
112
190
  function ensureDoctrinaProject(projectRoot) {
113
191
  if (!exists(path.join(projectRoot, ".doctrina"))) {
114
192
  throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
@@ -125,9 +203,17 @@ Subcommands:
125
203
  list One line per spec: id, version,
126
204
  status, line count, last updated.
127
205
  Read-only.
206
+ set <capability> [opts] Edit a spec's headers / a criterion
207
+ mark and resync the index in one step
208
+ (no manual lockstep bump — G8).
128
209
 
129
210
  Options:
130
- --bug Scaffold the bug-shape template (current / expected /
131
- unchanged behaviour) instead of the EARS capability spec.
132
- --force Overwrite an existing spec.
211
+ --bug Scaffold the bug-shape template (current / expected /
212
+ unchanged behaviour) instead of the EARS capability spec.
213
+ --force Overwrite an existing spec (with \`new\`).
214
+ --implementation "..." With \`set\`: set the Implementation header.
215
+ --status "..." With \`set\`: set the Status header.
216
+ --bump major|minor|patch With \`set\`: bump the spec Version.
217
+ --criterion "<n>:<mark>" With \`set\`: set criterion n's [mark]
218
+ (e.g. --criterion "2:verified").
133
219
  `;
@@ -0,0 +1,157 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { readdirSync } from "node:fs";
4
+ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
5
+ import { specHeader } from "../lib/scan.js";
6
+ import { flagBool } from "../lib/args.js";
7
+ import { c } from "../lib/colors.js";
8
+
9
+ // Intent-provenance report (ADR 0006). `coverage` proves a criterion has a
10
+ // test; `trace` proves a capability traces to a stated intent. Together they
11
+ // form the chain product-intent -> capability -> criterion -> test.
12
+ //
13
+ // Conventions (all opt-in; absent markers are "not yet traced", not errors):
14
+ // - product.md tags an intent anchor at the head of a bullet: "- [SC1] ...".
15
+ // Any "[A-Z]+\d+" tag is an anchor (Success criteria, In scope, ...).
16
+ // - a capability spec declares "**Realizes:** SC1, SC3".
17
+ //
18
+ // trace reports three provenance breaks: dropped intent (an anchor no spec
19
+ // realizes), dangling realizes (a spec cites an unknown id), and untraceable
20
+ // spec (an active spec with no Realizes: header). It is read-only; --strict
21
+ // turns any break into a non-zero exit for CI.
22
+ //
23
+ // What it does NOT do (the honest ceiling, per ADR 0006): judge whether a
24
+ // criterion faithfully encodes the intent. That semantic check is deferred to
25
+ // a future LLM-assisted layer and is never claimed by this deterministic gate.
26
+
27
+ const ANCHOR_RE = /[A-Z]+\d+/g;
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
+ const strict = flagBool(flags, "strict", false);
35
+
36
+ const anchors = collectAnchors(projectRoot); // [{ id }], in document order
37
+ const specs = collectSpecs(projectRoot); // [{ cap, status, realizes: [id] }]
38
+
39
+ const anchorIds = new Set(anchors.map((a) => a.id));
40
+ const anyRealizes = specs.some((s) => s.realizes !== null);
41
+
42
+ // The feature is unused: do not nag a project that never opted in.
43
+ if (anchors.length === 0 && !anyRealizes) {
44
+ console.log(
45
+ c.gray(
46
+ "no intent-provenance markers found — tag product.md bullets with `[SC1]` " +
47
+ "and add `**Realizes:** SC1` to the specs that deliver them",
48
+ ),
49
+ );
50
+ return 0;
51
+ }
52
+
53
+ // anchor id -> [capability]
54
+ const realizedBy = new Map();
55
+ const dangling = []; // { cap, id }
56
+ for (const s of specs) {
57
+ if (s.realizes === null) continue;
58
+ for (const id of s.realizes) {
59
+ if (anchorIds.has(id)) {
60
+ if (!realizedBy.has(id)) realizedBy.set(id, []);
61
+ realizedBy.get(id).push(s.cap);
62
+ } else {
63
+ dangling.push({ cap: s.cap, id });
64
+ }
65
+ }
66
+ }
67
+ // Untraceable: an active capability tied to no stated intent.
68
+ const untraceable = specs
69
+ .filter((s) => s.realizes === null && s.status === "active")
70
+ .map((s) => s.cap);
71
+
72
+ console.log(c.bold("Trace") + c.gray(" — product intent → capability provenance:"));
73
+ console.log("");
74
+
75
+ let realized = 0;
76
+ for (const a of anchors) {
77
+ const caps = realizedBy.get(a.id);
78
+ if (caps && caps.length > 0) {
79
+ realized += 1;
80
+ console.log(` ${c.cyan(a.id.padEnd(6))} ${c.green("✓")} realized by: ${caps.sort().join(", ")}`);
81
+ } else {
82
+ console.log(` ${c.cyan(a.id.padEnd(6))} ${c.red("✗")} dropped — no spec realizes this intent`);
83
+ }
84
+ }
85
+
86
+ if (dangling.length > 0 || untraceable.length > 0) console.log("");
87
+ for (const d of dangling) {
88
+ console.log(` ${c.yellow("!")} dangling: spec "${d.cap}" realizes ${d.id} (no such anchor in product.md)`);
89
+ }
90
+ if (untraceable.length > 0) {
91
+ console.log(` ${c.yellow("!")} untraceable specs (no Realizes:): ${untraceable.sort().join(", ")}`);
92
+ }
93
+
94
+ const dropped = anchors.length - realized;
95
+ console.log("");
96
+ const summary =
97
+ `${realized} of ${anchors.length} intent anchor${anchors.length === 1 ? "" : "s"} realized` +
98
+ `; ${dropped} dropped, ${dangling.length} dangling, ${untraceable.length} untraceable`;
99
+ const clean = dropped === 0 && dangling.length === 0 && untraceable.length === 0;
100
+ if (clean) {
101
+ console.log(c.green("ok") + " " + summary);
102
+ return 0;
103
+ }
104
+ console.log((strict ? c.red("fail") : c.yellow("gap")) + " " + summary);
105
+ // A report by default (exit 0); a gate under --strict (exit 1 for CI).
106
+ return strict ? 1 : 0;
107
+ }
108
+
109
+ // Every "[A-Z]+\d+" tag at the head of a bullet in product.md is an intent
110
+ // anchor. Section-agnostic so Success-criteria and In-scope bullets both work.
111
+ function collectAnchors(projectRoot) {
112
+ const productPath = path.join(projectRoot, ".doctrina", "product.md");
113
+ if (!isFile(productPath)) return [];
114
+ const out = [];
115
+ const seen = new Set();
116
+ for (const line of read(productPath).split(/\r?\n/)) {
117
+ const m = line.match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s+/);
118
+ if (m && !seen.has(m[1])) {
119
+ seen.add(m[1]);
120
+ out.push({ id: m[1] });
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+
126
+ function collectSpecs(projectRoot) {
127
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
128
+ if (!isDir(specsDir)) return [];
129
+ const out = [];
130
+ for (const cap of readdirSync(specsDir).sort()) {
131
+ const specPath = path.join(specsDir, cap, "spec.md");
132
+ if (!isFile(specPath)) continue;
133
+ const text = read(specPath);
134
+ const realizesRaw = specHeader(text, "Realizes");
135
+ const realizes = realizesRaw === null ? null : (realizesRaw.match(ANCHOR_RE) ?? []);
136
+ const status = (specHeader(text, "Status") ?? "active").trim().toLowerCase();
137
+ out.push({ cap, status, realizes });
138
+ }
139
+ return out;
140
+ }
141
+
142
+ export const help = `
143
+ Usage: doctrina trace [--strict]
144
+
145
+ Report intent provenance: how product.md intent maps to capability specs
146
+ (ADR 0006). Tag product bullets with an anchor — \`- [SC1] ...\` — and add
147
+ \`**Realizes:** SC1\` to the specs that deliver them.
148
+
149
+ trace reports three provenance breaks:
150
+ dropped intent an anchor that no spec realizes
151
+ dangling realizes a spec cites an id absent from product.md
152
+ untraceable spec an active spec with no Realizes: header
153
+
154
+ Read-only. Exits 0 as a report; with --strict, exits 1 when any break
155
+ exists (CI gate). It checks that the link exists and is complete — not
156
+ that a criterion is faithful to the intent (that stays a human/LLM call).
157
+ `;