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.
- package/README.md +48 -11
- package/package.json +1 -1
- package/src/commands/change.js +131 -29
- package/src/commands/coverage.js +73 -20
- 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 +16 -1
- package/src/commands/search.js +42 -7
- package/src/commands/spec.js +92 -6
- package/src/commands/trace.js +157 -0
- package/src/commands/validate.js +140 -34
- package/src/commands/verify.js +152 -2
- package/src/commands/work.js +243 -19
- package/src/index.js +6 -15
- package/src/lib/clarity.js +49 -0
- package/src/lib/index-json.js +8 -2
- package/src/lib/scan.js +8 -0
- package/src/lib/spec-ops.js +236 -0
- package/src/lib/version.js +21 -0
- package/templates/change/spec-delta.md.template +13 -2
- package/templates/decision.md.template +8 -0
- package/templates/doctrina/index.json.template +1 -1
- package/templates/doctrina/product.md.template +6 -1
- package/templates/spec.md.template +4 -0
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.
|
|
@@ -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
|
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
|
@@ -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
|
|
131
|
-
|
|
132
|
-
--force
|
|
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
|
+
`;
|