doctrina-cli 0.16.0 → 0.18.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 +31 -73
- package/package.json +3 -3
- package/src/commands/adapter.js +3 -2
- package/src/commands/analyze.js +8 -0
- package/src/commands/change.js +86 -80
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +136 -34
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +6 -4
- package/src/commands/decision.js +12 -0
- package/src/commands/doctor.js +1 -1
- package/src/commands/hooks.js +6 -1
- package/src/commands/index-rebuild.js +52 -2
- package/src/commands/init.js +19 -4
- package/src/commands/intake.js +55 -7
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +13 -5
- package/src/commands/next.js +2 -1
- package/src/commands/prime.js +4 -5
- package/src/commands/report.js +9 -20
- package/src/commands/review.js +47 -8
- package/src/commands/skill.js +8 -139
- package/src/commands/spec.js +13 -7
- package/src/commands/status.js +39 -20
- package/src/commands/templates.js +4 -10
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +32 -8
- package/src/commands/verify.js +59 -6
- package/src/commands/work.js +63 -8
- package/src/index.js +34 -6
- package/src/lib/actions.js +38 -28
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +95 -1
- package/src/lib/commands.js +96 -15
- package/src/lib/coverage-model.js +40 -1
- package/src/lib/doc-model.js +47 -6
- package/src/lib/docs-impact.js +74 -0
- package/src/lib/ears.js +24 -1
- package/src/lib/gates.js +53 -9
- package/src/lib/git.js +59 -2
- package/src/lib/index-json.js +40 -5
- package/src/lib/intake-model.js +71 -2
- package/src/lib/lexicon.js +38 -1
- package/src/lib/metrics-model.js +30 -0
- package/src/lib/playbook.js +1 -1
- package/src/lib/project.js +49 -1
- package/src/lib/runtime.js +6 -0
- package/src/lib/scan.js +139 -14
- package/src/lib/signoff.js +24 -0
- package/src/lib/skill-candidates.js +162 -0
- package/src/lib/snapshot.js +61 -2
- package/src/lib/spec-ops.js +141 -6
- package/src/lib/templates-model.js +35 -8
- package/src/lib/validation-model.js +117 -35
- package/src/lib/verify-cache.js +76 -0
- package/src/lib/views.js +10 -2
- package/src/lib/work-model.js +57 -12
- package/templates/AGENTS.md.template +3 -4
- package/templates/README.md +2 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +15 -0
- package/templates/change/spec-delta.md.template +2 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +10 -0
- package/templates/playbooks/bootstrap.md.template +6 -2
- package/templates/playbooks/chore.md.template +9 -4
- package/templates/playbooks/consolidate.md.template +28 -0
- package/templates/playbooks/work.md.template +13 -3
- package/src/commands/constitution.js +0 -68
package/src/lib/spec-ops.js
CHANGED
|
@@ -117,6 +117,40 @@ function matchOpsBlock(text) {
|
|
|
117
117
|
return null;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/** One op line, parsed by the grammar `apply` reads: `{ verb, ... }` or `{ verb, error }`. */
|
|
121
|
+
export function parseOp(line) {
|
|
122
|
+
return parseOpLine(String(line).trim());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Append op lines to a delta's live `ops` block — creating the block at the
|
|
127
|
+
* end when there is none (change 0205). A `bump-version` line replaces the
|
|
128
|
+
* block's existing one instead of stacking a second bump on it. Pure.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} deltaText
|
|
131
|
+
* @param {string[]} lines already validated with `parseOp`
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
export function insertOps(deltaText, lines) {
|
|
135
|
+
const ranges = commentRanges(deltaText);
|
|
136
|
+
const fenceRe = /^([ \t]*```[ \t]*ops[ \t]*\r?\n)([\s\S]*?)(^[ \t]*```[ \t]*$)/gm;
|
|
137
|
+
const bumps = lines.some((l) => parseOp(l).verb === "bump-version");
|
|
138
|
+
let m;
|
|
139
|
+
while ((m = fenceRe.exec(deltaText))) {
|
|
140
|
+
if (isInsideComment(ranges, m.index)) continue;
|
|
141
|
+
const kept = m[2].split(/\r?\n/).filter((l) => l.trim() !== "")
|
|
142
|
+
.filter((l) => !(bumps && parseOp(l).verb === "bump-version"));
|
|
143
|
+
// The bump goes last, where a reader looks for it.
|
|
144
|
+
const ordered = [...kept, ...lines].sort((a, b) =>
|
|
145
|
+
Number(parseOp(a).verb === "bump-version") - Number(parseOp(b).verb === "bump-version"));
|
|
146
|
+
const block = `${m[1]}${ordered.join("\n")}\n${m[3]}`;
|
|
147
|
+
return deltaText.slice(0, m.index) + block + deltaText.slice(m.index + m[0].length);
|
|
148
|
+
}
|
|
149
|
+
const ordered = [...lines].sort((a, b) =>
|
|
150
|
+
Number(parseOp(a).verb === "bump-version") - Number(parseOp(b).verb === "bump-version"));
|
|
151
|
+
return `${deltaText.replace(/\s*$/, "")}\n\n\`\`\`ops\n${ordered.join("\n")}\n\`\`\`\n`;
|
|
152
|
+
}
|
|
153
|
+
|
|
120
154
|
function parseOpLine(line) {
|
|
121
155
|
// `<verb> <rest>` — the verb is the first whitespace-delimited token.
|
|
122
156
|
const sp = line.indexOf(" ");
|
|
@@ -178,6 +212,24 @@ function parseOpLine(line) {
|
|
|
178
212
|
if (!value) return { verb, error: `replace-requirement ${n} is missing the requirement text` };
|
|
179
213
|
return { verb, section, n, value };
|
|
180
214
|
}
|
|
215
|
+
case "remove-requirement": {
|
|
216
|
+
// remove-requirement <section> <n>[: <why>] — the reason is for the
|
|
217
|
+
// reader of the delta; the spec loses the bullet either way.
|
|
218
|
+
const colon = rest.indexOf(":");
|
|
219
|
+
const head = (colon < 0 ? rest : rest.slice(0, colon)).trim().split(/\s+/);
|
|
220
|
+
const n = Number(head[head.length - 1]);
|
|
221
|
+
const section = normalizeSection(head.slice(0, -1).join(" "));
|
|
222
|
+
if (!section) return { verb, error: `remove-requirement: unknown section "${head.slice(0, -1).join(" ")}" (use ${SECTION_TOKENS})` };
|
|
223
|
+
if (!Number.isInteger(n) || n < 1) return { verb, error: `remove-requirement needs "<section> <n>" with a positive bullet number (got "${rest}")` };
|
|
224
|
+
return { verb, section, n };
|
|
225
|
+
}
|
|
226
|
+
case "remove-criterion": {
|
|
227
|
+
// remove-criterion <n>[: <why>]
|
|
228
|
+
const colon = rest.indexOf(":");
|
|
229
|
+
const n = Number((colon < 0 ? rest : rest.slice(0, colon)).trim());
|
|
230
|
+
if (!Number.isInteger(n) || n < 1) return { verb, error: `remove-criterion needs a positive criterion number (got "${rest}")` };
|
|
231
|
+
return { verb, n };
|
|
232
|
+
}
|
|
181
233
|
default:
|
|
182
234
|
return { verb: null, error: `unknown operation "${verb}"` };
|
|
183
235
|
}
|
|
@@ -238,6 +290,10 @@ function applyOne(text, op) {
|
|
|
238
290
|
return appendRequirement(text, op.section, op.value);
|
|
239
291
|
case "replace-requirement":
|
|
240
292
|
return replaceRequirement(text, op.section, op.n, op.value);
|
|
293
|
+
case "remove-requirement":
|
|
294
|
+
return removeRequirement(text, op.section, op.n);
|
|
295
|
+
case "remove-criterion":
|
|
296
|
+
return removeCriterion(text, op.n);
|
|
241
297
|
default:
|
|
242
298
|
return { error: `unknown operation "${op.verb}"` };
|
|
243
299
|
}
|
|
@@ -256,7 +312,10 @@ export function setHeader(text, name, value) {
|
|
|
256
312
|
// has a lifecycle of its own (proposed -> applied, accepted, superseded).
|
|
257
313
|
const listForm = /^\s*-/.test(text.match(re)?.[1] ?? "");
|
|
258
314
|
const domain = listForm ? null : headerValueError(name, value);
|
|
259
|
-
|
|
315
|
+
// `cause: "argument"` marks an error about the value supplied, not about
|
|
316
|
+
// the document: `spec set` answers it as USAGE, a delta as a defect of
|
|
317
|
+
// the work (ADR 0018).
|
|
318
|
+
if (domain) return { error: `set-header: ${domain}`, cause: "argument" };
|
|
260
319
|
const next = text.replace(re, (_m, prefix) => `${prefix} ${value}`.replace(/\s+$/, ""));
|
|
261
320
|
return { text: next, summary: `set ${name}: ${value}` };
|
|
262
321
|
}
|
|
@@ -309,10 +368,10 @@ export function setCriterionMark(text, n, markRaw) {
|
|
|
309
368
|
const loc = locateCriteria(text);
|
|
310
369
|
if (!loc) return { error: "set-criterion: spec has no '## Acceptance criteria' section" };
|
|
311
370
|
const item = loc.items.find((it) => it.n === n);
|
|
312
|
-
if (!item) return { error: `set-criterion: no criterion #${n} found
|
|
371
|
+
if (!item) return { error: `set-criterion: no criterion #${n} found`, cause: "argument" };
|
|
313
372
|
const mark = markRaw.replace(/^\[|\]$/g, "").trim();
|
|
314
373
|
const domain = criterionMarkError(mark);
|
|
315
|
-
if (domain) return { error: `set-criterion: ${domain}
|
|
374
|
+
if (domain) return { error: `set-criterion: ${domain}`, cause: "argument" };
|
|
316
375
|
const line = loc.lines[item.lineIndex];
|
|
317
376
|
let next;
|
|
318
377
|
if (/\[[^\]]*\]/.test(line)) {
|
|
@@ -331,10 +390,33 @@ export function replaceCriterion(text, n, value) {
|
|
|
331
390
|
if (!loc) return { error: "replace-criterion: spec has no '## Acceptance criteria' section" };
|
|
332
391
|
const item = loc.items.find((it) => it.n === n);
|
|
333
392
|
if (!item) return { error: `replace-criterion: no criterion #${n} found` };
|
|
334
|
-
|
|
393
|
+
// The whole item, continuation lines included (change 0206) — the lesson
|
|
394
|
+
// `replace-requirement` learned, which this verb never picked up: a
|
|
395
|
+
// criterion wrapped over three lines kept its old second and third lines
|
|
396
|
+
// under the new first one, so the spec stated a proof and then the
|
|
397
|
+
// previous proof's tail.
|
|
398
|
+
loc.lines.splice(item.lineIndex, endOfItem(loc.lines, item.lineIndex) - item.lineIndex + 1, `${item.indent}${n}. ${value}`);
|
|
335
399
|
return { text: loc.lines.join("\n"), summary: `replace-criterion ${n}` };
|
|
336
400
|
}
|
|
337
401
|
|
|
402
|
+
// remove-criterion <n> — delete criterion n, continuation lines included,
|
|
403
|
+
// and renumber the ones after it so the list stays 1..N (change 0206). A
|
|
404
|
+
// spec only ever grew: there was no verb to take a criterion out, so a
|
|
405
|
+
// superseded one stayed beside its replacement for good.
|
|
406
|
+
export function removeCriterion(text, n) {
|
|
407
|
+
const loc = locateCriteria(text);
|
|
408
|
+
if (!loc) return { error: "remove-criterion: spec has no '## Acceptance criteria' section" };
|
|
409
|
+
const item = loc.items.find((it) => it.n === n);
|
|
410
|
+
if (!item) return { error: `remove-criterion: no criterion #${n} found` };
|
|
411
|
+
const end = endOfItem(loc.lines, item.lineIndex);
|
|
412
|
+
const lines = loc.lines;
|
|
413
|
+
for (const it of loc.items) {
|
|
414
|
+
if (it.n > n) lines[it.lineIndex] = lines[it.lineIndex].replace(/^(\s*)\d+\./, `$1${it.n - 1}.`);
|
|
415
|
+
}
|
|
416
|
+
lines.splice(item.lineIndex, end - item.lineIndex + 1);
|
|
417
|
+
return { text: lines.join("\n"), summary: `remove-criterion ${n} (${loc.items.length - n} renumbered)` };
|
|
418
|
+
}
|
|
419
|
+
|
|
338
420
|
// The `### <Section>` blocks under `## Requirements (EARS)`. Returns
|
|
339
421
|
// { lines, sections: { <token>: { headingIndex, endIndex, bullets: [lineIndex] } } }
|
|
340
422
|
// or null when the Requirements section is absent. A section's token is the
|
|
@@ -481,11 +563,44 @@ export function replaceRequirement(text, section, n, value) {
|
|
|
481
563
|
if (n > real.length) {
|
|
482
564
|
return { error: `replace-requirement: section "${section}" has ${real.length} requirement${real.length === 1 ? "" : "s"}, no #${n}` };
|
|
483
565
|
}
|
|
484
|
-
|
|
485
|
-
|
|
566
|
+
// REPLACE THE WHOLE ITEM, continuation lines included.
|
|
567
|
+
//
|
|
568
|
+
// Writing only the first line leaves the old bullet's wrapped prose sitting
|
|
569
|
+
// under the new one, indented, reading as part of it. That is not cosmetic:
|
|
570
|
+
// the orphan is the PREVIOUS version of the requirement, so the spec ends up
|
|
571
|
+
// stating a contract and then contradicting it two lines later, and a spec
|
|
572
|
+
// is the one artifact this framework asks everyone to trust.
|
|
573
|
+
//
|
|
574
|
+
// It had already happened four times in this repository's own tree before
|
|
575
|
+
// anyone noticed — twice in `gates` (a closing sequence missing its review
|
|
576
|
+
// and runtime steps, a doctor set missing its runtime row) and once in
|
|
577
|
+
// `insight`, all three describing older behaviour beside the current one.
|
|
578
|
+
//
|
|
579
|
+
// `append-requirement` learned this in 0.13.0 and got `endOfItem`; this verb
|
|
580
|
+
// was written afterwards and never picked it up.
|
|
581
|
+
const start = real[n - 1];
|
|
582
|
+
const indent = loc.lines[start].match(/^(\s*)/)[1];
|
|
583
|
+
loc.lines.splice(start, endOfItem(loc.lines, start) - start + 1, `${indent}- ${value}`);
|
|
486
584
|
return { text: loc.lines.join("\n"), summary: `replace-requirement ${section} ${n}` };
|
|
487
585
|
}
|
|
488
586
|
|
|
587
|
+
// remove-requirement <section> <n> — delete the nth bullet (1-based) of the
|
|
588
|
+
// EARS subsection, continuation lines included (change 0206). The heading
|
|
589
|
+
// stays even when it empties: `append-requirement` can fill it again.
|
|
590
|
+
export function removeRequirement(text, section, n) {
|
|
591
|
+
const loc = locateRequirements(text);
|
|
592
|
+
if (!loc) return { error: "remove-requirement: spec has no '## Requirements (EARS)' section" };
|
|
593
|
+
const sec = loc.sections[section];
|
|
594
|
+
if (!sec) return { error: `remove-requirement: no '### ${section}...' subsection found under Requirements` };
|
|
595
|
+
const real = sec.bullets.filter((i) => !/^\s*-\s*$/.test(loc.lines[i]));
|
|
596
|
+
if (n > real.length) {
|
|
597
|
+
return { error: `remove-requirement: section "${section}" has ${real.length} requirement${real.length === 1 ? "" : "s"}, no #${n}` };
|
|
598
|
+
}
|
|
599
|
+
const start = real[n - 1];
|
|
600
|
+
loc.lines.splice(start, endOfItem(loc.lines, start) - start + 1);
|
|
601
|
+
return { text: loc.lines.join("\n"), summary: `remove-requirement ${section} ${n}` };
|
|
602
|
+
}
|
|
603
|
+
|
|
489
604
|
// append-criterion <text> — add a new numbered criterion after the last one
|
|
490
605
|
// in the section.
|
|
491
606
|
export function appendCriterion(text, value) {
|
|
@@ -504,3 +619,23 @@ export function appendCriterion(text, value) {
|
|
|
504
619
|
loc.lines.splice(endOfItem(loc.lines, last.lineIndex) + 1, 0, `${last.indent}${nextN}. ${value}`);
|
|
505
620
|
return { text: loc.lines.join("\n"), summary: `append-criterion ${nextN}` };
|
|
506
621
|
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The size of a spec, as `work --consolidate` reports it (change 0206):
|
|
625
|
+
* lines, real requirement bullets per EARS subsection, and criteria.
|
|
626
|
+
* @param {string} text
|
|
627
|
+
*/
|
|
628
|
+
export function specShape(text) {
|
|
629
|
+
const req = locateRequirements(text);
|
|
630
|
+
const sections = {};
|
|
631
|
+
let requirements = 0;
|
|
632
|
+
for (const token of EARS_ORDER) {
|
|
633
|
+
const sec = req?.sections[token];
|
|
634
|
+
if (!sec) continue;
|
|
635
|
+
const n = sec.bullets.filter((i) => !/^\s*-\s*$/.test(req.lines[i])).length;
|
|
636
|
+
sections[token] = n;
|
|
637
|
+
requirements += n;
|
|
638
|
+
}
|
|
639
|
+
const crit = locateCriteria(text);
|
|
640
|
+
return { lines: text.split(/\r?\n/).length, requirements, sections, criteria: crit ? crit.items.length : 0 };
|
|
641
|
+
}
|
|
@@ -9,7 +9,7 @@ import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "./
|
|
|
9
9
|
import { adapterFiles, isHubPointer, listAdapterNames } from "./adapters.js";
|
|
10
10
|
import { readTemplate } from "./templates.js";
|
|
11
11
|
import { PLAYBOOKS } from "./playbook.js";
|
|
12
|
-
import { COMMAND_META, COMMAND_NAMES, SURFACE_LINE_BUDGET, surfaceBlock, surfaceMarkdown, findSurfaceBlock } from "./commands.js";
|
|
12
|
+
import { COMMAND_META, COMMAND_NAMES, DEPRECATED, REMOVED, SURFACE_LINE_BUDGET, surfaceBlock, surfaceMarkdown, findSurfaceBlock } from "./commands.js";
|
|
13
13
|
import { declaredBudget } from "./runtime.js";
|
|
14
14
|
import { agentsMdBudget } from "./validation-model.js";
|
|
15
15
|
/**
|
|
@@ -80,6 +80,16 @@ export const PRODUCT_SECTIONS = [
|
|
|
80
80
|
"## Success criteria",
|
|
81
81
|
];
|
|
82
82
|
|
|
83
|
+
// The first deprecated or removed operation a text tells you to run, as
|
|
84
|
+
// `doctrina <op>`, or null.
|
|
85
|
+
function retiredCommandIn(text) {
|
|
86
|
+
for (const op of [...Object.keys(DEPRECATED), ...Object.keys(REMOVED)]) {
|
|
87
|
+
const name = op.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
88
|
+
if (new RegExp(`doctrina ${name}(?![a-z-])`).test(text)) return op;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
83
93
|
// The findings `templates check` reports, as structured records with an
|
|
84
94
|
// executable remedy. Exported so a test can seed each finding, run the
|
|
85
95
|
// remedy it names, and assert the finding clears — a remedy that cannot
|
|
@@ -111,16 +121,16 @@ export function collectFindings(projectRoot) {
|
|
|
111
121
|
: ` — the ${missingSections.length} missing stub(s) cost ${budget.cost} lines and ` +
|
|
112
122
|
`AGENTS.md has ${budget.slack} left of its declared ceiling`),
|
|
113
123
|
remedy: budget.fits
|
|
114
|
-
? "doctrina
|
|
115
|
-
: `cut ${budget.over} line(s) of prose from AGENTS.md, then \`doctrina
|
|
124
|
+
? "doctrina upgrade --write"
|
|
125
|
+
: `cut ${budget.over} line(s) of prose from AGENTS.md, then \`doctrina upgrade --write\``,
|
|
116
126
|
});
|
|
117
127
|
}
|
|
118
128
|
// Command-surface block: present and current vs the installed catalog.
|
|
119
129
|
const block = findSurfaceBlock(text);
|
|
120
130
|
if (!block) {
|
|
121
|
-
findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina
|
|
131
|
+
findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina upgrade --write" });
|
|
122
132
|
} else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock())) {
|
|
123
|
-
findings.push({ message: "AGENTS.md doctrina:surface block is stale vs the installed CLI", remedy: "doctrina
|
|
133
|
+
findings.push({ message: "AGENTS.md doctrina:surface block is stale vs the installed CLI", remedy: "doctrina upgrade --write" });
|
|
124
134
|
} else {
|
|
125
135
|
ok.push("AGENTS.md: doctrina:surface block current");
|
|
126
136
|
}
|
|
@@ -134,7 +144,7 @@ export function collectFindings(projectRoot) {
|
|
|
134
144
|
const text = read(productPath);
|
|
135
145
|
for (const heading of PRODUCT_SECTIONS) {
|
|
136
146
|
if (hasHeading(text, heading)) ok.push(`product.md: ${heading}`);
|
|
137
|
-
else findings.push({ message: `.doctrina/product.md missing recommended section "${heading}"`, remedy: "doctrina
|
|
147
|
+
else findings.push({ message: `.doctrina/product.md missing recommended section "${heading}"`, remedy: "doctrina upgrade --write" });
|
|
138
148
|
}
|
|
139
149
|
} else {
|
|
140
150
|
findings.push({ message: ".doctrina/product.md missing", remedy: "doctrina init --force" });
|
|
@@ -151,7 +161,24 @@ export function collectFindings(projectRoot) {
|
|
|
151
161
|
for (const file of spec.files) {
|
|
152
162
|
const installed = path.join(projectRoot, file.relativePath);
|
|
153
163
|
if (!isFile(installed)) continue; // not installed — nothing to check
|
|
154
|
-
if (!isHubPointer(file))
|
|
164
|
+
if (!isHubPointer(file)) {
|
|
165
|
+
// A command shim is copied into the project once, so an upgrade
|
|
166
|
+
// of the CLI leaves it teaching whatever the CLI taught then: the
|
|
167
|
+
// 0.16 `/doctrina-work` still ran `analyze` → `change apply` after
|
|
168
|
+
// 0.17 deprecated one and handed the other to the close (change
|
|
169
|
+
// 0187). A shim naming a retired command is stale; the same rewrite
|
|
170
|
+
// from the template that repairs a pointer repairs it.
|
|
171
|
+
const retired = retiredCommandIn(read(installed));
|
|
172
|
+
if (retired) {
|
|
173
|
+
findings.push({
|
|
174
|
+
message: `adapter ${file.relativePath} still teaches \`doctrina ${retired}\`, which this CLI ${DEPRECATED[retired] ? "deprecated" : "removed"} — the agent running it follows the old flow`,
|
|
175
|
+
remedy: `doctrina adapter add ${name} --force`,
|
|
176
|
+
});
|
|
177
|
+
} else {
|
|
178
|
+
ok.push(`adapter ${file.relativePath}: teaches live commands only`);
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
155
182
|
if (read(installed).includes("AGENTS.md")) {
|
|
156
183
|
ok.push(`adapter ${file.relativePath}: points at AGENTS.md`);
|
|
157
184
|
} else {
|
|
@@ -195,7 +222,7 @@ export function collectFindings(projectRoot) {
|
|
|
195
222
|
const idx = JSON.parse(read(indexPath));
|
|
196
223
|
for (const field of INDEX_FIELDS) {
|
|
197
224
|
if (idx[field] !== undefined) ok.push(`index.json: ${field}`);
|
|
198
|
-
else findings.push({ message: `.doctrina/index.json missing field "${field}"`, remedy: "doctrina
|
|
225
|
+
else findings.push({ message: `.doctrina/index.json missing field "${field}"`, remedy: "doctrina upgrade --write" });
|
|
199
226
|
}
|
|
200
227
|
if (idx.$schema_version && idx.$schema_version !== "0.1.0") {
|
|
201
228
|
findings.push({ message: `.doctrina/index.json $schema_version is "${idx.$schema_version}" (expected "0.1.0")`, remedy: null });
|
|
@@ -16,11 +16,13 @@
|
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { readdirSync } from "node:fs";
|
|
18
18
|
import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "./fs-ops.js";
|
|
19
|
+
import { fold, terms } from "./lexicon.js";
|
|
20
|
+
import { intakeStatusError } from "./intake-model.js";
|
|
19
21
|
import * as idx from "./index-json.js";
|
|
20
22
|
import { SCHEMA_VERSION } from "./index-json.js";
|
|
21
23
|
import { cliVersion, newestVersion } from "./version.js";
|
|
22
24
|
import { today } from "./dates.js";
|
|
23
|
-
import { checklistProgress, kindFromPath, maskComments, nonConformingHeaders, repairHeaders, parseFrontmatter, isPlaceholderHeaderValue } from "./doc-model.js";
|
|
25
|
+
import { checklistProgress, kindFromPath, maskComments, nonConformingHeaders, repairHeaders, parseFrontmatter, isPlaceholderHeaderValue, isScaffoldValue } from "./doc-model.js";
|
|
24
26
|
import { checkEars, isEarsSpec } from "./ears.js";
|
|
25
27
|
import { parseAdrScope, parseSourceGlobs, parseDependsOn, specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "./scan.js";
|
|
26
28
|
import { COMMAND_NAMES, referencedCommands, DEPRECATED } from "./commands.js";
|
|
@@ -144,6 +146,21 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
144
146
|
const productMd = path.join(projectRoot, ".doctrina", "product.md");
|
|
145
147
|
if (!isFile(productMd)) errors.push(".doctrina/product.md missing");
|
|
146
148
|
|
|
149
|
+
// 2b. intake.md's status, when there is one.
|
|
150
|
+
//
|
|
151
|
+
// `next` branches on this value to decide whether the bootstrap is done,
|
|
152
|
+
// and the playbook has the agent write it BY HAND — so it is the one
|
|
153
|
+
// control value in the tree that nothing read back. Anything but the two
|
|
154
|
+
// declared words meant "pending" in silence: "convertido" typed in a
|
|
155
|
+
// Portuguese project, or an empty value left by a botched edit, both left
|
|
156
|
+
// `next` asking forever for a bootstrap that had already happened.
|
|
157
|
+
// A spec's Status gets an enum and an error; so does this one.
|
|
158
|
+
const intakeMd = path.join(projectRoot, ".doctrina", "intake.md");
|
|
159
|
+
if (isFile(intakeMd)) {
|
|
160
|
+
const badStatus = intakeStatusError(read(intakeMd));
|
|
161
|
+
if (badStatus) errors.push(`.doctrina/intake.md: ${badStatus}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
147
164
|
// Header repair (M3). One grammar means a non-canonical header can be
|
|
148
165
|
// REWRITTEN, not just reported: `- **Status**: x` becomes
|
|
149
166
|
// `- **Status:** x`, and a spec written with list-style headers is
|
|
@@ -435,7 +452,7 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
435
452
|
if (ph > 0) {
|
|
436
453
|
warnings.push(
|
|
437
454
|
`open change "${entry}" tasks.md still carries ${ph} scaffold placeholder task${ph === 1 ? "" : "s"} — ` +
|
|
438
|
-
`the change was opened but never planned (replace them with real tasks;
|
|
455
|
+
`the change was opened but never planned (replace them with real tasks; \`change check\` and the close refuse them)`,
|
|
439
456
|
);
|
|
440
457
|
}
|
|
441
458
|
}
|
|
@@ -443,10 +460,15 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
443
460
|
// capabilities a change touches, and a name with no spec behind it
|
|
444
461
|
// — a typo, or a capability that was never created — passed
|
|
445
462
|
// analyze, check and close in silence. A capability this change is
|
|
446
|
-
// about to CREATE (an ADDED delta) is not a ghost.
|
|
463
|
+
// about to CREATE (an ADDED delta) is not a ghost. Neither is the
|
|
464
|
+
// header's own "none": a chore is scaffolded with "(none — chore)",
|
|
465
|
+
// and reading its words as names warned twice on every chore (change
|
|
466
|
+
// 0190). A parenthesised aside is a note, not a capability.
|
|
447
467
|
if (isFile(proposal)) {
|
|
448
|
-
const
|
|
449
|
-
|
|
468
|
+
const header = (listHeader(read(proposal), "Affects specs") ?? "").trim();
|
|
469
|
+
const affects = /^\(?\s*(none|n\/a)\b/i.test(header)
|
|
470
|
+
? []
|
|
471
|
+
: header.replace(/\([^)]*\)/g, "").match(/[a-z][a-z0-9-]*/g) ?? [];
|
|
450
472
|
const specsRoot = path.join(projectRoot, ".doctrina", "specs");
|
|
451
473
|
for (const cap of affects) {
|
|
452
474
|
if (isFile(path.join(specsRoot, cap, "spec.md"))) continue;
|
|
@@ -464,12 +486,12 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
464
486
|
if (!m) {
|
|
465
487
|
warnings.push(
|
|
466
488
|
`${relPath(projectRoot, deltaPath)} has no **Operation:** header — ` +
|
|
467
|
-
|
|
489
|
+
`\`change apply\` and the close will refuse it (add "**Operation:** ADDED|MODIFIED|REMOVED")`,
|
|
468
490
|
);
|
|
469
491
|
} else if (!["ADDED", "MODIFIED", "REMOVED"].includes(m[1])) {
|
|
470
492
|
warnings.push(
|
|
471
493
|
`${relPath(projectRoot, deltaPath)} Operation "${m[1]}" is not ADDED|MODIFIED|REMOVED — ` +
|
|
472
|
-
|
|
494
|
+
`\`change apply\` and the close will refuse it`,
|
|
473
495
|
);
|
|
474
496
|
}
|
|
475
497
|
}
|
|
@@ -487,7 +509,7 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
487
509
|
const specPath = path.join(specsDir, cap, "spec.md");
|
|
488
510
|
if (isFile(specPath)) {
|
|
489
511
|
const lines = lineCount(specPath);
|
|
490
|
-
if (lines > 400) warnings.push(`${relPath(projectRoot, specPath)} is ${lines} lines (>400 soft cap)
|
|
512
|
+
if (lines > 400) warnings.push(`${relPath(projectRoot, specPath)} is ${lines} lines (>400 soft cap) — consolidate it: \`doctrina work --consolidate ${path.basename(path.dirname(specPath))}\``);
|
|
491
513
|
|
|
492
514
|
// 8b. EARS grammar shape — section-appropriate When/While/Where/shall.
|
|
493
515
|
const text = read(specPath);
|
|
@@ -732,10 +754,25 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
732
754
|
const productPath = path.join(projectRoot, ".doctrina", "product.md");
|
|
733
755
|
if (isFile(productPath)) {
|
|
734
756
|
const seen = new Map();
|
|
757
|
+
// The mirror case: ONE intent under TWO ids. Realize either and the
|
|
758
|
+
// other stays "dropped" forever, so `trace --strict` — a CI gate —
|
|
759
|
+
// fails on a gap nobody can close. Compared folded, so case, accents
|
|
760
|
+
// and spacing do not hide a twin.
|
|
761
|
+
const seenText = new Map();
|
|
735
762
|
const lines = read(productPath).split(/\r?\n/);
|
|
736
763
|
for (let i = 0; i < lines.length; i++) {
|
|
737
|
-
const m = lines[i].match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s
|
|
764
|
+
const m = lines[i].match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s+(.*)$/);
|
|
738
765
|
if (!m) continue;
|
|
766
|
+
const key = intentKey(m[2]);
|
|
767
|
+
if (key && seenText.has(key) && seenText.get(key).id !== m[1]) {
|
|
768
|
+
const first = seenText.get(key);
|
|
769
|
+
warnings.push(
|
|
770
|
+
`.doctrina/product.md:${i + 1} intent [${m[1]}] states the same intent as [${first.id}] (line ${first.line}) — ` +
|
|
771
|
+
`realizing one leaves the other dropped, so \`trace --strict\` fails on a gap nobody can close; delete the twin`,
|
|
772
|
+
);
|
|
773
|
+
} else if (key && !seenText.has(key)) {
|
|
774
|
+
seenText.set(key, { id: m[1], line: i + 1 });
|
|
775
|
+
}
|
|
739
776
|
if (seen.has(m[1])) {
|
|
740
777
|
errors.push(
|
|
741
778
|
`.doctrina/product.md:${i + 1} intent anchor [${m[1]}] is declared twice (first at line ${seen.get(m[1])}) — ` +
|
|
@@ -972,11 +1009,11 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
972
1009
|
else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft cap)`);
|
|
973
1010
|
|
|
974
1011
|
// 15. Skill description drift — index.json must mirror the
|
|
975
|
-
// frontmatter description (
|
|
1012
|
+
// frontmatter description (`doctrina index rebuild` does).
|
|
976
1013
|
if (descField && index?.artifacts?.skills) {
|
|
977
1014
|
const entry = index.artifacts.skills.find((s) => s.id === baseName);
|
|
978
1015
|
if (entry && entry.description !== descField) {
|
|
979
|
-
warnings.push(`${rel} description differs from index.json (run \`doctrina
|
|
1016
|
+
warnings.push(`${rel} description differs from index.json (run \`doctrina index rebuild\`)`);
|
|
980
1017
|
}
|
|
981
1018
|
}
|
|
982
1019
|
}
|
|
@@ -1012,7 +1049,9 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
1012
1049
|
// rule is a forbid-regex over glob-scoped paths; a match is an ERROR with
|
|
1013
1050
|
// the rule's own message. Deterministic, zero-deps, and the instruction
|
|
1014
1051
|
// survives sessions because it is an artifact, not a memory.
|
|
1015
|
-
|
|
1052
|
+
const ruleFindings = checkProjectRules(projectRoot);
|
|
1053
|
+
for (const line of ruleFindings.errors) errors.push(line);
|
|
1054
|
+
for (const line of ruleFindings.warnings) warnings.push(line);
|
|
1016
1055
|
for (const key of loadConfig(projectRoot).unknown) {
|
|
1017
1056
|
warnings.push(`${CONFIG_REL}: unknown key "${key}" is ignored — the keys are language, context_budget, rules (a typo here is a setting that never applied)`);
|
|
1018
1057
|
}
|
|
@@ -1043,27 +1082,45 @@ export function collectValidation(projectRoot, { fix = false, runtime = false }
|
|
|
1043
1082
|
// something concrete — a path or glob, a command, a quoted error string, a
|
|
1044
1083
|
// file extension, an ALL_CAPS identifier, or simply enough distinctive
|
|
1045
1084
|
// keywords to match on.
|
|
1046
|
-
|
|
1085
|
+
// One list per language, because this framework runs on Portuguese projects
|
|
1086
|
+
// too — and the vagueness it exists to catch is written in the project's own
|
|
1087
|
+
// language. Change 0158 found the same split in `clarify`'s lexicons; this is
|
|
1088
|
+
// the same asymmetry one module over. Matched against the FOLDED text, so
|
|
1089
|
+
// "necessário" and "necessario" are one phrase.
|
|
1090
|
+
const VAGUE_TRIGGER_EN = /^(?:when(?:ever)?\s+)?(?:it|this|you|the agent)?\s*(?:is\s+)?(?:seems?|feels?|looks?|makes?)?\s*(?:relevant|appropriate|needed|necessary|useful|applicable|sense|right|as needed|if needed)\.?$/i;
|
|
1091
|
+
const VAGUE_TRIGGER_PT =
|
|
1092
|
+
/^(?:(?:sempre\s+)?que|quando|se)?\s*(?:isso|isto|voce|o agente)?\s*(?:for|foi|parecer|fizer|achar|julgar)?\s*(?:relevante|apropriad[oa]|necessari[oa]|util|aplicavel|preciso|sentido|cert[oa])\.?$/i;
|
|
1047
1093
|
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1094
|
+
function looksVague(text) {
|
|
1095
|
+
const folded = fold(text).trim();
|
|
1096
|
+
if (folded === "") return true;
|
|
1097
|
+
return VAGUE_TRIGGER_EN.test(folded) || VAGUE_TRIGGER_PT.test(folded);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Owned by the document model since change 0177 (the rebuild needs it and
|
|
1101
|
+
// cannot import this module); re-exported so existing callers stay put.
|
|
1102
|
+
export { isScaffoldValue };
|
|
1103
|
+
|
|
1104
|
+
/** An intent's text, folded for comparison: case, accents and spacing ignored. */
|
|
1105
|
+
export function intentKey(text) {
|
|
1106
|
+
return fold(String(text ?? "")).replace(/\s+/g, " ").replace(/[.;:!\s]+$/, "").trim();
|
|
1051
1107
|
}
|
|
1052
1108
|
|
|
1053
1109
|
export function hasDetectableTrigger(when) {
|
|
1054
1110
|
const text = String(when ?? "").trim();
|
|
1055
|
-
if (
|
|
1111
|
+
if (looksVague(text)) return false;
|
|
1056
1112
|
// The scaffold's own placeholder has enough words to rank on and names
|
|
1057
1113
|
// nothing (change 0111): a trigger nobody wrote fires for nobody.
|
|
1058
1114
|
if (isScaffoldValue(text)) return false;
|
|
1059
1115
|
// Anything structural is inherently matchable.
|
|
1060
|
-
if (/[\/\]|\*|`|"|'|\.\w{2,4}
|
|
1061
|
-
// Otherwise: enough distinctive words to rank on
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1116
|
+
if (/[\/\]|\*|`|"|'|\.\w{2,4}|[A-Z][A-Z0-9_]{2,}/.test(text)) return true;
|
|
1117
|
+
// Otherwise: enough distinctive words to rank on — counted by the SHARED
|
|
1118
|
+
// lexicon, which folds and carries the connective tissue of both languages.
|
|
1119
|
+
// The private stop list here was English-only, so every Portuguese function
|
|
1120
|
+
// word ("quando", "que", "para") counted as distinctive and a two-word
|
|
1121
|
+
// Portuguese phrase cleared the bar on grammar alone. One lexicon, the way
|
|
1122
|
+
// `work` and `context` already read text (audit findings F5/F6).
|
|
1123
|
+
return terms(text).length >= 2;
|
|
1067
1124
|
}
|
|
1068
1125
|
|
|
1069
1126
|
// Enforce the project's rules — permanent constraints as forbid-regexes over
|
|
@@ -1073,9 +1130,16 @@ export function hasDetectableTrigger(when) {
|
|
|
1073
1130
|
// "message": "white-label product; use a generic placeholder" } ] }
|
|
1074
1131
|
// Declared in .doctrina/config.json; still read from the legacy
|
|
1075
1132
|
// .doctrina/rules.json when that is where a project put them (change 0047).
|
|
1076
|
-
// Returns
|
|
1077
|
-
// mass violation stays readable)
|
|
1078
|
-
// file
|
|
1133
|
+
// Returns { errors, warnings }: an error per offending file+rule (capped per
|
|
1134
|
+
// rule so a mass violation stays readable), and a warning for a rule that
|
|
1135
|
+
// reached no file at all. No rules → nothing to enforce; a malformed file is
|
|
1136
|
+
// reported once, by the reader.
|
|
1137
|
+
//
|
|
1138
|
+
// The dead-scope warning is the same finding 8h makes about a `**Source:**`
|
|
1139
|
+
// glob and RT05 makes about a selector: a declaration that matches nothing
|
|
1140
|
+
// reads as coverage and provides none. A rule is the strongest of the three
|
|
1141
|
+
// — a permanent constraint the project believes is enforced — and it was the
|
|
1142
|
+
// one that said nothing, while `doctor` went on counting it as configured. Binary-ish and vendored dirs are
|
|
1079
1143
|
// skipped by the same bounded walk validate already uses elsewhere.
|
|
1080
1144
|
const RULES_SKIP_DIRS = new Set([
|
|
1081
1145
|
".git", "node_modules", "vendor", "dist", "build", "out", "target",
|
|
@@ -1086,6 +1150,7 @@ const RULES_MAX_HITS_PER_RULE = 10;
|
|
|
1086
1150
|
function checkProjectRules(projectRoot) {
|
|
1087
1151
|
const cfg = loadConfig(projectRoot);
|
|
1088
1152
|
const out = [...cfg.errors];
|
|
1153
|
+
const warn = [];
|
|
1089
1154
|
const where = cfg.sources.rules === SOURCES.config ? CONFIG_REL : RULES_REL;
|
|
1090
1155
|
const compiled = [];
|
|
1091
1156
|
for (const r of cfg.rules) {
|
|
@@ -1100,10 +1165,16 @@ function checkProjectRules(projectRoot) {
|
|
|
1100
1165
|
out.push(`${where}: rule "${r.id ?? r.forbid}" has an invalid regex: ${err.message}`);
|
|
1101
1166
|
continue;
|
|
1102
1167
|
}
|
|
1103
|
-
const
|
|
1104
|
-
|
|
1168
|
+
const declared = Array.isArray(r.paths) && r.paths.length ? r.paths : null;
|
|
1169
|
+
const paths = declared ?? ["**"];
|
|
1170
|
+
compiled.push({
|
|
1171
|
+
id: r.id ?? r.forbid, re, matchers: paths.map(globToRegExp),
|
|
1172
|
+
message: r.message ?? "", hits: 0,
|
|
1173
|
+
// Only a DECLARED scope can be dead; the implicit `**` is not a claim.
|
|
1174
|
+
declared, scanned: 0,
|
|
1175
|
+
});
|
|
1105
1176
|
}
|
|
1106
|
-
if (compiled.length === 0) return out;
|
|
1177
|
+
if (compiled.length === 0) return { errors: out, warnings: warn };
|
|
1107
1178
|
|
|
1108
1179
|
// One bounded walk; each text file is read at most once.
|
|
1109
1180
|
const stack = [projectRoot];
|
|
@@ -1123,7 +1194,11 @@ function checkProjectRules(projectRoot) {
|
|
|
1123
1194
|
}
|
|
1124
1195
|
const rel = relPath(projectRoot, full).replace(/\\/g, "/");
|
|
1125
1196
|
if (rel === ".doctrina/rules.json") continue; // the rule text itself always matches
|
|
1126
|
-
|
|
1197
|
+
// Count the reach BEFORE the hit cap: a rule that stopped reporting
|
|
1198
|
+
// because it hit the cap has plainly reached files.
|
|
1199
|
+
const inScope = compiled.filter((cr) => cr.matchers.some((m) => m.test(rel)));
|
|
1200
|
+
for (const cr of inScope) cr.scanned += 1;
|
|
1201
|
+
const applicable = inScope.filter((cr) => cr.hits < RULES_MAX_HITS_PER_RULE);
|
|
1127
1202
|
if (applicable.length === 0) continue;
|
|
1128
1203
|
let text;
|
|
1129
1204
|
try {
|
|
@@ -1145,8 +1220,15 @@ function checkProjectRules(projectRoot) {
|
|
|
1145
1220
|
if (cr.hits >= RULES_MAX_HITS_PER_RULE) {
|
|
1146
1221
|
out.push(`rule "${cr.id}": more matches suppressed after ${RULES_MAX_HITS_PER_RULE} files`);
|
|
1147
1222
|
}
|
|
1223
|
+
if (cr.declared && cr.scanned === 0) {
|
|
1224
|
+
warn.push(
|
|
1225
|
+
`${where}: rule "${cr.id}" scopes to ${cr.declared.map((g) => `\`${g}\``).join(", ")}, ` +
|
|
1226
|
+
"which matches no file — the constraint is declared and never enforced; " +
|
|
1227
|
+
"correct the paths or drop the rule",
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1148
1230
|
}
|
|
1149
|
-
return out;
|
|
1231
|
+
return { errors: out, warnings: warn };
|
|
1150
1232
|
}
|
|
1151
1233
|
|
|
1152
1234
|
// Minimal glob → RegExp: ** crosses directories, * stays within a segment.
|
|
@@ -1168,9 +1250,9 @@ function globToRegExp(glob) {
|
|
|
1168
1250
|
// artifact whose recorded metadata no longer matches its file.
|
|
1169
1251
|
function metadataDrift(current, derived) {
|
|
1170
1252
|
const lines = [];
|
|
1171
|
-
// Skills are deliberately excluded: their description
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1253
|
+
// Skills are deliberately excluded: their description is reconciled by
|
|
1254
|
+
// `doctrina index rebuild` and stays an advisory warning (see section
|
|
1255
|
+
// 15), not a hard error.
|
|
1174
1256
|
const cats = ["specs", "decisions", "changes", "changes_archive", "contracts"];
|
|
1175
1257
|
for (const cat of cats) {
|
|
1176
1258
|
const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|