doctrina-cli 0.3.0 → 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 +29 -17
- 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/trace.js +157 -0
- package/src/commands/validate.js +64 -23
- package/src/commands/work.js +15 -3
- 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/version.js +21 -0
- 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/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> CLI for the Doctrina framework. The package is published as
|
|
4
4
|
> `doctrina-cli`; the executable it installs is `doctrina`.
|
|
5
5
|
|
|
6
|
-
**Status:** v0.
|
|
6
|
+
**Status:** v0.4.0 — current release. Zero runtime dependencies
|
|
7
7
|
(Node.js standard library only).
|
|
8
8
|
|
|
9
9
|
## Install
|
|
@@ -20,14 +20,19 @@ Requires Node.js 20.12 or later.
|
|
|
20
20
|
|
|
21
21
|
```
|
|
22
22
|
doctrina init scaffold AGENTS.md + .doctrina/ in cwd
|
|
23
|
+
doctrina intake [<file>] store the full description + bootstrap playbook (clarification gate)
|
|
24
|
+
doctrina work "<prompt>" brief prompt -> scaffolded change + work playbook (clarification gate)
|
|
23
25
|
doctrina spec new <capability> create a capability spec
|
|
24
26
|
doctrina spec list list specs with version, status, size
|
|
25
27
|
doctrina change new <id> "<title>" open a change proposal
|
|
26
28
|
doctrina change apply <id> apply spec deltas (ADDED/REMOVED auto)
|
|
27
29
|
doctrina change archive <id> archive an applied change
|
|
28
30
|
doctrina change diff <id> preview spec deltas (line diff for MODIFIED)
|
|
31
|
+
doctrina contract new <name> own the integration surface (ports, env, interfaces)
|
|
32
|
+
doctrina contract check verify port collisions, env drift, referenced specs
|
|
29
33
|
doctrina decision new "<title>" create the next sequential ADR
|
|
30
34
|
doctrina decision accept <num> flip a proposed ADR to accepted
|
|
35
|
+
doctrina decision land <num> [path...] stamp an accepted ADR as implemented (non-mutating Landed:)
|
|
31
36
|
doctrina decision supersede <num> "<t>" supersede an existing ADR
|
|
32
37
|
doctrina decision list list ADRs with status, date, title
|
|
33
38
|
doctrina skill new <name> scaffold an on-demand procedural-memory skill
|
|
@@ -36,8 +41,11 @@ doctrina skill sync mirror skill frontmatter descriptions
|
|
|
36
41
|
doctrina analyze <change-id> inspect a change folder before applying
|
|
37
42
|
doctrina clarify <path> smell-test a Markdown file for ambiguity (--all: tree)
|
|
38
43
|
doctrina context [<capability>] print the context pack in read order (--concat)
|
|
39
|
-
doctrina search <term> [...] search artifacts, grouped by category
|
|
44
|
+
doctrina search <term> [...] search artifacts, grouped by category (ranked)
|
|
40
45
|
doctrina validate schema + artifact-existence checks
|
|
46
|
+
doctrina coverage acceptance criteria with linked evidence (--strict gates)
|
|
47
|
+
doctrina trace intent provenance: product intent -> specs (--strict gates)
|
|
48
|
+
doctrina verify run project typecheck/test/build checks (the real gate)
|
|
41
49
|
doctrina templates list enumerate shipped templates
|
|
42
50
|
doctrina templates check compare project against recommended template shape
|
|
43
51
|
doctrina templates update additive fixer for check findings (preview; --write applies)
|
|
@@ -109,9 +117,19 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
|
|
|
109
117
|
16. Each skill's frontmatter description matches `index.json` (drift warn; `skill sync` fixes).
|
|
110
118
|
17. EARS grammar shape per section in specs declaring `## Requirements (EARS)` (warn).
|
|
111
119
|
18. Nested `AGENTS.md` files obey the root size caps (warn > 150, error > 200).
|
|
120
|
+
19. No two ADR files share the same `NNNN` number (merge-collision error).
|
|
121
|
+
20. `index.json` records a `framework_version` matching the running CLI
|
|
122
|
+
(stamp-divergence warn; `index rebuild` migrates it).
|
|
123
|
+
21. Accepted ADRs anchor to reality via `Evidence:` and/or `Landed:`; cited
|
|
124
|
+
paths must exist on disk (drift warn).
|
|
112
125
|
|
|
113
126
|
Exit code: 0 if no errors (warnings allowed), 1 otherwise.
|
|
114
127
|
|
|
128
|
+
Two related read-only reports gate separately: `doctrina coverage`
|
|
129
|
+
(each acceptance criterion cites a real test) and `doctrina trace`
|
|
130
|
+
(each product intent has a capability, each capability traces to an
|
|
131
|
+
intent). Both exit 1 under `--strict` for CI.
|
|
132
|
+
|
|
115
133
|
## Tests
|
|
116
134
|
|
|
117
135
|
```
|
package/package.json
CHANGED
package/src/commands/change.js
CHANGED
|
@@ -144,6 +144,7 @@ function changeApply(args, _flags) {
|
|
|
144
144
|
// Flip proposal status from "proposed" to "applied" when fully successful.
|
|
145
145
|
// Zero-delta changes are trivially successful (no work to fail), so they
|
|
146
146
|
// also flip — otherwise metadata-only changes are stuck at "proposed".
|
|
147
|
+
let flippedToApplied = false;
|
|
147
148
|
if (errors === 0 && manual === 0) {
|
|
148
149
|
const proposalPath = path.join(changeDir, "proposal.md");
|
|
149
150
|
if (exists(proposalPath)) {
|
|
@@ -155,30 +156,41 @@ function changeApply(args, _flags) {
|
|
|
155
156
|
if (updated !== txt) {
|
|
156
157
|
write(proposalPath, updated, { force: true });
|
|
157
158
|
console.log(c.green("status") + " proposal.md → applied");
|
|
159
|
+
flippedToApplied = true;
|
|
158
160
|
}
|
|
159
161
|
}
|
|
160
162
|
}
|
|
161
163
|
|
|
162
|
-
// Update index
|
|
163
|
-
|
|
164
|
+
// Update the index when specs changed (ADDED/REMOVED) and/or the proposal
|
|
165
|
+
// flipped to applied. The change entry's status must mirror the proposal —
|
|
166
|
+
// the same value `index rebuild` derives from the file — or the index
|
|
167
|
+
// drifts from the tree in the apply→archive window (caught by
|
|
168
|
+
// `index rebuild --check`, e.g. in the pre-commit hook).
|
|
169
|
+
if (writes > 0 || flippedToApplied) {
|
|
164
170
|
const index = idx.load(projectRoot);
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
171
|
+
if (writes > 0) {
|
|
172
|
+
for (const deltaPath of deltaFiles) {
|
|
173
|
+
const text = read(deltaPath);
|
|
174
|
+
const op = parseOperation(text);
|
|
175
|
+
const capability = parseCapabilityFromDelta(text, deltaPath);
|
|
176
|
+
if (!capability) continue;
|
|
177
|
+
if (op === "ADDED") {
|
|
178
|
+
idx.addSpec(index, {
|
|
179
|
+
id: capability,
|
|
180
|
+
path: `.doctrina/specs/${capability}/spec.md`,
|
|
181
|
+
status: "active",
|
|
182
|
+
version: "0.1.0",
|
|
183
|
+
last_updated: date,
|
|
184
|
+
});
|
|
185
|
+
} else if (op === "REMOVED") {
|
|
186
|
+
idx.removeSpec(index, capability);
|
|
187
|
+
}
|
|
180
188
|
}
|
|
181
189
|
}
|
|
190
|
+
if (flippedToApplied) {
|
|
191
|
+
const entry = index.artifacts.changes.find((ch) => ch.id === id);
|
|
192
|
+
if (entry) entry.status = "applied";
|
|
193
|
+
}
|
|
182
194
|
idx.touch(index, date);
|
|
183
195
|
idx.save(projectRoot, index);
|
|
184
196
|
}
|
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.
|
|
@@ -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
|
`;
|
|
@@ -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
|
+
`;
|
package/src/commands/validate.js
CHANGED
|
@@ -3,6 +3,8 @@ 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";
|
|
@@ -45,8 +47,20 @@ export async function run(_positional, _flags) {
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
if (index) {
|
|
48
|
-
if (index.$schema_version !==
|
|
49
|
-
warnings.push(`index.json $schema_version is "${index.$schema_version}" (expected "
|
|
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
|
+
);
|
|
50
64
|
}
|
|
51
65
|
if (!index.artifacts) {
|
|
52
66
|
errors.push("index.json missing artifacts object");
|
|
@@ -79,10 +93,20 @@ export async function run(_positional, _flags) {
|
|
|
79
93
|
|
|
80
94
|
// 5. ADRs have parseable Status: header
|
|
81
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();
|
|
82
100
|
if (isDir(adrDir)) {
|
|
83
101
|
for (const f of walk(adrDir)) {
|
|
84
102
|
if (!f.endsWith(".md")) continue;
|
|
85
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
|
+
}
|
|
86
110
|
// ADR filenames must be NNNN-slug.md (four digits). The entire ADR
|
|
87
111
|
// toolchain — decision new/accept/supersede, index derivation, and
|
|
88
112
|
// the orphan check below — keys off this shape; a file like
|
|
@@ -105,33 +129,50 @@ export async function run(_positional, _flags) {
|
|
|
105
129
|
// 5b. ADR evidence drift. A decision is only true if reality reflects
|
|
106
130
|
// it; an accepted ADR that points at nothing — or at a file that
|
|
107
131
|
// no longer exists — is the classic "ADR decrees X, the code
|
|
108
|
-
// never did X".
|
|
109
|
-
//
|
|
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.
|
|
110
138
|
const evidence = listHeader(text, "Evidence");
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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)) {
|
|
117
149
|
warnings.push(
|
|
118
|
-
`${relPath(projectRoot, f)}:
|
|
119
|
-
`
|
|
120
|
-
`nothing behind it may be drift waiting to be superseded`,
|
|
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)`,
|
|
121
152
|
);
|
|
122
153
|
}
|
|
123
|
-
} else {
|
|
124
|
-
for (const token of extractBacktickPaths(evidence)) {
|
|
125
|
-
const candidates = [path.resolve(path.dirname(f), token), path.resolve(projectRoot, token)];
|
|
126
|
-
if (!candidates.some(exists)) {
|
|
127
|
-
warnings.push(
|
|
128
|
-
`${relPath(projectRoot, f)}: Evidence cites \`${token}\` which is missing on disk — ` +
|
|
129
|
-
`the decision may have drifted from the code (update the evidence or supersede the ADR)`,
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
154
|
}
|
|
134
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
|
+
}
|
|
135
176
|
}
|
|
136
177
|
}
|
|
137
178
|
|
package/src/commands/work.js
CHANGED
|
@@ -4,6 +4,7 @@ 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
|
|
@@ -58,9 +59,10 @@ export async function run(positional, flags) {
|
|
|
58
59
|
|
|
59
60
|
const matches = pinned ? [] : rankCapabilities(projectRoot, prompt);
|
|
60
61
|
const capability = pinned ?? matches[0]?.id ?? null;
|
|
62
|
+
const clarity = assessBrief(prompt, { kind: "prompt" });
|
|
61
63
|
|
|
62
64
|
console.log("");
|
|
63
|
-
printPlaybook(projectRoot, { id, prompt, pinned, matches, capability });
|
|
65
|
+
printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity });
|
|
64
66
|
return 0;
|
|
65
67
|
}
|
|
66
68
|
|
|
@@ -150,12 +152,21 @@ export function rankCapabilities(projectRoot, prompt) {
|
|
|
150
152
|
return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, 3);
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability }) {
|
|
155
|
+
function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity }) {
|
|
154
156
|
const capToken = capability ?? "<capability>";
|
|
155
157
|
console.log(c.bold(`Work playbook — change ${id}`) + c.gray(" — agent-executed (ADR 0005)."));
|
|
156
158
|
console.log("");
|
|
157
159
|
console.log(`Prompt: "${prompt}"`);
|
|
158
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
|
+
|
|
159
170
|
if (pinned) {
|
|
160
171
|
const hasSpec = isFile(path.join(projectRoot, ".doctrina", "specs", pinned, "spec.md"));
|
|
161
172
|
console.log(`Capability (pinned): ${c.cyan(pinned)}` +
|
|
@@ -214,7 +225,8 @@ Turn a brief prompt into a scaffolded change plus the work playbook the
|
|
|
214
225
|
host AI agent executes (see ADR 0005). The CLI derives a sequential
|
|
215
226
|
change id (NNNN-<slug>) from the prompt, opens the change folder,
|
|
216
227
|
records the prompt under the proposal's "## Why", ranks existing specs
|
|
217
|
-
by term overlap as a capability hint,
|
|
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:
|
|
218
230
|
context → spec delta → tasks → implement → analyze → apply → verify
|
|
219
231
|
(verify + coverage) → archive → validate. No natural-language
|
|
220
232
|
interpretation happens in the CLI.
|
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";
|
|
@@ -27,11 +25,12 @@ import * as work from "./commands/work.js";
|
|
|
27
25
|
import * as coverage from "./commands/coverage.js";
|
|
28
26
|
import * as verify from "./commands/verify.js";
|
|
29
27
|
import * as contract from "./commands/contract.js";
|
|
28
|
+
import * as trace from "./commands/trace.js";
|
|
30
29
|
|
|
31
30
|
const COMMANDS = {
|
|
32
31
|
init, spec, change, decision, validate, hooks, analyze, clarify,
|
|
33
32
|
templates, skill, index: indexCmd, next, metrics, context, search,
|
|
34
|
-
intake, work, coverage, verify, contract,
|
|
33
|
+
intake, work, coverage, verify, contract, trace,
|
|
35
34
|
};
|
|
36
35
|
|
|
37
36
|
const TOP_HELP = `
|
|
@@ -51,6 +50,7 @@ Commands:
|
|
|
51
50
|
contract check Verify port collisions, env drift, referenced specs
|
|
52
51
|
decision new Create the next sequentially numbered ADR
|
|
53
52
|
decision accept Flip a proposed ADR to accepted
|
|
53
|
+
decision land Record that an accepted ADR is now implemented (non-mutating)
|
|
54
54
|
decision supersede Create a new ADR that supersedes an existing one
|
|
55
55
|
decision list List ADRs with status, date, and title
|
|
56
56
|
skill new Scaffold an on-demand procedural memory skill
|
|
@@ -62,6 +62,7 @@ Commands:
|
|
|
62
62
|
search Search the artifact tree, grouped by category
|
|
63
63
|
validate Run schema and structural checks
|
|
64
64
|
coverage Report acceptance criteria with linked evidence (--strict gates)
|
|
65
|
+
trace Report intent provenance: product intent → specs (--strict gates)
|
|
65
66
|
verify Run project-declared typecheck/test/build checks (the real gate)
|
|
66
67
|
templates list List the templates shipped by the installed CLI
|
|
67
68
|
templates check Compare the project against the recommended template shape
|
|
@@ -81,7 +82,7 @@ async function main(argv) {
|
|
|
81
82
|
});
|
|
82
83
|
|
|
83
84
|
if (flags.get("version") || flags.get("v")) {
|
|
84
|
-
console.log(
|
|
85
|
+
console.log(cliVersion());
|
|
85
86
|
return 0;
|
|
86
87
|
}
|
|
87
88
|
|
|
@@ -119,14 +120,4 @@ async function main(argv) {
|
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
function readVersion() {
|
|
123
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
124
|
-
const pkgPath = path.resolve(here, "..", "package.json");
|
|
125
|
-
try {
|
|
126
|
-
return JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0";
|
|
127
|
-
} catch {
|
|
128
|
-
return "0.0.0";
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
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
|
+
}
|
package/src/lib/index-json.js
CHANGED
|
@@ -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:
|
|
39
|
+
framework_version: cliVersion(),
|
|
34
40
|
last_updated: date,
|
|
35
41
|
artifacts: {
|
|
36
42
|
product: {
|
package/src/lib/scan.js
CHANGED
|
@@ -81,6 +81,12 @@ export function deriveIndex(projectRoot, current) {
|
|
|
81
81
|
const implRaw = specHeader(text, "Implementation");
|
|
82
82
|
const impl = implRaw ? implRaw.trim() : prev?.implementation;
|
|
83
83
|
if (impl) entry.implementation = impl;
|
|
84
|
+
// Realizes: the product intent anchors this capability delivers (ADR
|
|
85
|
+
// 0006). Stored as an id list so the provenance link is queryable, not
|
|
86
|
+
// just prose. Header wins over the previous index, like Implementation.
|
|
87
|
+
const realizesRaw = specHeader(text, "Realizes");
|
|
88
|
+
const realizes = realizesRaw ? (realizesRaw.match(/[A-Z]+\d+/g) ?? []) : prev?.realizes;
|
|
89
|
+
if (realizes && realizes.length) entry.realizes = realizes;
|
|
84
90
|
out.artifacts.specs.push(entry);
|
|
85
91
|
}
|
|
86
92
|
|
|
@@ -104,6 +110,8 @@ export function deriveIndex(projectRoot, current) {
|
|
|
104
110
|
if (supersedes && supersedes !== "—") entry.supersedes = supersedes;
|
|
105
111
|
const supersededBy = listHeader(text, "Superseded by");
|
|
106
112
|
if (supersededBy && supersededBy !== "—") entry.superseded_by = supersededBy;
|
|
113
|
+
const landed = listHeader(text, "Landed");
|
|
114
|
+
if (landed && landed !== "—") entry.landed = landed;
|
|
107
115
|
out.artifacts.decisions.push(entry);
|
|
108
116
|
}
|
|
109
117
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
// Single source for "which CLI version is running." Read from the package
|
|
6
|
+
// manifest so it tracks the published version with no extra wiring. Used to
|
|
7
|
+
// stamp index.json (so an artifact tree records the framework that manages
|
|
8
|
+
// it — `doctrina validate` warns and `index rebuild` migrates when the stamp
|
|
9
|
+
// falls behind the running CLI) and to print `doctrina --version`.
|
|
10
|
+
|
|
11
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
export function cliVersion() {
|
|
14
|
+
// src/lib/version.js -> packages/doctrina-cli/package.json
|
|
15
|
+
const pkgPath = path.resolve(here, "..", "..", "package.json");
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0";
|
|
18
|
+
} catch {
|
|
19
|
+
return "0.0.0";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
- **Supersedes:** —
|
|
7
7
|
- **Superseded by:** —
|
|
8
8
|
- **Evidence:** —
|
|
9
|
+
- **Landed:** —
|
|
9
10
|
|
|
10
11
|
<!--
|
|
11
12
|
Evidence anchors the decision to reality. Once accepted, cite the file(s)
|
|
@@ -14,6 +15,13 @@ that prove this decision is actually implemented, in backticks, e.g.
|
|
|
14
15
|
`doctrina validate` warns when cited evidence is missing on disk (the
|
|
15
16
|
decision drifted from the code) or when an accepted ADR cites none. For a
|
|
16
17
|
process decision with no code artifact, write "n/a — <why>".
|
|
18
|
+
|
|
19
|
+
Landed is the non-mutating "this decision is now implemented and verified
|
|
20
|
+
here" stamp. Many ADRs are accepted at design time with "Evidence: n/a — no
|
|
21
|
+
implementation yet"; once the capability ships, run
|
|
22
|
+
"doctrina decision land NNNN `path/to/proof.ts` ..." to record the date and
|
|
23
|
+
proof without editing the immutable body. A filled Landed satisfies the
|
|
24
|
+
accepted-ADR evidence check.
|
|
17
25
|
-->
|
|
18
26
|
|
|
19
27
|
## Context
|
|
@@ -28,7 +28,12 @@ Out of scope (deferred or rejected):
|
|
|
28
28
|
|
|
29
29
|
## Success criteria
|
|
30
30
|
|
|
31
|
-
<!--
|
|
31
|
+
<!--
|
|
32
|
+
How you will know this project is working. Prefer measurable signals.
|
|
33
|
+
Tag a bullet with a short stable id — "- [SC1] ..." — to make it an intent
|
|
34
|
+
anchor that capability specs cite via "**Realizes:** SC1" (`doctrina trace`;
|
|
35
|
+
ADR 0006). Untagged bullets are fine; anchors are opt-in.
|
|
36
|
+
-->
|
|
32
37
|
|
|
33
38
|
## Delivery order (walking skeleton)
|
|
34
39
|
|
|
@@ -17,6 +17,10 @@ An "active" spec left at "planned" is an inventory claim with nothing
|
|
|
17
17
|
behind it, and `doctrina validate` warns. To record a deliberate gap and
|
|
18
18
|
silence the warning, add a note after the value, e.g.
|
|
19
19
|
"**Implementation:** planned — backend deferred to Q3, see ADR 0007".
|
|
20
|
+
|
|
21
|
+
Optional: trace this capability to product intent by adding a header
|
|
22
|
+
"**Realizes:** SC1, SC3" that names the product.md anchors it delivers
|
|
23
|
+
(`doctrina trace`; ADR 0006).
|
|
20
24
|
-->
|
|
21
25
|
|
|
22
26
|
## Purpose
|