doctrina-cli 0.11.0 → 0.12.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/package.json +1 -1
- package/src/commands/analyze.js +8 -2
- package/src/commands/change.js +78 -7
- package/src/commands/clarify.js +76 -4
- package/src/commands/close.js +42 -9
- package/src/commands/context.js +10 -1
- package/src/commands/coverage.js +157 -41
- package/src/commands/intent.js +173 -0
- package/src/commands/review.js +25 -4
- package/src/commands/skill.js +46 -7
- package/src/commands/spec.js +13 -3
- package/src/commands/upgrade.js +129 -0
- package/src/commands/validate.js +117 -2
- package/src/commands/why.js +11 -1
- package/src/commands/work.js +45 -16
- package/src/index.js +11 -2
- package/src/lib/commands.js +5 -2
- package/src/lib/scan.js +16 -0
- package/templates/AGENTS.md.template +8 -4
- package/templates/spec.md.template +9 -1
package/package.json
CHANGED
package/src/commands/analyze.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
4
4
|
import { c } from "../lib/colors.js";
|
|
5
|
+
import { isUntouchedScaffold } from "./change.js";
|
|
5
6
|
|
|
6
7
|
export async function run(positional, _flags) {
|
|
7
8
|
const id = positional[0];
|
|
@@ -74,8 +75,13 @@ export async function run(positional, _flags) {
|
|
|
74
75
|
const targetSpec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
|
|
75
76
|
const targetRel = relPath(projectRoot, targetSpec);
|
|
76
77
|
if (op === "ADDED") {
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
// Mirrors `change apply`: an existing target that is still the
|
|
79
|
+
// untouched `spec new` scaffold is the canonical flow (spec new →
|
|
80
|
+
// ADDED delta), so it passes as a replacement; only real content fails.
|
|
81
|
+
if (exists(targetSpec) && !isUntouchedScaffold(read(targetSpec), cap)) {
|
|
82
|
+
results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first`, " "));
|
|
83
|
+
} else if (exists(targetSpec)) {
|
|
84
|
+
results.push(pass(` ${cap} (ADDED) → ${targetRel} (replaces the untouched scaffold)`, " "));
|
|
79
85
|
} else {
|
|
80
86
|
results.push(pass(` ${cap} (ADDED) → ${targetRel} (new)`, " "));
|
|
81
87
|
}
|
package/src/commands/change.js
CHANGED
|
@@ -67,9 +67,15 @@ function changeNew(args, flags) {
|
|
|
67
67
|
const date = today();
|
|
68
68
|
const tokens = { CHANGE_ID: id, CHANGE_TITLE: title, DATE: date, CAPABILITY: "" };
|
|
69
69
|
|
|
70
|
+
// design.md is opt-in (--design): in practice it scaffolded on every change
|
|
71
|
+
// and stayed empty — nine changes out of nine in the 0.11.0 field review. A
|
|
72
|
+
// change that needs a design doc asks for one; the rest stop carrying a
|
|
73
|
+
// blank file through apply/archive/ledger.
|
|
74
|
+
const wantDesign = flagBool(flags, "design", false);
|
|
70
75
|
const tree = loadTemplateTree(templatesDir, "change");
|
|
71
76
|
for (const entry of tree) {
|
|
72
77
|
if (entry.relativePath === "spec-delta.md.template") continue;
|
|
78
|
+
if (entry.relativePath === "design.md.template" && !wantDesign) continue;
|
|
73
79
|
const written = materialiseEntry(entry, changeDir, tokens, { force });
|
|
74
80
|
console.log(c.green("created") + ` ${relPath(projectRoot, written)}`);
|
|
75
81
|
}
|
|
@@ -139,15 +145,24 @@ function changeApply(args, _flags) {
|
|
|
139
145
|
const targetPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
|
|
140
146
|
|
|
141
147
|
if (op === "ADDED") {
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
// The canonical new-capability flow is `spec new <cap>` (which creates
|
|
149
|
+
// the file) → write the ADDED delta — so "target already exists" is the
|
|
150
|
+
// NORMAL case, not a conflict. When the existing spec is still the
|
|
151
|
+
// untouched `spec new` scaffold, ADDED means "replace the placeholder
|
|
152
|
+
// with the real spec" and applies as a whole-file write. Only a spec
|
|
153
|
+
// with real content refuses, since overwriting it would destroy truth.
|
|
154
|
+
if (exists(targetPath) && !isUntouchedScaffold(read(targetPath), capability)) {
|
|
155
|
+
console.error(c.red("error:") + ` ADDED delta targets existing spec ${relPath(projectRoot, targetPath)} with real content — refusing to overwrite`);
|
|
156
|
+
console.error(c.gray("hint: ") + "use a MODIFIED delta (with an ```ops block for bookkeeping edits), or REMOVE the spec first if it is truly being replaced");
|
|
144
157
|
errors += 1;
|
|
145
158
|
continue;
|
|
146
159
|
}
|
|
160
|
+
const replacing = exists(targetPath);
|
|
147
161
|
const body = extractDeltaBody(text);
|
|
148
|
-
write(targetPath, body, { force:
|
|
162
|
+
write(targetPath, body, { force: replacing });
|
|
149
163
|
writes += 1;
|
|
150
|
-
console.log(c.green("applied[ADDED]") + ` ${relPath(projectRoot, targetPath)}`
|
|
164
|
+
console.log(c.green("applied[ADDED]") + ` ${relPath(projectRoot, targetPath)}` +
|
|
165
|
+
(replacing ? c.gray(" (replaced the untouched spec-new scaffold)") : ""));
|
|
151
166
|
} else if (op === "REMOVED") {
|
|
152
167
|
if (!exists(targetPath)) {
|
|
153
168
|
console.log(c.yellow("warn:") + ` REMOVED delta targets missing spec ${relPath(projectRoot, targetPath)} — skipping`);
|
|
@@ -275,6 +290,25 @@ function changeArchive(args, flags) {
|
|
|
275
290
|
console.error(c.red("error:") + ` archive target already exists: ${relPath(projectRoot, archiveDir)}`);
|
|
276
291
|
return 1;
|
|
277
292
|
}
|
|
293
|
+
|
|
294
|
+
// Flip the proposal Status before the move. `apply` only flips it when no
|
|
295
|
+
// delta fell back to a manual merge, so a change whose merges were manual
|
|
296
|
+
// (the common case before the ops-block docs) reached the archive still
|
|
297
|
+
// saying "proposed" — the index recorded applied, the file lied. Archiving
|
|
298
|
+
// IS the declaration that the change went in; stamp the file to match.
|
|
299
|
+
const proposalPathLive = path.join(changeDir, "proposal.md");
|
|
300
|
+
if (exists(proposalPathLive)) {
|
|
301
|
+
const txt = read(proposalPathLive);
|
|
302
|
+
const updated = txt.replace(
|
|
303
|
+
/^(-\s+\*\*Status:\*\*)\s+proposed\s*$/m,
|
|
304
|
+
`$1 applied\n- **Applied:** ${date}`,
|
|
305
|
+
);
|
|
306
|
+
if (updated !== txt) {
|
|
307
|
+
write(proposalPathLive, updated, { force: true });
|
|
308
|
+
console.log(c.green("status") + " proposal.md → applied (stamped at archive)");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
278
312
|
move(changeDir, archiveDir);
|
|
279
313
|
console.log(c.green("archived") + ` ${relPath(projectRoot, archiveDir)}`);
|
|
280
314
|
|
|
@@ -470,6 +504,38 @@ function extractDeltaBody(text) {
|
|
|
470
504
|
return text.slice(idxSep + 5).replace(/^\n+/, "");
|
|
471
505
|
}
|
|
472
506
|
|
|
507
|
+
// Is the on-disk spec still the untouched `spec new <cap>` scaffold? Precise
|
|
508
|
+
// check: render the shipped capability template for the same capability and
|
|
509
|
+
// compare, ignoring the date-bearing "Last updated" line and whitespace
|
|
510
|
+
// normalisation. When the template cannot be located (unusual installs),
|
|
511
|
+
// fall back to the scaffold's own placeholder fingerprints — text no real
|
|
512
|
+
// spec keeps. Used by `change apply` so an ADDED delta can replace a
|
|
513
|
+
// scaffold (the canonical spec-new → delta flow) without ever clobbering a
|
|
514
|
+
// spec that carries real content.
|
|
515
|
+
function isUntouchedScaffold(specText, capability) {
|
|
516
|
+
const normalize = (s) =>
|
|
517
|
+
s.replace(/\r\n/g, "\n")
|
|
518
|
+
.split("\n")
|
|
519
|
+
.filter((line) => !/^\*\*Last updated:\*\*/.test(line))
|
|
520
|
+
.join("\n")
|
|
521
|
+
.trim();
|
|
522
|
+
try {
|
|
523
|
+
const tplPath = path.join(locateTemplatesDir(), "spec.md.template");
|
|
524
|
+
const rendered = read(tplPath)
|
|
525
|
+
.replace(/\{\{CAPABILITY\}\}/g, capability)
|
|
526
|
+
.replace(/\{\{DATE\}\}/g, "");
|
|
527
|
+
if (normalize(rendered) === normalize(specText)) return true;
|
|
528
|
+
} catch {
|
|
529
|
+
// fall through to the fingerprint heuristic
|
|
530
|
+
}
|
|
531
|
+
// Fingerprints: the Purpose placeholder comment AND an empty Ubiquitous
|
|
532
|
+
// section survive only in a scaffold nobody edited.
|
|
533
|
+
return (
|
|
534
|
+
specText.includes("<!-- One paragraph: what this capability does and why it exists. -->") &&
|
|
535
|
+
/##\s+Requirements \(EARS\)[\s\S]*?### Ubiquitous\s*\n\s*-\s*\n/.test(specText)
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
473
539
|
// Reasons a change is not finished enough to archive. Counts unchecked
|
|
474
540
|
// GitHub-style checkboxes (`- [ ]`) in tasks.md (every task, including the
|
|
475
541
|
// closing steps) and in the proposal's "## Verification" section. Returns
|
|
@@ -521,8 +587,12 @@ Usage: doctrina change <subcommand> [args]
|
|
|
521
587
|
Subcommands:
|
|
522
588
|
new <id> "<title>" Open a new change proposal at .doctrina/changes/<id>/
|
|
523
589
|
(--chore / --no-spec: a spec-less change for infra /
|
|
524
|
-
docs / build that still gets a proposal + ledger
|
|
525
|
-
|
|
590
|
+
docs / build that still gets a proposal + ledger;
|
|
591
|
+
--design: also scaffold design.md, opt-in)
|
|
592
|
+
apply <id> Apply spec deltas: ADDED writes the full body (it
|
|
593
|
+
also REPLACES a target that is still the untouched
|
|
594
|
+
\`spec new\` scaffold — the canonical new-capability
|
|
595
|
+
flow; real content refuses), REMOVED deletes,
|
|
526
596
|
MODIFIED with an \`\`\`ops block is applied mechanically
|
|
527
597
|
(set-header / bump-version / set-criterion / ...),
|
|
528
598
|
MODIFIED without one prints a manual-merge pointer.
|
|
@@ -544,9 +614,10 @@ A MODIFIED delta may carry a fenced operations block that \`apply\` executes:
|
|
|
544
614
|
Options:
|
|
545
615
|
--force Overwrite existing files (where applicable)
|
|
546
616
|
--chore, --no-spec With new, open a spec-less chore change
|
|
617
|
+
--design With new, also scaffold design.md (opt-in)
|
|
547
618
|
--reason "<text>" With abandon, the reason recorded in the ledger
|
|
548
619
|
`;
|
|
549
620
|
|
|
550
621
|
// Re-export parsers so scan.js (index rebuild) can reuse them, and the
|
|
551
622
|
// scaffold so `work` can open a change without duplicating the logic.
|
|
552
|
-
export { parseOperation, parseCapabilityFromDelta, changeNew };
|
|
623
|
+
export { parseOperation, parseCapabilityFromDelta, changeNew, isUntouchedScaffold };
|
package/src/commands/clarify.js
CHANGED
|
@@ -5,7 +5,14 @@ import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
|
5
5
|
import { flagBool } from "../lib/args.js";
|
|
6
6
|
import { c } from "../lib/colors.js";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// Two lexicons, one linter (0.11.0 field review item 3: an English-only
|
|
9
|
+
// clarify is permanently red on a PT-BR project — "some" is the verb *sumir*,
|
|
10
|
+
// "TODO o pensamento" is the pronoun *todo* — which teaches people to ignore
|
|
11
|
+
// the gate). The language comes from `.doctrina/config.json` ("language":
|
|
12
|
+
// "pt-BR" | "en"), else a per-file stopword heuristic. Portuguese mode swaps
|
|
13
|
+
// the ambiguous EN tokens for a PT lexicon; TODO only counts with a colon
|
|
14
|
+
// ("TODO:") since bare TODO is the pronoun.
|
|
15
|
+
const RULES_EN = [
|
|
9
16
|
{
|
|
10
17
|
// "may" is intentionally NOT in this list: EARS Optional grammar
|
|
11
18
|
// uses "the system may ..." legitimately, and a clarify pass that
|
|
@@ -27,6 +34,56 @@ const RULES = [
|
|
|
27
34
|
},
|
|
28
35
|
];
|
|
29
36
|
|
|
37
|
+
const RULES_PT = [
|
|
38
|
+
{
|
|
39
|
+
name: "weasel",
|
|
40
|
+
re: /\b(talvez|possivelmente|provavelmente|aproximadamente|quem sabe|eventualmente)\b/gi,
|
|
41
|
+
hint: "afirme explicitamente, com um verbo definido",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
// ASCII-folded variants included so "varios"/"vários" both match.
|
|
45
|
+
name: "vague",
|
|
46
|
+
re: /\b(muitos|muitas|poucos|poucas|v[aá]rios|v[aá]rias|alguns|algumas|adequado|adequada|robusto|robusta|escal[aá]vel)\b(?!\s+\d)/gi,
|
|
47
|
+
hint: "quantifique (um número ou um escopo preciso)",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
// Bare TODO is the Portuguese pronoun; only TODO: is a marker here.
|
|
51
|
+
name: "placeholder",
|
|
52
|
+
re: /\b(TBD|FIXME|XXX|\?\?\?)\b|\bTODO:/g,
|
|
53
|
+
hint: "resolva antes de aplicar",
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// Language for a file: the project-declared `.doctrina/config.json`
|
|
58
|
+
// ("language": "pt-BR" / "pt" / "en") wins; otherwise a deterministic
|
|
59
|
+
// stopword count over the file text decides. Never semantic (ADR 0005).
|
|
60
|
+
const PT_STOPWORDS = /\b(que|n[aã]o|para|uma|como|mais|ser|quando|est[aá]|s[aã]o|pela|pelo|dos|das|ou seja|deve)\b/gi;
|
|
61
|
+
const EN_STOPWORDS = /\b(the|and|that|with|shall|when|this|from|are|not|for|must)\b/gi;
|
|
62
|
+
|
|
63
|
+
function projectLanguage(projectRoot) {
|
|
64
|
+
const cfgPath = path.join(projectRoot, ".doctrina", "config.json");
|
|
65
|
+
if (isFile(cfgPath)) {
|
|
66
|
+
try {
|
|
67
|
+
const lang = String(JSON.parse(read(cfgPath))?.language ?? "").toLowerCase();
|
|
68
|
+
if (lang.startsWith("pt")) return "pt";
|
|
69
|
+
if (lang.startsWith("en")) return "en";
|
|
70
|
+
} catch { /* fall through to detection */ }
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function detectLang(text) {
|
|
76
|
+
const pt = (text.match(PT_STOPWORDS) ?? []).length;
|
|
77
|
+
const en = (text.match(EN_STOPWORDS) ?? []).length;
|
|
78
|
+
return pt > en ? "pt" : "en";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function rulesFor(projectRoot, text) {
|
|
82
|
+
const declared = projectLanguage(projectRoot);
|
|
83
|
+
const lang = declared ?? detectLang(text);
|
|
84
|
+
return lang === "pt" ? RULES_PT : RULES_EN;
|
|
85
|
+
}
|
|
86
|
+
|
|
30
87
|
export async function run(positional, flags) {
|
|
31
88
|
const projectRoot = process.cwd();
|
|
32
89
|
|
|
@@ -110,19 +167,27 @@ function clarifyAll(projectRoot) {
|
|
|
110
167
|
return 1;
|
|
111
168
|
}
|
|
112
169
|
|
|
113
|
-
function scanFile(fullPath) {
|
|
170
|
+
function scanFile(fullPath, projectRoot = process.cwd()) {
|
|
114
171
|
const text = read(fullPath);
|
|
115
172
|
const skippedRanges = collectSkippedRanges(text);
|
|
173
|
+
const rules = rulesFor(projectRoot, text);
|
|
116
174
|
const smells = [];
|
|
117
175
|
|
|
118
|
-
// Per-line rules: regex pass with skip-range filter.
|
|
176
|
+
// Per-line rules: regex pass with skip-range filter. A line carrying the
|
|
177
|
+
// inline suppression marker `<!-- clarify:ok -->` is deliberately accepted
|
|
178
|
+
// by the author — skip it entirely (the escape hatch for a false positive
|
|
179
|
+
// the lexicon cannot know about; "block, never imprison").
|
|
119
180
|
const lines = text.split("\n");
|
|
120
181
|
let cursor = 0;
|
|
121
182
|
for (let i = 0; i < lines.length; i++) {
|
|
122
183
|
const line = lines[i];
|
|
123
184
|
const lineStart = cursor;
|
|
124
185
|
const lineEnd = cursor + line.length;
|
|
125
|
-
|
|
186
|
+
if (line.includes("clarify:ok")) {
|
|
187
|
+
cursor = lineEnd + 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
for (const rule of rules) {
|
|
126
191
|
rule.re.lastIndex = 0;
|
|
127
192
|
let m;
|
|
128
193
|
while ((m = rule.re.exec(line))) {
|
|
@@ -200,6 +265,13 @@ placeholders, and empty Acceptance criteria sections. Exits 0 when
|
|
|
200
265
|
no smells are found, 1 otherwise. Matches inside fenced code
|
|
201
266
|
blocks, HTML comments, and inline backtick spans are skipped.
|
|
202
267
|
|
|
268
|
+
Language-aware: declare it in .doctrina/config.json
|
|
269
|
+
({ "language": "pt-BR" }) or let a per-file stopword count decide.
|
|
270
|
+
Portuguese mode uses a PT lexicon (bare TODO is the pronoun, only
|
|
271
|
+
"TODO:" is a marker; "some"/"several" are not smells). A line ending
|
|
272
|
+
in <!-- clarify:ok --> is author-accepted and never flagged — the
|
|
273
|
+
escape hatch for a false positive.
|
|
274
|
+
|
|
203
275
|
With --all, every living document is scanned in one pass:
|
|
204
276
|
product.md, capability specs, open changes, and skills. ADRs
|
|
205
277
|
(immutable) and the archive (history) are excluded. CI-friendly.
|
package/src/commands/close.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import { exists, isFile } from "../lib/fs-ops.js";
|
|
3
|
+
import { exists, isFile, read, walk } from "../lib/fs-ops.js";
|
|
4
4
|
import { flagBool } from "../lib/args.js";
|
|
5
5
|
import { c } from "../lib/colors.js";
|
|
6
|
+
import { parseCapabilityFromDelta } from "./change.js";
|
|
6
7
|
import * as analyze from "./analyze.js";
|
|
7
8
|
import * as change from "./change.js";
|
|
8
9
|
import * as verify from "./verify.js";
|
|
@@ -30,9 +31,21 @@ export async function run(positional, flags) {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
const force = flagBool(flags, "force", false);
|
|
33
|
-
const strict = new Map([["strict", true]]);
|
|
34
34
|
const archiveFlags = force ? new Map([["force", true]]) : new Map();
|
|
35
35
|
|
|
36
|
+
// Scope the coverage gate to the capabilities THIS change touches (its
|
|
37
|
+
// deltas), so one deliberately deferred spec elsewhere in the tree cannot
|
|
38
|
+
// block closing a change that never went near it (0.11.0 field review
|
|
39
|
+
// item 4). A change with no deltas (chore / metadata-only) falls back to
|
|
40
|
+
// the whole-tree gate — there is no narrower honest scope for it.
|
|
41
|
+
const touched = touchedCapabilities(projectRoot, id);
|
|
42
|
+
const coverageFlags = new Map([["strict", true]]);
|
|
43
|
+
let coverageRerun = "doctrina coverage --strict";
|
|
44
|
+
if (touched.length > 0) {
|
|
45
|
+
coverageFlags.set("only", touched.join(","));
|
|
46
|
+
coverageRerun = `doctrina coverage --strict --only ${touched.join(",")}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
// Each step: a label, the command that runs it, and the literal command to
|
|
37
50
|
// rerun on failure. verify is conditional (skipped, with a loud note, when no
|
|
38
51
|
// verify.json is declared — a project may not have wired the real gate yet).
|
|
@@ -43,7 +56,11 @@ export async function run(positional, flags) {
|
|
|
43
56
|
verifyConfigured
|
|
44
57
|
? { label: "verify", rerun: "doctrina verify", run: () => verify.run([], new Map()) }
|
|
45
58
|
: { label: "verify", skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
|
|
46
|
-
{
|
|
59
|
+
{
|
|
60
|
+
label: touched.length > 0 ? `coverage (scoped: ${touched.join(", ")})` : "coverage",
|
|
61
|
+
rerun: coverageRerun,
|
|
62
|
+
run: () => coverage.run([], coverageFlags),
|
|
63
|
+
},
|
|
47
64
|
// trace is advisory (provenance is a warning, not a hard gate): report it,
|
|
48
65
|
// never let it block the close.
|
|
49
66
|
{ label: "trace", rerun: "doctrina trace", run: async () => { await trace.run([], new Map()); return 0; } },
|
|
@@ -84,19 +101,35 @@ export async function run(positional, flags) {
|
|
|
84
101
|
return 0;
|
|
85
102
|
}
|
|
86
103
|
|
|
104
|
+
// The capabilities this change's deltas target — the honest scope for its
|
|
105
|
+
// coverage gate. Read from the change folder's specs/**/delta.md files.
|
|
106
|
+
function touchedCapabilities(projectRoot, id) {
|
|
107
|
+
const specsDir = path.join(projectRoot, ".doctrina", "changes", id, "specs");
|
|
108
|
+
const caps = new Set();
|
|
109
|
+
for (const deltaPath of walk(specsDir)) {
|
|
110
|
+
if (!deltaPath.endsWith("delta.md")) continue;
|
|
111
|
+
const cap = parseCapabilityFromDelta(read(deltaPath), deltaPath);
|
|
112
|
+
if (cap) caps.add(cap);
|
|
113
|
+
}
|
|
114
|
+
return [...caps].sort();
|
|
115
|
+
}
|
|
116
|
+
|
|
87
117
|
export const help = `
|
|
88
118
|
Usage: doctrina close <id> [--force]
|
|
89
119
|
|
|
90
120
|
Run the whole closing sequence for a change in one pass, stopping at the
|
|
91
121
|
first failure with the exact command to rerun:
|
|
92
122
|
|
|
93
|
-
analyze → change apply → verify → coverage --strict
|
|
94
|
-
archive → validate
|
|
123
|
+
analyze → change apply → verify → coverage --strict (scoped to the
|
|
124
|
+
change's touched capabilities) → trace → change archive → validate
|
|
95
125
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
126
|
+
The coverage gate is scoped to the capabilities the change's deltas touch,
|
|
127
|
+
so a deliberately deferred spec elsewhere cannot block an unrelated close;
|
|
128
|
+
a change with no deltas gates on the whole tree. verify is skipped (with a
|
|
129
|
+
note) when no .doctrina/verify.json is declared; trace is advisory (a
|
|
130
|
+
report, never a blocker). This is a driver over the existing commands — it
|
|
131
|
+
adds no checks of its own — so the agent makes one call instead of seven
|
|
132
|
+
and the human approves once.
|
|
100
133
|
|
|
101
134
|
Options:
|
|
102
135
|
--force Pass through to \`change archive\` (archive even if verification
|
package/src/commands/context.js
CHANGED
|
@@ -3,7 +3,7 @@ import process from "node:process";
|
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
6
|
-
import { listHeader } from "../lib/scan.js";
|
|
6
|
+
import { listHeader, parseDependsOn } from "../lib/scan.js";
|
|
7
7
|
import { parseFrontmatter } from "./skill.js";
|
|
8
8
|
import { flagBool, flagString } from "../lib/args.js";
|
|
9
9
|
import { c } from "../lib/colors.js";
|
|
@@ -80,6 +80,15 @@ export async function run(positional, flags) {
|
|
|
80
80
|
const specRel = `.doctrina/specs/${capability}/spec.md`;
|
|
81
81
|
if (isFile(path.join(projectRoot, specRel))) {
|
|
82
82
|
pushFile(specRel, `spec: ${capability}`);
|
|
83
|
+
// Pull the declared dependencies into the pack (the machine-readable
|
|
84
|
+
// **Depends on:** header): a task on `risk-map` that builds on
|
|
85
|
+
// `reporting` needs both truths in one read. One level deep,
|
|
86
|
+
// deterministic, no cycles (each spec is pushed at most once).
|
|
87
|
+
for (const dep of parseDependsOn(read(path.join(projectRoot, specRel)))) {
|
|
88
|
+
if (dep === capability) continue;
|
|
89
|
+
const depRel = `.doctrina/specs/${dep}/spec.md`;
|
|
90
|
+
if (isFile(path.join(projectRoot, depRel))) pushFile(depRel, `dependency of ${capability}`);
|
|
91
|
+
}
|
|
83
92
|
} else {
|
|
84
93
|
specMissing = true;
|
|
85
94
|
}
|