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.
- package/README.md +20 -2
- package/package.json +1 -1
- package/src/commands/change.js +91 -18
- package/src/commands/contract.js +254 -0
- package/src/commands/coverage.js +179 -0
- package/src/commands/decision.js +83 -1
- package/src/commands/index-rebuild.js +9 -0
- package/src/commands/init.js +7 -1
- package/src/commands/intake.js +32 -8
- package/src/commands/search.js +42 -7
- package/src/commands/spec.js +16 -5
- package/src/commands/templates.js +1 -1
- package/src/commands/trace.js +157 -0
- package/src/commands/validate.js +149 -2
- package/src/commands/verify.js +153 -0
- package/src/commands/work.js +36 -13
- package/src/index.js +14 -16
- package/src/lib/clarity.js +49 -0
- package/src/lib/index-json.js +17 -2
- package/src/lib/scan.js +37 -3
- package/src/lib/version.js +21 -0
- package/templates/change/proposal.md.template +10 -1
- package/templates/contract.md.template +58 -0
- package/templates/decision.md.template +18 -0
- package/templates/doctrina/index.json.template +1 -1
- package/templates/doctrina/product.md.template +18 -1
- package/templates/spec.md.template +45 -3
package/src/commands/decision.js
CHANGED
|
@@ -8,7 +8,7 @@ import { today, slugify, padNumber } from "../lib/dates.js";
|
|
|
8
8
|
import { c } from "../lib/colors.js";
|
|
9
9
|
import { suggest } from "../lib/suggest.js";
|
|
10
10
|
|
|
11
|
-
const SUBCOMMANDS = ["new", "supersede", "accept", "list"];
|
|
11
|
+
const SUBCOMMANDS = ["new", "supersede", "accept", "land", "list"];
|
|
12
12
|
|
|
13
13
|
export async function run(positional, _flags) {
|
|
14
14
|
const sub = positional[0];
|
|
@@ -19,6 +19,8 @@ export async function run(positional, _flags) {
|
|
|
19
19
|
return decisionSupersede(positional.slice(1));
|
|
20
20
|
case "accept":
|
|
21
21
|
return decisionAccept(positional.slice(1));
|
|
22
|
+
case "land":
|
|
23
|
+
return decisionLand(positional.slice(1));
|
|
22
24
|
case "list":
|
|
23
25
|
return decisionList();
|
|
24
26
|
default:
|
|
@@ -198,6 +200,81 @@ function decisionAccept(args) {
|
|
|
198
200
|
return 0;
|
|
199
201
|
}
|
|
200
202
|
|
|
203
|
+
// Record that an accepted ADR is now implemented and verified, without
|
|
204
|
+
// editing the immutable body. Mutates ONLY the **Landed:** header line (the
|
|
205
|
+
// same "headers are updatable, the decision prose is not" pattern that accept
|
|
206
|
+
// and supersede already use), stamping today's date plus any cited proof
|
|
207
|
+
// paths. This closes the gap where a design-time ADR ("Evidence: n/a — no
|
|
208
|
+
// implementation yet") had no first-class way to record that reality caught up
|
|
209
|
+
// short of a heavyweight supersede. (3.4)
|
|
210
|
+
function decisionLand(args) {
|
|
211
|
+
const target = args[0];
|
|
212
|
+
if (!target) {
|
|
213
|
+
console.error(c.red("error:") + " decision land requires the target ADR number (e.g. 0003)");
|
|
214
|
+
return 2;
|
|
215
|
+
}
|
|
216
|
+
const projectRoot = process.cwd();
|
|
217
|
+
ensureDoctrinaProject(projectRoot);
|
|
218
|
+
|
|
219
|
+
const padded = padNumber(parseInt(target, 10));
|
|
220
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
221
|
+
const file = walk(adrDir).find((f) => path.basename(f).startsWith(`${padded}-`));
|
|
222
|
+
if (!file) {
|
|
223
|
+
console.error(c.red("error:") + ` no ADR found with number ${padded} in ${relPath(projectRoot, adrDir)}`);
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
226
|
+
const text = read(file);
|
|
227
|
+
const statusMatch = text.match(/^-\s+\*\*Status:\*\*\s+(.+)$/m);
|
|
228
|
+
if (!statusMatch) {
|
|
229
|
+
console.error(c.red("error:") + ` ADR at ${relPath(projectRoot, file)} has no Status: header`);
|
|
230
|
+
return 1;
|
|
231
|
+
}
|
|
232
|
+
const status = statusMatch[1].trim().toLowerCase();
|
|
233
|
+
if (status !== "accepted") {
|
|
234
|
+
console.error(
|
|
235
|
+
c.red("error:") +
|
|
236
|
+
` ADR ${padded} is "${statusMatch[1].trim()}", not "accepted" — accept it before recording that it landed`,
|
|
237
|
+
);
|
|
238
|
+
return 1;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const date = today();
|
|
242
|
+
const proofPaths = args.slice(1).filter(Boolean);
|
|
243
|
+
const cited = proofPaths.map((p) => `\`${p}\``).join(", ");
|
|
244
|
+
const landedValue = cited ? `${date} — ${cited}` : date;
|
|
245
|
+
|
|
246
|
+
// Rewrite the existing Landed header, or insert one (after Evidence, else
|
|
247
|
+
// after Status) for ADRs that predate the field. The body stays untouched.
|
|
248
|
+
let updated;
|
|
249
|
+
if (/^-\s+\*\*Landed:\*\*/m.test(text)) {
|
|
250
|
+
updated = text.replace(/^(-\s+\*\*Landed:\*\*)\s+.*$/m, `$1 ${landedValue}`);
|
|
251
|
+
} else if (/^-\s+\*\*Evidence:\*\*.*$/m.test(text)) {
|
|
252
|
+
updated = text.replace(/^(-\s+\*\*Evidence:\*\*.*)$/m, `$1\n- **Landed:** ${landedValue}`);
|
|
253
|
+
} else {
|
|
254
|
+
updated = text.replace(/^(-\s+\*\*Status:\*\*.*)$/m, `$1\n- **Landed:** ${landedValue}`);
|
|
255
|
+
}
|
|
256
|
+
write(file, updated, { force: true });
|
|
257
|
+
console.log(c.green("landed") + ` ${relPath(projectRoot, file)} on ${date}`);
|
|
258
|
+
|
|
259
|
+
const index = idx.load(projectRoot);
|
|
260
|
+
// Store the same value the header now carries, so the index matches what
|
|
261
|
+
// `index rebuild` would derive from the file (no perpetual drift).
|
|
262
|
+
idx.updateDecision(index, padded, () => ({ landed: landedValue }));
|
|
263
|
+
idx.touch(index, date);
|
|
264
|
+
idx.save(projectRoot, index);
|
|
265
|
+
console.log(c.green("indexed") + ` decision ${padded} landed`);
|
|
266
|
+
|
|
267
|
+
// Parity with `validate`: cited proof that does not resolve is a dangling
|
|
268
|
+
// citation, surfaced now rather than at the next validate.
|
|
269
|
+
for (const p of proofPaths) {
|
|
270
|
+
const candidates = [path.resolve(path.dirname(file), p), path.resolve(projectRoot, p)];
|
|
271
|
+
if (!candidates.some(exists)) {
|
|
272
|
+
console.log(c.yellow("warn:") + ` cited proof \`${p}\` not found on disk`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
201
278
|
function decisionList() {
|
|
202
279
|
const projectRoot = process.cwd();
|
|
203
280
|
ensureDoctrinaProject(projectRoot);
|
|
@@ -259,6 +336,11 @@ Subcommands:
|
|
|
259
336
|
new "<title>" Create the next sequentially numbered ADR
|
|
260
337
|
accept <number> Flip a proposed ADR to accepted (rewrites
|
|
261
338
|
only the Status: header) and update the index
|
|
339
|
+
land <number> [path ...] Stamp an accepted ADR as implemented:
|
|
340
|
+
rewrites only the Landed: header with today's
|
|
341
|
+
date plus any cited proof paths. The decision
|
|
342
|
+
body stays immutable. Satisfies the accepted-
|
|
343
|
+
ADR evidence check without a supersede.
|
|
262
344
|
supersede <number> "<new title>" Create a new ADR that supersedes the target
|
|
263
345
|
and rewrite the target's Status: header
|
|
264
346
|
list One line per ADR: number, status, date,
|
|
@@ -3,6 +3,7 @@ import process from "node:process";
|
|
|
3
3
|
import { exists } from "../lib/fs-ops.js";
|
|
4
4
|
import * as idx from "../lib/index-json.js";
|
|
5
5
|
import { deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
|
|
6
|
+
import { cliVersion } from "../lib/version.js";
|
|
6
7
|
import { today } from "../lib/dates.js";
|
|
7
8
|
import { flagBool } from "../lib/args.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
@@ -42,6 +43,11 @@ function rebuild(flags) {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
const derived = deriveIndex(projectRoot, current);
|
|
46
|
+
// Migrate the framework stamp to the running CLI (3.6). deriveIndex carries
|
|
47
|
+
// the old value over (so `next` does not nag on a version-only difference);
|
|
48
|
+
// overriding it here lets a stale stamp count as drift, so `index rebuild`
|
|
49
|
+
// both reports and fixes it instead of short-circuiting on "nothing to do".
|
|
50
|
+
derived.framework_version = cliVersion();
|
|
45
51
|
|
|
46
52
|
if (indexesMatch(derived, current)) {
|
|
47
53
|
console.log(c.green("ok") + " index.json matches the tree (nothing to do)");
|
|
@@ -72,6 +78,9 @@ function rebuild(flags) {
|
|
|
72
78
|
function describeDrift(current, derived) {
|
|
73
79
|
const lines = [];
|
|
74
80
|
if (!current) return ["index.json missing or unreadable"];
|
|
81
|
+
if ((current.framework_version ?? null) !== (derived.framework_version ?? null)) {
|
|
82
|
+
lines.push(`framework_version: ${current.framework_version ?? "unset"} -> ${derived.framework_version}`);
|
|
83
|
+
}
|
|
75
84
|
const categories = ["specs", "decisions", "changes", "changes_archive", "skills"];
|
|
76
85
|
for (const cat of categories) {
|
|
77
86
|
const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
package/src/commands/init.js
CHANGED
|
@@ -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.`);
|
package/src/commands/intake.js
CHANGED
|
@@ -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.
|
|
@@ -110,7 +124,9 @@ export function printBootstrapPlaybook(projectRoot) {
|
|
|
110
124
|
console.log(`1. Read ${c.cyan(".doctrina/intake.md")} (raw intent) and ${c.cyan(".doctrina/product.md")}.`);
|
|
111
125
|
console.log("");
|
|
112
126
|
console.log("2. Fill every product.md section from the intake: Vision, Problem,");
|
|
113
|
-
console.log(" Target users, Scope (in/out), Non-goals, Success criteria
|
|
127
|
+
console.log(" Target users, Scope (in/out), Non-goals, Success criteria, and the");
|
|
128
|
+
console.log(" Delivery order — name the ONE end-to-end walking skeleton to build");
|
|
129
|
+
console.log(" and verify before fanning out (depth before breadth).");
|
|
114
130
|
console.log(" One fact, one home — product.md holds vision, never requirements.");
|
|
115
131
|
console.log("");
|
|
116
132
|
console.log("3. Derive the capability list: kebab-case, one per area of behaviour");
|
|
@@ -119,22 +135,29 @@ export function printBootstrapPlaybook(projectRoot) {
|
|
|
119
135
|
console.log(" then replace the template placeholders in");
|
|
120
136
|
console.log(" .doctrina/specs/<capability>/spec.md with EARS requirements derived");
|
|
121
137
|
console.log(" from the intake (Ubiquitous / Event-driven / State-driven /");
|
|
122
|
-
console.log(" Unwanted-behavior / Optional).
|
|
123
|
-
console.log("
|
|
138
|
+
console.log(" Unwanted-behavior / Optional). Keep the two axes honest — leave");
|
|
139
|
+
console.log(" Implementation: planned for what is not built yet; keep aspiration");
|
|
140
|
+
console.log(" under ## Maturity → Future, not in EARS; write concrete Acceptance");
|
|
141
|
+
console.log(" criteria, each [unverified] until a cited test proves it.");
|
|
142
|
+
console.log("");
|
|
143
|
+
console.log("4. If the project spans services/front-ends, own the seams between");
|
|
144
|
+
console.log(" them (port map, env, API/WS/event shapes) as a contract:");
|
|
145
|
+
console.log(` ${c.cyan("doctrina contract new <name>")} → fill it → ${c.cyan("doctrina contract check")}`);
|
|
124
146
|
console.log("");
|
|
125
|
-
console.log("
|
|
147
|
+
console.log("5. Record any architectural decision the intake forces (cite Evidence):");
|
|
126
148
|
console.log(` ${c.cyan("doctrina decision new \"<title>\"")} → edit → ${c.cyan("doctrina decision accept <num>")}`);
|
|
127
149
|
console.log("");
|
|
128
|
-
console.log("
|
|
150
|
+
console.log("6. Quality gates — fix everything they report before moving on:");
|
|
129
151
|
console.log(` ${c.cyan("doctrina clarify --all")}`);
|
|
130
152
|
console.log(` ${c.cyan("doctrina validate")}`);
|
|
153
|
+
console.log(` ${c.cyan("doctrina coverage")} (and ${c.cyan("doctrina verify --init")} to declare the build gate)`);
|
|
131
154
|
console.log(" If something in the intake is genuinely ambiguous, ask the user");
|
|
132
155
|
console.log(" before assuming.");
|
|
133
156
|
console.log("");
|
|
134
|
-
console.log("
|
|
157
|
+
console.log("7. Mark the intake consumed: in .doctrina/intake.md flip");
|
|
135
158
|
console.log(" \"- **Status:** pending\" to \"- **Status:** converted\".");
|
|
136
159
|
console.log("");
|
|
137
|
-
console.log(`
|
|
160
|
+
console.log(`8. ${c.cyan("doctrina next")} — follow the recommendation. Implementation then`);
|
|
138
161
|
console.log(` flows through ${c.cyan("doctrina work \"<brief prompt>\"")}, one change at a time.`);
|
|
139
162
|
if (!hasSpecs) {
|
|
140
163
|
console.log("");
|
|
@@ -149,7 +172,8 @@ Store the full project description verbatim at .doctrina/intake.md and
|
|
|
149
172
|
print the bootstrap playbook — the ordered instruction sequence the host
|
|
150
173
|
AI agent executes to convert the intake into product.md content and
|
|
151
174
|
capability specs (see ADR 0005). The CLI itself does no natural-language
|
|
152
|
-
interpretation.
|
|
175
|
+
interpretation. A thin/under-specified description is flagged so the agent
|
|
176
|
+
clarifies with the user before converting (advisory, never blocking).
|
|
153
177
|
|
|
154
178
|
Forms:
|
|
155
179
|
doctrina intake <file> Ingest a description file
|
package/src/commands/search.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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.
|
|
90
|
-
|
|
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
|
`;
|
package/src/commands/spec.js
CHANGED
|
@@ -51,13 +51,20 @@ export async function run(positional, flags) {
|
|
|
51
51
|
console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}${bug ? " (bug-spec shape)" : ""}`);
|
|
52
52
|
|
|
53
53
|
const index = idx.load(projectRoot);
|
|
54
|
-
|
|
54
|
+
// Two axes: a fresh capability scaffold is a draft document for a
|
|
55
|
+
// planned capability. Promote `status` to active once it reflects
|
|
56
|
+
// intent; advance `implementation` as code lands (see `doctrina
|
|
57
|
+
// coverage`). Bug specs are a different shape and stay off the
|
|
58
|
+
// implementation axis — only the capability template carries it.
|
|
59
|
+
const entry = {
|
|
55
60
|
id: capability,
|
|
56
61
|
path: `.doctrina/specs/${capability}/spec.md`,
|
|
57
|
-
status: "active",
|
|
62
|
+
status: bug ? "active" : "draft",
|
|
58
63
|
version: "0.1.0",
|
|
59
64
|
last_updated: date,
|
|
60
|
-
}
|
|
65
|
+
};
|
|
66
|
+
if (!bug) entry.implementation = "planned";
|
|
67
|
+
idx.addSpec(index, entry);
|
|
61
68
|
idx.touch(index, date);
|
|
62
69
|
idx.save(projectRoot, index);
|
|
63
70
|
console.log(c.green("indexed") + ` spec "${capability}"`);
|
|
@@ -75,10 +82,14 @@ function specList() {
|
|
|
75
82
|
const p = path.join(specsDir, cap, "spec.md");
|
|
76
83
|
if (!isFile(p)) continue;
|
|
77
84
|
const text = read(p);
|
|
85
|
+
// Show only the leading state word of Implementation, dropping any
|
|
86
|
+
// explanatory note after it ("planned — deferred" -> "planned").
|
|
87
|
+
const implRaw = specHeader(text, "Implementation");
|
|
78
88
|
rows.push({
|
|
79
89
|
id: cap,
|
|
80
90
|
version: specHeader(text, "Version") ?? "—",
|
|
81
91
|
status: specHeader(text, "Status") ?? "active",
|
|
92
|
+
impl: implRaw ? implRaw.trim().split(/[\s—-]+/)[0] : "—",
|
|
82
93
|
lines: lineCount(p),
|
|
83
94
|
updated: specHeader(text, "Last updated") ?? "—",
|
|
84
95
|
});
|
|
@@ -88,10 +99,10 @@ function specList() {
|
|
|
88
99
|
console.log(c.gray("no specs found in .doctrina/specs/"));
|
|
89
100
|
return 0;
|
|
90
101
|
}
|
|
91
|
-
console.log(c.bold("Specs:"));
|
|
102
|
+
console.log(c.bold("Specs:") + c.gray(" (status = document, impl = capability)"));
|
|
92
103
|
console.log("");
|
|
93
104
|
for (const r of rows) {
|
|
94
|
-
console.log(` ${c.cyan(r.id.padEnd(20))} ${r.version.padEnd(8)} ${r.status.padEnd(10)} ${String(r.lines).padStart(4)} lines ${r.updated}`);
|
|
105
|
+
console.log(` ${c.cyan(r.id.padEnd(20))} ${r.version.padEnd(8)} ${r.status.padEnd(10)} ${r.impl.padEnd(12)} ${String(r.lines).padStart(4)} lines ${r.updated}`);
|
|
95
106
|
}
|
|
96
107
|
console.log("");
|
|
97
108
|
console.log(c.gray(`${rows.length} spec${rows.length === 1 ? "" : "s"}`));
|
|
@@ -89,7 +89,7 @@ function updateTemplates(flags) {
|
|
|
89
89
|
if (idx.$schema_version === undefined) describe.push('index.json: add "$schema_version": "0.1.0"');
|
|
90
90
|
if (idx.project === undefined) describe.push(`index.json: add "project": "${path.basename(projectRoot)}"`);
|
|
91
91
|
if (idx.artifacts === undefined) describe.push('index.json: add empty "artifacts" object');
|
|
92
|
-
const categories = ["specs", "decisions", "changes", "changes_archive", "skills"];
|
|
92
|
+
const categories = ["specs", "decisions", "changes", "changes_archive", "skills", "contracts"];
|
|
93
93
|
for (const cat of categories) {
|
|
94
94
|
if (idx.artifacts && idx.artifacts[cat] === undefined) {
|
|
95
95
|
describe.push(`index.json: add empty artifact category "${cat}"`);
|
|
@@ -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
|
+
`;
|