doctrina-cli 0.15.0 → 0.16.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 +1 -1
- package/package.json +1 -1
- package/src/commands/adapter.js +24 -1
- package/src/commands/analyze.js +8 -199
- package/src/commands/change.js +143 -217
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +58 -21
- package/src/commands/close.js +291 -42
- package/src/commands/constitution.js +14 -91
- package/src/commands/context.js +278 -107
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +54 -7
- package/src/commands/doctor.js +292 -129
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +11 -2
- package/src/commands/index-rebuild.js +9 -48
- package/src/commands/init.js +70 -13
- package/src/commands/intake.js +30 -92
- package/src/commands/metrics.js +53 -84
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +24 -91
- package/src/commands/report.js +50 -108
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +16 -25
- package/src/commands/spec.js +26 -5
- package/src/commands/status.js +51 -172
- package/src/commands/templates.js +31 -138
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +7 -75
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +121 -160
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +234 -263
- package/src/index.js +106 -7
- package/src/lib/actions.js +484 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/change-ops.js +131 -0
- package/src/lib/commands.js +109 -6
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +392 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +326 -0
- package/src/lib/docs-impact.js +196 -27
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +210 -24
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +2 -19
- package/src/lib/intake-model.js +86 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +191 -0
- package/src/lib/metrics-model.js +159 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +57 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +238 -11
- package/src/lib/scan.js +204 -13
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +209 -0
- package/src/lib/spec-ops.js +50 -5
- package/src/lib/templates-model.js +262 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1299 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +410 -0
- package/src/lib/work-model.js +77 -0
- package/templates/AGENTS.md.template +20 -26
- package/templates/README.md +35 -0
- package/templates/change/proposal.md.template +1 -0
- package/templates/contract.md.template +7 -0
- package/templates/hooks/pre-commit.sample +6 -2
- package/templates/playbooks/bootstrap.md.template +43 -0
- package/templates/playbooks/chore.md.template +19 -0
- package/templates/playbooks/work.md.template +76 -0
package/src/lib/spec-ops.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
import { commentRanges, isInsideComment } from "./doc-model.js";
|
|
2
3
|
// Structured spec operations (ADR 0007 / review F3). A MODIFIED spec delta
|
|
3
4
|
// historically carried prose ("flip criterion 2 to verified", "bump to
|
|
4
5
|
// 0.3.0") that a human merged by hand — the single most error-prone step in
|
|
@@ -39,6 +40,40 @@
|
|
|
39
40
|
const HEADER_RE = (name) =>
|
|
40
41
|
new RegExp(`^(\\s*(?:-\\s+)?\\*\\*${escapeRe(name)}:\\*\\*)\\s*(.*)$`, "m");
|
|
41
42
|
|
|
43
|
+
// The ENUMS behind the two machine-read headers and the criterion mark
|
|
44
|
+
// (change 0102). `--version` and `--bump` always validated their input;
|
|
45
|
+
// `--status`, `--implementation` and `--criterion` wrote whatever string they
|
|
46
|
+
// were given, synced it into the index, and `validate` passed it — so a spec
|
|
47
|
+
// could say `Status: bogus` and `prime` would report "1 banana" without
|
|
48
|
+
// finding anything odd. A header every gate branches on has a domain, and
|
|
49
|
+
// this is it. A note after the state word stays legal ("planned — deferred,
|
|
50
|
+
// see ADR 0007"): only the WORD is checked.
|
|
51
|
+
export const STATUS_VALUES = ["draft", "active", "deprecated"];
|
|
52
|
+
export const IMPLEMENTATION_VALUES = ["planned", "partial", "implemented", "verified"];
|
|
53
|
+
export const CRITERION_MARKS = ["verified", "unverified", "orchestration"];
|
|
54
|
+
|
|
55
|
+
/** The state word of a header/mark value: the first token, note stripped. */
|
|
56
|
+
export function stateWord(value) {
|
|
57
|
+
const first = String(value ?? "").trim().split(/\s+/)[0] ?? "";
|
|
58
|
+
return first.replace(/[—:-]+$/, "").toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** null when `value` is a legal value for `name`; otherwise the error text. */
|
|
62
|
+
export function headerValueError(name, value) {
|
|
63
|
+
const allowed = name === "Status" ? STATUS_VALUES : name === "Implementation" ? IMPLEMENTATION_VALUES : null;
|
|
64
|
+
if (!allowed) return null;
|
|
65
|
+
const word = stateWord(value);
|
|
66
|
+
if (allowed.includes(word)) return null;
|
|
67
|
+
return `${name} must be one of ${allowed.join("|")} (got "${String(value).trim()}") — a note may follow the value, the value itself does not change`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** null when `mark` is a legal criterion mark; otherwise the error text. */
|
|
71
|
+
export function criterionMarkError(mark) {
|
|
72
|
+
const word = stateWord(mark);
|
|
73
|
+
if (CRITERION_MARKS.includes(word)) return null;
|
|
74
|
+
return `a criterion mark must be one of ${CRITERION_MARKS.join("|")} (got "${String(mark).trim()}")`;
|
|
75
|
+
}
|
|
76
|
+
|
|
42
77
|
function escapeRe(s) {
|
|
43
78
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
44
79
|
}
|
|
@@ -71,11 +106,8 @@ export function extractOps(deltaText) {
|
|
|
71
106
|
// contain a comment (an `<!-- illustrative -->` marker inside an
|
|
72
107
|
// append-criterion landed in the spec with the marker silently gutted).
|
|
73
108
|
function matchOpsBlock(text) {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
commentRanges.push([m.index, m.index + m[0].length]);
|
|
77
|
-
}
|
|
78
|
-
const insideComment = (offset) => commentRanges.some(([a, b]) => offset >= a && offset < b);
|
|
109
|
+
const ranges = commentRanges(text);
|
|
110
|
+
const insideComment = (offset) => isInsideComment(ranges, offset);
|
|
79
111
|
|
|
80
112
|
const fenceRe = /^[ \t]*```[ \t]*ops[ \t]*\r?\n([\s\S]*?)^[ \t]*```[ \t]*$/gm;
|
|
81
113
|
let m;
|
|
@@ -219,6 +251,12 @@ export function setHeader(text, name, value) {
|
|
|
219
251
|
if (!re.test(text)) {
|
|
220
252
|
return { error: `set-header: no "**${name}:**" header in the spec to set` };
|
|
221
253
|
}
|
|
254
|
+
// The domain is the SPEC's (bare bold header). The list form
|
|
255
|
+
// (`- **Status:** proposed`) is a proposal's or an ADR's, whose Status
|
|
256
|
+
// has a lifecycle of its own (proposed -> applied, accepted, superseded).
|
|
257
|
+
const listForm = /^\s*-/.test(text.match(re)?.[1] ?? "");
|
|
258
|
+
const domain = listForm ? null : headerValueError(name, value);
|
|
259
|
+
if (domain) return { error: `set-header: ${domain}` };
|
|
222
260
|
const next = text.replace(re, (_m, prefix) => `${prefix} ${value}`.replace(/\s+$/, ""));
|
|
223
261
|
return { text: next, summary: `set ${name}: ${value}` };
|
|
224
262
|
}
|
|
@@ -273,6 +311,8 @@ export function setCriterionMark(text, n, markRaw) {
|
|
|
273
311
|
const item = loc.items.find((it) => it.n === n);
|
|
274
312
|
if (!item) return { error: `set-criterion: no criterion #${n} found` };
|
|
275
313
|
const mark = markRaw.replace(/^\[|\]$/g, "").trim();
|
|
314
|
+
const domain = criterionMarkError(mark);
|
|
315
|
+
if (domain) return { error: `set-criterion: ${domain}` };
|
|
276
316
|
const line = loc.lines[item.lineIndex];
|
|
277
317
|
let next;
|
|
278
318
|
if (/\[[^\]]*\]/.test(line)) {
|
|
@@ -454,6 +494,11 @@ export function appendCriterion(text, value) {
|
|
|
454
494
|
if (loc.items.length === 0) {
|
|
455
495
|
return { error: "append-criterion: no existing numbered criterion to append after" };
|
|
456
496
|
}
|
|
497
|
+
const leadingMark = String(value).match(/^\s*\[([^\]]*)\]/);
|
|
498
|
+
if (leadingMark) {
|
|
499
|
+
const domain = criterionMarkError(leadingMark[1]);
|
|
500
|
+
if (domain) return { error: `append-criterion: ${domain}` };
|
|
501
|
+
}
|
|
457
502
|
const last = loc.items[loc.items.length - 1];
|
|
458
503
|
const nextN = Math.max(...loc.items.map((it) => it.n)) + 1;
|
|
459
504
|
loc.lines.splice(endOfItem(loc.lines, last.lineIndex) + 1, 0, `${last.indent}${nextN}. ${value}`);
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The template-shape FINDINGS.
|
|
3
|
+
//
|
|
4
|
+
// `doctor` reports these as one of its rows and used to import them out of
|
|
5
|
+
// `commands/templates.js` (audit finding F7). Collecting is not rendering, so
|
|
6
|
+
// the collection lives here and both surfaces read it.
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "./fs-ops.js";
|
|
9
|
+
import { adapterFiles, isHubPointer, listAdapterNames } from "./adapters.js";
|
|
10
|
+
import { readTemplate } from "./templates.js";
|
|
11
|
+
import { PLAYBOOKS } from "./playbook.js";
|
|
12
|
+
import { COMMAND_META, COMMAND_NAMES, SURFACE_LINE_BUDGET, surfaceBlock, surfaceMarkdown, findSurfaceBlock } from "./commands.js";
|
|
13
|
+
import { declaredBudget } from "./runtime.js";
|
|
14
|
+
import { agentsMdBudget } from "./validation-model.js";
|
|
15
|
+
/**
|
|
16
|
+
* The surface-block budget: how many lines the generated block spends, the
|
|
17
|
+
* ceiling it spends them against, and what is left.
|
|
18
|
+
*
|
|
19
|
+
* This number is COUPLED to `agents-md-lines`: the block is written into
|
|
20
|
+
* AGENTS.md, so one command added to the catalog spends a line of both.
|
|
21
|
+
* Every caller that reports on it reads it HERE, so `templates check` and
|
|
22
|
+
* `doctor` can never quote two different sizes for one block — the second
|
|
23
|
+
* count is how the two AGENTS.md numbers drifted before change 0059, and
|
|
24
|
+
* this budget had no owner at all until 0072.
|
|
25
|
+
*
|
|
26
|
+
* @param {string|null} [projectRoot] The project whose contract may declare
|
|
27
|
+
* the ceiling; omit to measure against the shipped default alone.
|
|
28
|
+
* @returns {{ used: number, budget: number, slack: number, declared: boolean }}
|
|
29
|
+
*/
|
|
30
|
+
export function surfaceBudget(projectRoot = null) {
|
|
31
|
+
const used = surfaceMarkdown().split("\n").length;
|
|
32
|
+
const { value: budget, declared } = projectRoot
|
|
33
|
+
? declaredBudget(projectRoot, "surface-block-lines", SURFACE_LINE_BUDGET)
|
|
34
|
+
: { value: SURFACE_LINE_BUDGET, declared: false };
|
|
35
|
+
return { used, budget, slack: budget - used, declared };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// What one appended stub section costs AGENTS.md: a blank line, the heading,
|
|
39
|
+
// a blank line and the fill-me comment. Kept beside the writer that emits
|
|
40
|
+
// that shape (`templates update --write`) so the estimate and the write
|
|
41
|
+
// cannot drift.
|
|
42
|
+
export const STUB_SECTION_LINES = 4;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What appending stub sections to AGENTS.md would cost, against what the
|
|
46
|
+
* file has left of its declared ceiling.
|
|
47
|
+
*
|
|
48
|
+
* `templates check` recommends the sections and `agents-md-lines` caps the
|
|
49
|
+
* file, and the two never spoke: following the recommendation on this very
|
|
50
|
+
* repository took AGENTS.md from 147 to 155 lines, past a ceiling `analyze`
|
|
51
|
+
* refuses to raise because the budget is declared OUTPUT. A recommendation
|
|
52
|
+
* that cannot say what it costs is a recommendation that sends you into
|
|
53
|
+
* another gate's refusal (change 0078).
|
|
54
|
+
*
|
|
55
|
+
* @param {string} projectRoot
|
|
56
|
+
* @param {string[]} missing The recommended headings AGENTS.md does not have.
|
|
57
|
+
* @returns {{ cost: number, slack: number, fits: boolean, over: number }}
|
|
58
|
+
*/
|
|
59
|
+
export function agentsSectionCost(projectRoot, missing) {
|
|
60
|
+
const cost = missing.length * STUB_SECTION_LINES;
|
|
61
|
+
const { slack } = agentsMdBudget(projectRoot);
|
|
62
|
+
return { cost, slack, fits: cost <= slack, over: Math.max(0, cost - slack) };
|
|
63
|
+
}
|
|
64
|
+
// Recommended sections per file kind. Adopters whose files lack these
|
|
65
|
+
// headings get a warning from `templates check`; they are recommendations,
|
|
66
|
+
// not hard requirements (validate handles the hard requirements).
|
|
67
|
+
export const AGENTS_SECTIONS = [
|
|
68
|
+
"## Stack",
|
|
69
|
+
"## Commands",
|
|
70
|
+
"## Repository structure",
|
|
71
|
+
"## Conventions and boundaries",
|
|
72
|
+
"## How to read context",
|
|
73
|
+
];
|
|
74
|
+
export const INDEX_FIELDS = ["$schema_version", "project", "artifacts"];
|
|
75
|
+
export const PRODUCT_SECTIONS = [
|
|
76
|
+
"## Vision",
|
|
77
|
+
"## Problem",
|
|
78
|
+
"## Target users",
|
|
79
|
+
"## Scope",
|
|
80
|
+
"## Success criteria",
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
// The findings `templates check` reports, as structured records with an
|
|
84
|
+
// executable remedy. Exported so a test can seed each finding, run the
|
|
85
|
+
// remedy it names, and assert the finding clears — a remedy that cannot
|
|
86
|
+
// resolve its own finding is not a remedy (C2).
|
|
87
|
+
export function collectFindings(projectRoot) {
|
|
88
|
+
const findings = [];
|
|
89
|
+
const ok = [];
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
// AGENTS.md sections
|
|
93
|
+
const agentsPath = path.join(projectRoot, "AGENTS.md");
|
|
94
|
+
if (isFile(agentsPath)) {
|
|
95
|
+
const text = read(agentsPath);
|
|
96
|
+
const missingSections = AGENTS_SECTIONS.filter((h) => !hasHeading(text, h));
|
|
97
|
+
const budget = agentsSectionCost(projectRoot, missingSections);
|
|
98
|
+
for (const heading of AGENTS_SECTIONS) {
|
|
99
|
+
if (hasHeading(text, heading)) {
|
|
100
|
+
ok.push(`AGENTS.md: ${heading}`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// The recommendation names its price. When the stubs do not fit, the
|
|
104
|
+
// remedy names the CUT first — appending them anyway would clear this
|
|
105
|
+
// finding by breaching a ceiling `analyze` refuses to raise, which is
|
|
106
|
+
// one gate sending you into another's refusal.
|
|
107
|
+
findings.push({
|
|
108
|
+
message: `AGENTS.md missing recommended section "${heading}"` +
|
|
109
|
+
(budget.fits
|
|
110
|
+
? ``
|
|
111
|
+
: ` — the ${missingSections.length} missing stub(s) cost ${budget.cost} lines and ` +
|
|
112
|
+
`AGENTS.md has ${budget.slack} left of its declared ceiling`),
|
|
113
|
+
remedy: budget.fits
|
|
114
|
+
? "doctrina templates update --write"
|
|
115
|
+
: `cut ${budget.over} line(s) of prose from AGENTS.md, then \`doctrina templates update --write\``,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// Command-surface block: present and current vs the installed catalog.
|
|
119
|
+
const block = findSurfaceBlock(text);
|
|
120
|
+
if (!block) {
|
|
121
|
+
findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina templates update --write" });
|
|
122
|
+
} 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 templates update --write" });
|
|
124
|
+
} else {
|
|
125
|
+
ok.push("AGENTS.md: doctrina:surface block current");
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
findings.push({ message: "AGENTS.md missing at project root", remedy: "doctrina init --force" });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// product.md sections
|
|
132
|
+
const productPath = path.join(projectRoot, ".doctrina", "product.md");
|
|
133
|
+
if (isFile(productPath)) {
|
|
134
|
+
const text = read(productPath);
|
|
135
|
+
for (const heading of PRODUCT_SECTIONS) {
|
|
136
|
+
if (hasHeading(text, heading)) ok.push(`product.md: ${heading}`);
|
|
137
|
+
else findings.push({ message: `.doctrina/product.md missing recommended section "${heading}"`, remedy: "doctrina templates update --write" });
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
findings.push({ message: ".doctrina/product.md missing", remedy: "doctrina init --force" });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Installed agent adapters: a HUB POINTER file must still reference
|
|
144
|
+
// AGENTS.md (that is why one `upgrade --write` refresh of the hub reaches
|
|
145
|
+
// every installed agent). Only files whose template declares the
|
|
146
|
+
// {{AGENTS_MD_PATH}} token are pointers; a slash-command shim reaches the
|
|
147
|
+
// hub through its parent pointer file and is not checked (C2).
|
|
148
|
+
for (const name of listAdapterNames(projectRoot)) {
|
|
149
|
+
const spec = adapterFiles(projectRoot, name);
|
|
150
|
+
if (!spec) continue;
|
|
151
|
+
for (const file of spec.files) {
|
|
152
|
+
const installed = path.join(projectRoot, file.relativePath);
|
|
153
|
+
if (!isFile(installed)) continue; // not installed — nothing to check
|
|
154
|
+
if (!isHubPointer(file)) continue; // command shim, not a pointer
|
|
155
|
+
if (read(installed).includes("AGENTS.md")) {
|
|
156
|
+
ok.push(`adapter ${file.relativePath}: points at AGENTS.md`);
|
|
157
|
+
} else {
|
|
158
|
+
findings.push({
|
|
159
|
+
message: `adapter ${file.relativePath} no longer references AGENTS.md — agents loading it will miss the hub`,
|
|
160
|
+
// Executable and verified: `adapter add --force` rewrites exactly
|
|
161
|
+
// this file from its template. A remedy that cannot resolve the
|
|
162
|
+
// finding it is attached to is not a remedy (C2).
|
|
163
|
+
remedy: `doctrina adapter add ${name} --force`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// M2: a command that cannot state its trigger has not earned a place on
|
|
170
|
+
// the surface. The block is an agent's only discovery surface, so both
|
|
171
|
+
// fields are required and the block has a declared size budget.
|
|
172
|
+
for (const name of COMMAND_NAMES) {
|
|
173
|
+
const meta = COMMAND_META[name];
|
|
174
|
+
if (!meta || !meta.purpose || !meta.when) {
|
|
175
|
+
findings.push({
|
|
176
|
+
message: `command "${name}" declares no ${!meta ? "purpose or when" : (!meta.purpose ? "purpose" : "when")} — the surface block cannot say when to reach for it`,
|
|
177
|
+
remedy: null,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const surface = surfaceBudget(projectRoot);
|
|
182
|
+
if (surface.used > surface.budget) {
|
|
183
|
+
findings.push({
|
|
184
|
+
message: `the generated surface block is ${surface.used} lines (budget ${surface.budget}) — cut commands rather than raising the budget`,
|
|
185
|
+
remedy: null,
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
ok.push(`surface block within budget (${surface.used}/${surface.budget} lines)`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// index.json schema fields
|
|
192
|
+
const indexPath = path.join(projectRoot, ".doctrina", "index.json");
|
|
193
|
+
if (isFile(indexPath)) {
|
|
194
|
+
try {
|
|
195
|
+
const idx = JSON.parse(read(indexPath));
|
|
196
|
+
for (const field of INDEX_FIELDS) {
|
|
197
|
+
if (idx[field] !== undefined) ok.push(`index.json: ${field}`);
|
|
198
|
+
else findings.push({ message: `.doctrina/index.json missing field "${field}"`, remedy: "doctrina templates update --write" });
|
|
199
|
+
}
|
|
200
|
+
if (idx.$schema_version && idx.$schema_version !== "0.1.0") {
|
|
201
|
+
findings.push({ message: `.doctrina/index.json $schema_version is "${idx.$schema_version}" (expected "0.1.0")`, remedy: null });
|
|
202
|
+
}
|
|
203
|
+
} catch (err) {
|
|
204
|
+
findings.push({ message: `.doctrina/index.json failed to parse: ${err.message}`, remedy: null });
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
findings.push({ message: ".doctrina/index.json missing", remedy: "doctrina index rebuild" });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Output
|
|
211
|
+
// The playbooks (change 0038). A playbook is the procedure the agent
|
|
212
|
+
// actually executes, and since it became a template an adopter can replace
|
|
213
|
+
// it — which means it can also be MISSING or malformed, and a playbook that
|
|
214
|
+
// does not render is a broken session, not a cosmetic gap. Each is checked
|
|
215
|
+
// for resolution and for the shape every playbook must have: a title line
|
|
216
|
+
// and the ordered steps.
|
|
217
|
+
for (const name of PLAYBOOKS) {
|
|
218
|
+
const rel = `playbooks/${name}.md.template`;
|
|
219
|
+
let body = null;
|
|
220
|
+
try {
|
|
221
|
+
body = readTemplate(projectRoot, rel).body;
|
|
222
|
+
} catch {
|
|
223
|
+
body = null;
|
|
224
|
+
}
|
|
225
|
+
if (body === null) {
|
|
226
|
+
findings.push({
|
|
227
|
+
message: `playbook "${name}" does not resolve (${rel} is missing from the project AND the installed CLI)`,
|
|
228
|
+
remedy: "reinstall doctrina-cli, or restore .doctrina/templates/" + rel,
|
|
229
|
+
});
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const problems = [];
|
|
233
|
+
if (body.trim() === "") problems.push("it is empty");
|
|
234
|
+
if (!/^\s*1\./m.test(body)) problems.push("it has no numbered first step");
|
|
235
|
+
for (const [open, close] of [["[[c]]", "[[/c]]"], ["[[g]]", "[[/g]]"], ["[[b]]", "[[/b]]"], ["[[y]]", "[[/y]]"]]) {
|
|
236
|
+
const opens = body.split(open).length - 1;
|
|
237
|
+
const closes = body.split(close).length - 1;
|
|
238
|
+
if (opens !== closes) problems.push(`${opens} ${open} spans but ${closes} ${close}`);
|
|
239
|
+
}
|
|
240
|
+
if (problems.length > 0) {
|
|
241
|
+
findings.push({
|
|
242
|
+
message: `playbook "${name}" is malformed: ${problems.join("; ")}`,
|
|
243
|
+
remedy: `fix .doctrina/templates/${rel}, or delete it to fall back to the bundled playbook`,
|
|
244
|
+
});
|
|
245
|
+
} else {
|
|
246
|
+
ok.push(`playbook: ${name}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { findings, ok };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function normalizeBlock(s) {
|
|
254
|
+
return s.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trim();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function hasHeading(text, heading) {
|
|
258
|
+
// Match an exact h2 heading at line start (case-insensitive for the body
|
|
259
|
+
// after "## ", so "## Vision" matches "## VISION" or "## vision" too).
|
|
260
|
+
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
261
|
+
return new RegExp(`^${escaped}\\b`, "im").test(text);
|
|
262
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, read } from "./fs-ops.js";
|
|
5
|
+
import { specHeader } from "./scan.js";
|
|
6
|
+
|
|
7
|
+
// The trace MODEL: product intent anchors, the specs that realize them, and
|
|
8
|
+
// the provenance arithmetic over the two. Lifted out of `commands/trace.js`
|
|
9
|
+
// so `status`, `review` and the snapshot read it directly instead of
|
|
10
|
+
// importing out of a command module (audit finding F7). Nothing here prints.
|
|
11
|
+
|
|
12
|
+
const ANCHOR_RE = /[A-Z]+\d+/g;
|
|
13
|
+
|
|
14
|
+
// Pure summary of intent provenance, for other commands (`status`, `review`)
|
|
15
|
+
// that need the numbers without the report output.
|
|
16
|
+
export function summarize(projectRoot) {
|
|
17
|
+
const anchors = collectAnchors(projectRoot);
|
|
18
|
+
const specs = collectSpecs(projectRoot);
|
|
19
|
+
const anchorIds = new Set(anchors.map((a) => a.id));
|
|
20
|
+
const realizedBy = new Map();
|
|
21
|
+
let dangling = 0;
|
|
22
|
+
for (const s of specs) {
|
|
23
|
+
if (s.realizes === null) continue;
|
|
24
|
+
for (const id of s.realizes) {
|
|
25
|
+
if (anchorIds.has(id)) {
|
|
26
|
+
if (!realizedBy.has(id)) realizedBy.set(id, []);
|
|
27
|
+
realizedBy.get(id).push(s.cap);
|
|
28
|
+
} else {
|
|
29
|
+
dangling += 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const untraceable = specs.filter((s) => s.realizes === null && s.status === "active").length;
|
|
34
|
+
let realized = 0;
|
|
35
|
+
for (const a of anchors) if ((realizedBy.get(a.id) ?? []).length > 0) realized += 1;
|
|
36
|
+
return {
|
|
37
|
+
anchors: anchors.length,
|
|
38
|
+
realized,
|
|
39
|
+
dropped: anchors.length - realized,
|
|
40
|
+
dangling,
|
|
41
|
+
untraceable,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Every "[A-Z]+\d+" tag at the head of a bullet in product.md is an intent
|
|
46
|
+
// anchor. Section-agnostic so Success-criteria and In-scope bullets both work.
|
|
47
|
+
export function collectAnchors(projectRoot) {
|
|
48
|
+
const productPath = path.join(projectRoot, ".doctrina", "product.md");
|
|
49
|
+
if (!isFile(productPath)) return [];
|
|
50
|
+
const out = [];
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
for (const line of read(productPath).split(/\r?\n/)) {
|
|
53
|
+
const m = line.match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s+/);
|
|
54
|
+
if (m && !seen.has(m[1])) {
|
|
55
|
+
seen.add(m[1]);
|
|
56
|
+
out.push({ id: m[1] });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Anchor ids declared MORE THAN ONCE in product.md (change 0108). The
|
|
63
|
+
// collector above keeps the first bullet per id, which is right for the
|
|
64
|
+
// graph and wrong as the only reading: the second intent under the same id
|
|
65
|
+
// vanished from trace, why, intent list and every pack, with `trace
|
|
66
|
+
// --strict` green. Returned as {id, lines} so trace can name both bullets.
|
|
67
|
+
export function collectAnchorDuplicates(projectRoot) {
|
|
68
|
+
const productPath = path.join(projectRoot, ".doctrina", "product.md");
|
|
69
|
+
if (!isFile(productPath)) return [];
|
|
70
|
+
const lines = new Map();
|
|
71
|
+
const rows = read(productPath).split(/\r?\n/);
|
|
72
|
+
for (let i = 0; i < rows.length; i++) {
|
|
73
|
+
const m = rows[i].match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s+/);
|
|
74
|
+
if (!m) continue;
|
|
75
|
+
if (!lines.has(m[1])) lines.set(m[1], []);
|
|
76
|
+
lines.get(m[1]).push(i + 1);
|
|
77
|
+
}
|
|
78
|
+
return [...lines].filter(([, at]) => at.length > 1).map(([id, at]) => ({ id, lines: at }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function collectSpecs(projectRoot) {
|
|
82
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
83
|
+
if (!isDir(specsDir)) return [];
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
86
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
87
|
+
if (!isFile(specPath)) continue;
|
|
88
|
+
const text = read(specPath);
|
|
89
|
+
const realizesRaw = specHeader(text, "Realizes");
|
|
90
|
+
const realizes = realizesRaw === null ? null : (realizesRaw.match(ANCHOR_RE) ?? []);
|
|
91
|
+
const status = (specHeader(text, "Status") ?? "active").trim().toLowerCase();
|
|
92
|
+
out.push({ cap, status, realizes });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { fold } from "./lexicon.js";
|
|
3
|
+
// The lane CLASSIFIER (ADR 0024).
|
|
4
|
+
//
|
|
5
|
+
// `work` asks the same question `triage` answers — is this request PRODUCT,
|
|
6
|
+
// RUNTIME or CHORE — and imported the answer out of the command module
|
|
7
|
+
// (audit finding F7). One classifier, read by both.
|
|
8
|
+
|
|
9
|
+
// The prompt is FOLDED before matching (change 0107): accents stripped,
|
|
10
|
+
// lower-cased — the same `fold` `work` and `context --for` retrieve with
|
|
11
|
+
// (ADR 0040). Every pattern below is written in folded (ASCII) form, and
|
|
12
|
+
// the Portuguese half of each list sits next to the English half: the
|
|
13
|
+
// lexicon was English-only, so "the build is broken in CI, DATABASE_URL
|
|
14
|
+
// never reaches the process" was held as RUNTIME with exit 3 while the
|
|
15
|
+
// same sentence in Portuguese opened a change. A project whose prompts
|
|
16
|
+
// arrive in Portuguese (this one) had no guard at all.
|
|
17
|
+
export const CHORE_SIGNALS = [
|
|
18
|
+
[3, /\b(bump|upgrade|pin|lockfile|dependency|dependencies|devdependenc)\b/i],
|
|
19
|
+
[2, /\b(rename|move|reorganis|reorganiz|refactor|tidy|cleanup|clean up|format(ting)?|lint)\b/i],
|
|
20
|
+
[2, /\b(typo|comment|readme|changelog|docs? only)\b/i],
|
|
21
|
+
[2, /\b(already (specified|in the spec)|wiring only|no behaviour change|no behavior change)\b/i],
|
|
22
|
+
// pt
|
|
23
|
+
[3, /\b(atualizar (a |as )?dependencia|dependencias|fixar (a )?versao)\b/i],
|
|
24
|
+
[2, /\b(renomear|mover|reorganizar|refatorar|arrumar|limpeza|limpar|formatar|formatacao)\b/i],
|
|
25
|
+
[2, /\b(erro de digitacao|comentario|so (a )?doc(umentacao)?)\b/i],
|
|
26
|
+
[2, /\b(ja (esta )?(especificad[oa]|na spec)|so (a )?fiacao|sem mudanca de comportamento)\b/i],
|
|
27
|
+
];
|
|
28
|
+
export const PRODUCT_SIGNALS = [
|
|
29
|
+
[3, /\b(add|introduce|support|allow|enable|implement|build)\b/i],
|
|
30
|
+
[3, /\b(should|shall|must|requirement|acceptance|criteri)\b/i],
|
|
31
|
+
// AUTHORING VERBS. Both lanes above match domain NOUNS, which turned out
|
|
32
|
+
// to misread work ON the machinery as an incident IN it: "declare the
|
|
33
|
+
// release workflow wiring in a contract" scored RUNTIME on "workflow" and
|
|
34
|
+
// "wiring", and "so an intentional rename stops warning" scored CHORE on
|
|
35
|
+
// "rename" — a noun, not the act. Neither prompt was a diagnosis; both
|
|
36
|
+
// were someone sitting down to write an artifact.
|
|
37
|
+
//
|
|
38
|
+
// These are the verbs of authoring. A prompt whose verb is "declare" or
|
|
39
|
+
// "document" is describing work on the tree, whatever nouns surround it.
|
|
40
|
+
[3, /\b(declare|declaring|document|documenting|record|recording|specify|specifying|define|defining)\b/i],
|
|
41
|
+
[2, /\b(new (feature|capability|command|endpoint|screen|flow))\b/i],
|
|
42
|
+
[2, /\b(users? (can|should|want)|so that|in order to)\b/i],
|
|
43
|
+
[1, /\b(change|behaviour|behavior|spec)\b/i],
|
|
44
|
+
// pt
|
|
45
|
+
[3, /\b(adicionar|introduzir|suportar|permitir|habilitar|implementar|construir|criar)\b/i],
|
|
46
|
+
[3, /\b(deve|devera|requisito|aceite|aceitacao|criterio)\b/i],
|
|
47
|
+
[3, /\b(declarar|declarando|documentar|documentando|registrar|registrando|especificar|especificando|definir|definindo)\b/i],
|
|
48
|
+
[2, /\b(nov[oa] (feature|funcionalidade|capability|capacidade|comando|endpoint|tela|fluxo))\b/i],
|
|
49
|
+
[2, /\b(usuari[oa]s? (pode|podem|deve|devem|quer|querem)|para que|de modo que|a fim de)\b/i],
|
|
50
|
+
[1, /\b(mudanca|comportamento)\b/i],
|
|
51
|
+
];
|
|
52
|
+
// Signals that a request is about the running system rather than about what
|
|
53
|
+
// the system should do. Weighted: a phrase that can only mean runtime counts
|
|
54
|
+
// for more than a word that merely leans that way.
|
|
55
|
+
export const RUNTIME_SIGNALS = [
|
|
56
|
+
[3, /\b(workflow|pipeline|ci|github actions|actions run|job|runner)\b/i],
|
|
57
|
+
[3, /\b(env var|environment variable|dotenv|\.env|secret|secrets|vars)\b/i],
|
|
58
|
+
[3, /\b(0 (tests?|scenarios?|cases?)|no (tests?|scenarios?) ran|ran nothing|empty (run|suite|summary))\b/i],
|
|
59
|
+
[2, /\b(exit code|stack ?trace|traceback|logs?|step summary|artifact missing)\b/i],
|
|
60
|
+
[2, /\b(green but|passes but|silently|never (fires|applies|reaches|ran))\b/i],
|
|
61
|
+
[2, /\b(flaky|timeout|timed out|hangs?|crash(es|ed)?|broken build|failing build)\b/i],
|
|
62
|
+
[2, /\b(tag|selector|filter|dispatch)\b/i],
|
|
63
|
+
[1, /\b(config|configuration|wiring|wired|deploy(ment)?|docker|compose)\b/i],
|
|
64
|
+
[1, /\b(debug|diagnos(e|is)|investigate|why (is|does|did)|not working|doesn't work)\b/i],
|
|
65
|
+
// pt
|
|
66
|
+
[3, /\b(execucao d[oa]s? actions|esteira)\b/i],
|
|
67
|
+
[3, /\b(variavel de ambiente|variaveis de ambiente|segredo|segredos)\b/i],
|
|
68
|
+
[3, /\b(0 (testes?|cenarios?|casos?)|nenhum (teste|cenario|caso) (rodou|correu|executou)|(rodou|correu|executou) nada|(execucao|suite|resumo) vazi[oa])\b/i],
|
|
69
|
+
[2, /\b(codigo de saida|resumo d[oa] (step|etapa)|artefato (ausente|faltando|sumiu))\b/i],
|
|
70
|
+
[2, /\b(verde mas|passa mas|silenciosamente|em silencio|nunca (dispara|aplica|chega|rodou|correu|executou))\b/i],
|
|
71
|
+
[2, /\b(intermitente|estourou o tempo|trava|travou|crashou|build (quebrad[oa]|falhando|falhou))\b/i],
|
|
72
|
+
[2, /\b(seletor|filtro|despacho)\b/i],
|
|
73
|
+
[1, /\b(configuracao|fiacao|implantacao)\b/i],
|
|
74
|
+
[1, /\b(depurar|diagnostic(o|ar)|investigar|por que (esta|e|nao)|nao funciona|nao esta funcionando)\b/i],
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
export function classify(prompt) {
|
|
78
|
+
const text = fold(prompt);
|
|
79
|
+
const score = (signals) => {
|
|
80
|
+
let total = 0;
|
|
81
|
+
const hits = [];
|
|
82
|
+
for (const [weight, re] of signals) {
|
|
83
|
+
const m = text.match(re);
|
|
84
|
+
if (m) {
|
|
85
|
+
total += weight;
|
|
86
|
+
hits.push(m[0].toLowerCase());
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { total, hits };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const lanes = {
|
|
93
|
+
runtime: score(RUNTIME_SIGNALS),
|
|
94
|
+
chore: score(CHORE_SIGNALS),
|
|
95
|
+
product: score(PRODUCT_SIGNALS),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Product is the default: when nothing distinguishes the request, the
|
|
99
|
+
// ceremony that writes a spec delta is the safe one to fall into. Runtime
|
|
100
|
+
// must BEAT product to win, so an ordinary feature request mentioning a
|
|
101
|
+
// "job" does not get diverted into a diagnosis.
|
|
102
|
+
let lane = "product";
|
|
103
|
+
let best = lanes.product.total;
|
|
104
|
+
for (const name of ["runtime", "chore"]) {
|
|
105
|
+
if (lanes[name].total > best) {
|
|
106
|
+
lane = name;
|
|
107
|
+
best = lanes[name].total;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (best === 0) lane = "product";
|
|
111
|
+
|
|
112
|
+
// Confidence is the MARGIN over the runner-up, not the winner's own score:
|
|
113
|
+
// a prompt that scores 5 for runtime and 4 for product is a coin toss the
|
|
114
|
+
// agent should see as one, and a clear product request must be able to read
|
|
115
|
+
// as confident too (comparing a lane against itself never could).
|
|
116
|
+
const runnerUp = Object.entries(lanes)
|
|
117
|
+
.filter(([name]) => name !== lane)
|
|
118
|
+
.reduce((max, [, v]) => Math.max(max, v.total), 0);
|
|
119
|
+
|
|
120
|
+
return { lane, scores: lanes, confident: best > 0 && best - runnerUp >= 2 };
|
|
121
|
+
}
|
package/src/lib/usage.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
// Audit item M8: instrument the command surface before shrinking it.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// How big the surface is, this module does not say: `COMMAND_NAMES` and
|
|
5
|
+
// `OPERATIONS` in lib/commands.js own that number, and a copy of it here
|
|
6
|
+
// would be one more count to rot (change 0059 found four, all disagreeing).
|
|
7
|
+
// The audit's charge is that some of those commands exist because they were
|
|
8
|
+
// easy to add, not because anybody reaches for them — and the honest way to
|
|
9
|
+
// find out which is to MEASURE, not to guess from the outside. Guessing is how `prime`, `handoff` and
|
|
8
10
|
// `doctor` were nearly cut in 0.13.0, right before the operator review
|
|
9
11
|
// found they had gone unused only because AGENTS.md never named them.
|
|
10
12
|
//
|