doctrina-cli 0.15.1 → 0.17.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 +27 -72
- package/package.json +3 -3
- package/src/commands/adapter.js +27 -3
- package/src/commands/analyze.js +16 -212
- package/src/commands/change.js +144 -267
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +65 -21
- package/src/commands/close.js +349 -45
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +284 -111
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +66 -7
- package/src/commands/doctor.js +293 -130
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +17 -3
- package/src/commands/index-rebuild.js +55 -44
- package/src/commands/init.js +88 -16
- package/src/commands/intake.js +85 -99
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +65 -88
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +23 -91
- package/src/commands/report.js +40 -109
- 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 +23 -39
- package/src/commands/spec.js +38 -11
- package/src/commands/status.js +69 -171
- package/src/commands/templates.js +35 -148
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +44 -877
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +243 -267
- package/src/index.js +139 -12
- package/src/lib/actions.js +490 -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/args.js +14 -0
- package/src/lib/change-ops.js +133 -0
- package/src/lib/commands.js +135 -10
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +431 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +370 -3
- package/src/lib/docs-impact.js +270 -27
- package/src/lib/ears.js +24 -1
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +231 -29
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +42 -24
- package/src/lib/intake-model.js +155 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +228 -0
- package/src/lib/metrics-model.js +189 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +105 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +328 -25
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +231 -0
- package/src/lib/spec-ops.js +72 -8
- package/src/lib/templates-model.js +289 -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 +1381 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +413 -0
- package/src/lib/work-model.js +102 -0
- package/templates/AGENTS.md.template +21 -28
- package/templates/README.md +35 -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 +16 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +16 -2
- package/templates/playbooks/bootstrap.md.template +47 -0
- package/templates/playbooks/chore.md.template +24 -0
- package/templates/playbooks/work.md.template +83 -0
- package/src/commands/constitution.js +0 -145
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
5
|
+
import { projectName } from "./project.js";
|
|
6
|
+
import * as idx from "./index-json.js";
|
|
7
|
+
import { deriveIndex, indexesMatch, specHeader, listHeader } from "./scan.js";
|
|
8
|
+
import { cliVersion } from "./version.js";
|
|
9
|
+
import { summarize as coverageSummary } from "./coverage-model.js";
|
|
10
|
+
import { summarize as traceSummary } from "./trace-model.js";
|
|
11
|
+
import { acceptedDecisions, productSection, hasProductSection } from "./constitution-model.js";
|
|
12
|
+
import { computeActions } from "./actions.js";
|
|
13
|
+
import { readLedger } from "./ledger.js";
|
|
14
|
+
import { parseChangeTitle, checklistProgress } from "./doc-model.js";
|
|
15
|
+
import { summarizeSignoffs } from "./signoff.js";
|
|
16
|
+
import { gitWindow, historyState, windowCutoff } from "./git.js";
|
|
17
|
+
import { collectMetrics } from "./metrics-model.js";
|
|
18
|
+
|
|
19
|
+
// ONE collector, several views (audit finding F7).
|
|
20
|
+
//
|
|
21
|
+
// `status`, `prime`, `handoff` and `report` were four commands rendering the
|
|
22
|
+
// same three collections, and they got at them by importing functions out of
|
|
23
|
+
// each other's modules: `prime` reached into `status` for `collectStatus`,
|
|
24
|
+
// `handoff` and `report` reached into `prime` for `openChanges`, and all
|
|
25
|
+
// three reached into `coverage` and `trace` for their summaries. Every one of
|
|
26
|
+
// those is a command module — a RENDERER — being used as a data source, which
|
|
27
|
+
// is how four surfaces end up able to disagree about the same number.
|
|
28
|
+
//
|
|
29
|
+
// So the project's state is collected ONCE, here, into a plain object. The
|
|
30
|
+
// four commands become formatters over it: same numbers, four shapes, and a
|
|
31
|
+
// new view costs a function rather than a fifth traversal of the tree.
|
|
32
|
+
//
|
|
33
|
+
// Nothing here prints and nothing here writes.
|
|
34
|
+
|
|
35
|
+
const VERIFY_CONFIG_REL = ".doctrina/verify.json";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Everything the read-only views report, collected in one pass.
|
|
39
|
+
*
|
|
40
|
+
* `actions` is included because three of the four views end with "what next",
|
|
41
|
+
* and computing it separately in each was the same duplication one level up.
|
|
42
|
+
*/
|
|
43
|
+
export function collectSnapshot(projectRoot, { actions = true } = {}) {
|
|
44
|
+
let index = null;
|
|
45
|
+
try {
|
|
46
|
+
index = idx.load(projectRoot);
|
|
47
|
+
} catch {
|
|
48
|
+
index = null;
|
|
49
|
+
}
|
|
50
|
+
let indexState = "missing";
|
|
51
|
+
if (index) {
|
|
52
|
+
indexState = indexesMatch(deriveIndex(projectRoot, index), index) ? "in-sync" : "drifted";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Collected once, then shared: the views render it and `computeActions`
|
|
56
|
+
// recommends from it, so a recommendation can never disagree with the row
|
|
57
|
+
// it comes from (change 0037, extended to `next` by change 0064).
|
|
58
|
+
const coverage = coverageSummary(projectRoot);
|
|
59
|
+
const trace = traceSummary(projectRoot);
|
|
60
|
+
const verify = readVerifyConfig(projectRoot);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
project: projectName(projectRoot, index),
|
|
64
|
+
stamp: index?.framework_version ?? null,
|
|
65
|
+
cli: cliVersion(),
|
|
66
|
+
indexState,
|
|
67
|
+
coverage,
|
|
68
|
+
trace,
|
|
69
|
+
verify,
|
|
70
|
+
specs: countSpecs(projectRoot),
|
|
71
|
+
decisions: countDecisions(projectRoot),
|
|
72
|
+
skills: countSkills(projectRoot),
|
|
73
|
+
openChanges: openChanges(projectRoot),
|
|
74
|
+
adrs: acceptedDecisions(projectRoot),
|
|
75
|
+
nonGoals: productSection(projectRoot, "Non-goals"),
|
|
76
|
+
hasNonGoalsSection: hasProductSection(projectRoot, "Non-goals"),
|
|
77
|
+
// The gate state is already collected above; hand it over so the whole
|
|
78
|
+
// snapshot stays one read of the tree (change 0037).
|
|
79
|
+
actions: actions ? computeActions(projectRoot, { coverage, trace, verify }) : [],
|
|
80
|
+
archive: index?.artifacts?.changes_archive ?? [],
|
|
81
|
+
// The lane each open change was born in, for the report's mix. Read from
|
|
82
|
+
// the index rather than re-parsed, so one derivation owns the field.
|
|
83
|
+
openLanes: (index?.artifacts?.changes ?? []).map((c) => c.lane ?? null),
|
|
84
|
+
// The archive ledger, parsed (change 0046). The index records WHICH
|
|
85
|
+
// changes landed; the ledger records what each one touched and when, in
|
|
86
|
+
// one append-only file — which is what a question like "how often has
|
|
87
|
+
// this capability moved?" is actually asking about.
|
|
88
|
+
ledger: readLedger(projectRoot).entries,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The subset `status --json`, `doctor` and the dashboard have always
|
|
94
|
+
* published. Kept as its own shape so the JSON envelope does not silently
|
|
95
|
+
* grow the day a view needs one more field.
|
|
96
|
+
*/
|
|
97
|
+
export function collectStatus(projectRoot) {
|
|
98
|
+
const s = collectSnapshot(projectRoot, { actions: false });
|
|
99
|
+
return {
|
|
100
|
+
project: s.project,
|
|
101
|
+
stamp: s.stamp,
|
|
102
|
+
cli: s.cli,
|
|
103
|
+
indexState: s.indexState,
|
|
104
|
+
coverage: s.coverage,
|
|
105
|
+
trace: s.trace,
|
|
106
|
+
verify: s.verify,
|
|
107
|
+
specs: s.specs,
|
|
108
|
+
decisions: s.decisions,
|
|
109
|
+
skills: s.skills,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readVerifyConfig(projectRoot) {
|
|
114
|
+
const p = path.join(projectRoot, VERIFY_CONFIG_REL);
|
|
115
|
+
if (!isFile(p)) return { configured: false, checks: 0, invalid: false, signoffs: null };
|
|
116
|
+
try {
|
|
117
|
+
const cfg = JSON.parse(read(p));
|
|
118
|
+
const checks = Array.isArray(cfg?.checks) ? cfg.checks : [];
|
|
119
|
+
// Executed proof and SIGNED proof are different claims (change 0039), and
|
|
120
|
+
// a report that adds them together hides which is which. The counts ride
|
|
121
|
+
// along so every view can say so without loading the sign-off store
|
|
122
|
+
// itself.
|
|
123
|
+
const signoffs = summarizeSignoffs(projectRoot, checks);
|
|
124
|
+
return { configured: true, checks: checks.length, invalid: false, signoffs };
|
|
125
|
+
} catch {
|
|
126
|
+
return { configured: true, checks: 0, invalid: true, signoffs: null };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Open changes with their proposal status/title and task progress. */
|
|
131
|
+
export function openChanges(projectRoot) {
|
|
132
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
133
|
+
const out = [];
|
|
134
|
+
if (!isDir(changesDir)) return out;
|
|
135
|
+
for (const id of readdirSync(changesDir).sort()) {
|
|
136
|
+
if (id === "archive" || id.startsWith(".")) continue;
|
|
137
|
+
if (!isDir(path.join(changesDir, id))) continue;
|
|
138
|
+
const proposalPath = path.join(changesDir, id, "proposal.md");
|
|
139
|
+
const proposal = isFile(proposalPath) ? read(proposalPath) : null;
|
|
140
|
+
const title = proposal ? parseChangeTitle(proposal) : null;
|
|
141
|
+
const status = proposal ? (listHeader(proposal, "Status") ?? "proposed") : "no proposal.md";
|
|
142
|
+
const tasksPath = path.join(changesDir, id, "tasks.md");
|
|
143
|
+
// One counter for every surface (change 0067). This one used to require
|
|
144
|
+
// TEXT after the box, so the scaffold's empty placeholders were invisible
|
|
145
|
+
// and `prime` reported "tasks 0/3" for a change with six open boxes.
|
|
146
|
+
const progress = isFile(tasksPath)
|
|
147
|
+
? checklistProgress(read(tasksPath))
|
|
148
|
+
: { total: 0, done: 0, open: [], placeholders: 0 };
|
|
149
|
+
const tasksTotal = progress.total;
|
|
150
|
+
const tasksDone = progress.done;
|
|
151
|
+
const unchecked = progress.open.map((t) => t || "(unwritten task — the scaffold's placeholder)");
|
|
152
|
+
out.push({ id, title, status, tasksDone, tasksTotal, unchecked });
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function countSpecs(projectRoot) {
|
|
158
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
159
|
+
const impl = {};
|
|
160
|
+
let total = 0;
|
|
161
|
+
if (isDir(specsDir)) {
|
|
162
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
163
|
+
const p = path.join(specsDir, cap, "spec.md");
|
|
164
|
+
if (!isFile(p)) continue;
|
|
165
|
+
total += 1;
|
|
166
|
+
const implRaw = specHeader(read(p), "Implementation");
|
|
167
|
+
if (implRaw) {
|
|
168
|
+
const word = implRaw.trim().split(/[\s—-]+/)[0].toLowerCase();
|
|
169
|
+
impl[word] = (impl[word] ?? 0) + 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
174
|
+
let open = 0;
|
|
175
|
+
if (isDir(changesDir)) {
|
|
176
|
+
for (const e of readdirSync(changesDir)) {
|
|
177
|
+
if (e === "archive" || e.startsWith(".")) continue;
|
|
178
|
+
if (isDir(path.join(changesDir, e))) open += 1;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { total, impl, openChanges: open };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function countDecisions(projectRoot) {
|
|
185
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
186
|
+
let total = 0, proposed = 0, bare = 0;
|
|
187
|
+
const isBare = (v) => {
|
|
188
|
+
const t = (v ?? "").trim();
|
|
189
|
+
return t === "" || t === "—" || t === "-";
|
|
190
|
+
};
|
|
191
|
+
if (isDir(adrDir)) {
|
|
192
|
+
for (const f of walk(adrDir)) {
|
|
193
|
+
if (!/^\d{4}-.+\.md$/.test(path.basename(f))) continue;
|
|
194
|
+
total += 1;
|
|
195
|
+
const text = read(f);
|
|
196
|
+
const status = (listHeader(text, "Status") ?? "").toLowerCase();
|
|
197
|
+
if (status === "proposed") proposed += 1;
|
|
198
|
+
if (status === "accepted") {
|
|
199
|
+
const evidence = listHeader(text, "Evidence");
|
|
200
|
+
const landed = listHeader(text, "Landed");
|
|
201
|
+
if (evidence !== null && isBare(evidence) && isBare(landed)) bare += 1;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { total, proposed, bare };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function countSkills(projectRoot) {
|
|
209
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
210
|
+
if (!isDir(skillsDir)) return 0;
|
|
211
|
+
return walk(skillsDir).filter((f) => f.endsWith(".md")).length;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Everything the period digest reads beyond the snapshot, for a window of
|
|
216
|
+
* `days`. ONE collector for every door that renders the digest: `report`
|
|
217
|
+
* passed the metrics and `status --view report` did not, so the view that
|
|
218
|
+
* promised "never different numbers" printed two lines fewer (change 0176).
|
|
219
|
+
*
|
|
220
|
+
* @param {string} projectRoot
|
|
221
|
+
* @param {number} days
|
|
222
|
+
*/
|
|
223
|
+
export function collectReportWindow(projectRoot, days) {
|
|
224
|
+
return {
|
|
225
|
+
days,
|
|
226
|
+
cutoffIso: windowCutoff(days),
|
|
227
|
+
git: gitWindow(projectRoot, days),
|
|
228
|
+
gitState: historyState(projectRoot),
|
|
229
|
+
metrics: collectMetrics(projectRoot, `${days} days ago`),
|
|
230
|
+
};
|
|
231
|
+
}
|
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,15 @@ 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
|
+
// `cause: "argument"` marks an error about the value supplied, not about
|
|
260
|
+
// the document: `spec set` answers it as USAGE, a delta as a defect of
|
|
261
|
+
// the work (ADR 0018).
|
|
262
|
+
if (domain) return { error: `set-header: ${domain}`, cause: "argument" };
|
|
222
263
|
const next = text.replace(re, (_m, prefix) => `${prefix} ${value}`.replace(/\s+$/, ""));
|
|
223
264
|
return { text: next, summary: `set ${name}: ${value}` };
|
|
224
265
|
}
|
|
@@ -271,8 +312,10 @@ export function setCriterionMark(text, n, markRaw) {
|
|
|
271
312
|
const loc = locateCriteria(text);
|
|
272
313
|
if (!loc) return { error: "set-criterion: spec has no '## Acceptance criteria' section" };
|
|
273
314
|
const item = loc.items.find((it) => it.n === n);
|
|
274
|
-
if (!item) return { error: `set-criterion: no criterion #${n} found
|
|
315
|
+
if (!item) return { error: `set-criterion: no criterion #${n} found`, cause: "argument" };
|
|
275
316
|
const mark = markRaw.replace(/^\[|\]$/g, "").trim();
|
|
317
|
+
const domain = criterionMarkError(mark);
|
|
318
|
+
if (domain) return { error: `set-criterion: ${domain}`, cause: "argument" };
|
|
276
319
|
const line = loc.lines[item.lineIndex];
|
|
277
320
|
let next;
|
|
278
321
|
if (/\[[^\]]*\]/.test(line)) {
|
|
@@ -441,8 +484,24 @@ export function replaceRequirement(text, section, n, value) {
|
|
|
441
484
|
if (n > real.length) {
|
|
442
485
|
return { error: `replace-requirement: section "${section}" has ${real.length} requirement${real.length === 1 ? "" : "s"}, no #${n}` };
|
|
443
486
|
}
|
|
444
|
-
|
|
445
|
-
|
|
487
|
+
// REPLACE THE WHOLE ITEM, continuation lines included.
|
|
488
|
+
//
|
|
489
|
+
// Writing only the first line leaves the old bullet's wrapped prose sitting
|
|
490
|
+
// under the new one, indented, reading as part of it. That is not cosmetic:
|
|
491
|
+
// the orphan is the PREVIOUS version of the requirement, so the spec ends up
|
|
492
|
+
// stating a contract and then contradicting it two lines later, and a spec
|
|
493
|
+
// is the one artifact this framework asks everyone to trust.
|
|
494
|
+
//
|
|
495
|
+
// It had already happened four times in this repository's own tree before
|
|
496
|
+
// anyone noticed — twice in `gates` (a closing sequence missing its review
|
|
497
|
+
// and runtime steps, a doctor set missing its runtime row) and once in
|
|
498
|
+
// `insight`, all three describing older behaviour beside the current one.
|
|
499
|
+
//
|
|
500
|
+
// `append-requirement` learned this in 0.13.0 and got `endOfItem`; this verb
|
|
501
|
+
// was written afterwards and never picked it up.
|
|
502
|
+
const start = real[n - 1];
|
|
503
|
+
const indent = loc.lines[start].match(/^(\s*)/)[1];
|
|
504
|
+
loc.lines.splice(start, endOfItem(loc.lines, start) - start + 1, `${indent}- ${value}`);
|
|
446
505
|
return { text: loc.lines.join("\n"), summary: `replace-requirement ${section} ${n}` };
|
|
447
506
|
}
|
|
448
507
|
|
|
@@ -454,6 +513,11 @@ export function appendCriterion(text, value) {
|
|
|
454
513
|
if (loc.items.length === 0) {
|
|
455
514
|
return { error: "append-criterion: no existing numbered criterion to append after" };
|
|
456
515
|
}
|
|
516
|
+
const leadingMark = String(value).match(/^\s*\[([^\]]*)\]/);
|
|
517
|
+
if (leadingMark) {
|
|
518
|
+
const domain = criterionMarkError(leadingMark[1]);
|
|
519
|
+
if (domain) return { error: `append-criterion: ${domain}` };
|
|
520
|
+
}
|
|
457
521
|
const last = loc.items[loc.items.length - 1];
|
|
458
522
|
const nextN = Math.max(...loc.items.map((it) => it.n)) + 1;
|
|
459
523
|
loc.lines.splice(endOfItem(loc.lines, last.lineIndex) + 1, 0, `${last.indent}${nextN}. ${value}`);
|
|
@@ -0,0 +1,289 @@
|
|
|
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, DEPRECATED, REMOVED, 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 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
|
+
|
|
93
|
+
// The findings `templates check` reports, as structured records with an
|
|
94
|
+
// executable remedy. Exported so a test can seed each finding, run the
|
|
95
|
+
// remedy it names, and assert the finding clears — a remedy that cannot
|
|
96
|
+
// resolve its own finding is not a remedy (C2).
|
|
97
|
+
export function collectFindings(projectRoot) {
|
|
98
|
+
const findings = [];
|
|
99
|
+
const ok = [];
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
// AGENTS.md sections
|
|
103
|
+
const agentsPath = path.join(projectRoot, "AGENTS.md");
|
|
104
|
+
if (isFile(agentsPath)) {
|
|
105
|
+
const text = read(agentsPath);
|
|
106
|
+
const missingSections = AGENTS_SECTIONS.filter((h) => !hasHeading(text, h));
|
|
107
|
+
const budget = agentsSectionCost(projectRoot, missingSections);
|
|
108
|
+
for (const heading of AGENTS_SECTIONS) {
|
|
109
|
+
if (hasHeading(text, heading)) {
|
|
110
|
+
ok.push(`AGENTS.md: ${heading}`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// The recommendation names its price. When the stubs do not fit, the
|
|
114
|
+
// remedy names the CUT first — appending them anyway would clear this
|
|
115
|
+
// finding by breaching a ceiling `analyze` refuses to raise, which is
|
|
116
|
+
// one gate sending you into another's refusal.
|
|
117
|
+
findings.push({
|
|
118
|
+
message: `AGENTS.md missing recommended section "${heading}"` +
|
|
119
|
+
(budget.fits
|
|
120
|
+
? ``
|
|
121
|
+
: ` — the ${missingSections.length} missing stub(s) cost ${budget.cost} lines and ` +
|
|
122
|
+
`AGENTS.md has ${budget.slack} left of its declared ceiling`),
|
|
123
|
+
remedy: budget.fits
|
|
124
|
+
? "doctrina upgrade --write"
|
|
125
|
+
: `cut ${budget.over} line(s) of prose from AGENTS.md, then \`doctrina upgrade --write\``,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// Command-surface block: present and current vs the installed catalog.
|
|
129
|
+
const block = findSurfaceBlock(text);
|
|
130
|
+
if (!block) {
|
|
131
|
+
findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina upgrade --write" });
|
|
132
|
+
} else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock())) {
|
|
133
|
+
findings.push({ message: "AGENTS.md doctrina:surface block is stale vs the installed CLI", remedy: "doctrina upgrade --write" });
|
|
134
|
+
} else {
|
|
135
|
+
ok.push("AGENTS.md: doctrina:surface block current");
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
findings.push({ message: "AGENTS.md missing at project root", remedy: "doctrina init --force" });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// product.md sections
|
|
142
|
+
const productPath = path.join(projectRoot, ".doctrina", "product.md");
|
|
143
|
+
if (isFile(productPath)) {
|
|
144
|
+
const text = read(productPath);
|
|
145
|
+
for (const heading of PRODUCT_SECTIONS) {
|
|
146
|
+
if (hasHeading(text, heading)) ok.push(`product.md: ${heading}`);
|
|
147
|
+
else findings.push({ message: `.doctrina/product.md missing recommended section "${heading}"`, remedy: "doctrina upgrade --write" });
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
findings.push({ message: ".doctrina/product.md missing", remedy: "doctrina init --force" });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Installed agent adapters: a HUB POINTER file must still reference
|
|
154
|
+
// AGENTS.md (that is why one `upgrade --write` refresh of the hub reaches
|
|
155
|
+
// every installed agent). Only files whose template declares the
|
|
156
|
+
// {{AGENTS_MD_PATH}} token are pointers; a slash-command shim reaches the
|
|
157
|
+
// hub through its parent pointer file and is not checked (C2).
|
|
158
|
+
for (const name of listAdapterNames(projectRoot)) {
|
|
159
|
+
const spec = adapterFiles(projectRoot, name);
|
|
160
|
+
if (!spec) continue;
|
|
161
|
+
for (const file of spec.files) {
|
|
162
|
+
const installed = path.join(projectRoot, file.relativePath);
|
|
163
|
+
if (!isFile(installed)) continue; // not installed — nothing to check
|
|
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
|
+
}
|
|
182
|
+
if (read(installed).includes("AGENTS.md")) {
|
|
183
|
+
ok.push(`adapter ${file.relativePath}: points at AGENTS.md`);
|
|
184
|
+
} else {
|
|
185
|
+
findings.push({
|
|
186
|
+
message: `adapter ${file.relativePath} no longer references AGENTS.md — agents loading it will miss the hub`,
|
|
187
|
+
// Executable and verified: `adapter add --force` rewrites exactly
|
|
188
|
+
// this file from its template. A remedy that cannot resolve the
|
|
189
|
+
// finding it is attached to is not a remedy (C2).
|
|
190
|
+
remedy: `doctrina adapter add ${name} --force`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// M2: a command that cannot state its trigger has not earned a place on
|
|
197
|
+
// the surface. The block is an agent's only discovery surface, so both
|
|
198
|
+
// fields are required and the block has a declared size budget.
|
|
199
|
+
for (const name of COMMAND_NAMES) {
|
|
200
|
+
const meta = COMMAND_META[name];
|
|
201
|
+
if (!meta || !meta.purpose || !meta.when) {
|
|
202
|
+
findings.push({
|
|
203
|
+
message: `command "${name}" declares no ${!meta ? "purpose or when" : (!meta.purpose ? "purpose" : "when")} — the surface block cannot say when to reach for it`,
|
|
204
|
+
remedy: null,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const surface = surfaceBudget(projectRoot);
|
|
209
|
+
if (surface.used > surface.budget) {
|
|
210
|
+
findings.push({
|
|
211
|
+
message: `the generated surface block is ${surface.used} lines (budget ${surface.budget}) — cut commands rather than raising the budget`,
|
|
212
|
+
remedy: null,
|
|
213
|
+
});
|
|
214
|
+
} else {
|
|
215
|
+
ok.push(`surface block within budget (${surface.used}/${surface.budget} lines)`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// index.json schema fields
|
|
219
|
+
const indexPath = path.join(projectRoot, ".doctrina", "index.json");
|
|
220
|
+
if (isFile(indexPath)) {
|
|
221
|
+
try {
|
|
222
|
+
const idx = JSON.parse(read(indexPath));
|
|
223
|
+
for (const field of INDEX_FIELDS) {
|
|
224
|
+
if (idx[field] !== undefined) ok.push(`index.json: ${field}`);
|
|
225
|
+
else findings.push({ message: `.doctrina/index.json missing field "${field}"`, remedy: "doctrina upgrade --write" });
|
|
226
|
+
}
|
|
227
|
+
if (idx.$schema_version && idx.$schema_version !== "0.1.0") {
|
|
228
|
+
findings.push({ message: `.doctrina/index.json $schema_version is "${idx.$schema_version}" (expected "0.1.0")`, remedy: null });
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
findings.push({ message: `.doctrina/index.json failed to parse: ${err.message}`, remedy: null });
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
findings.push({ message: ".doctrina/index.json missing", remedy: "doctrina index rebuild" });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Output
|
|
238
|
+
// The playbooks (change 0038). A playbook is the procedure the agent
|
|
239
|
+
// actually executes, and since it became a template an adopter can replace
|
|
240
|
+
// it — which means it can also be MISSING or malformed, and a playbook that
|
|
241
|
+
// does not render is a broken session, not a cosmetic gap. Each is checked
|
|
242
|
+
// for resolution and for the shape every playbook must have: a title line
|
|
243
|
+
// and the ordered steps.
|
|
244
|
+
for (const name of PLAYBOOKS) {
|
|
245
|
+
const rel = `playbooks/${name}.md.template`;
|
|
246
|
+
let body = null;
|
|
247
|
+
try {
|
|
248
|
+
body = readTemplate(projectRoot, rel).body;
|
|
249
|
+
} catch {
|
|
250
|
+
body = null;
|
|
251
|
+
}
|
|
252
|
+
if (body === null) {
|
|
253
|
+
findings.push({
|
|
254
|
+
message: `playbook "${name}" does not resolve (${rel} is missing from the project AND the installed CLI)`,
|
|
255
|
+
remedy: "reinstall doctrina-cli, or restore .doctrina/templates/" + rel,
|
|
256
|
+
});
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const problems = [];
|
|
260
|
+
if (body.trim() === "") problems.push("it is empty");
|
|
261
|
+
if (!/^\s*1\./m.test(body)) problems.push("it has no numbered first step");
|
|
262
|
+
for (const [open, close] of [["[[c]]", "[[/c]]"], ["[[g]]", "[[/g]]"], ["[[b]]", "[[/b]]"], ["[[y]]", "[[/y]]"]]) {
|
|
263
|
+
const opens = body.split(open).length - 1;
|
|
264
|
+
const closes = body.split(close).length - 1;
|
|
265
|
+
if (opens !== closes) problems.push(`${opens} ${open} spans but ${closes} ${close}`);
|
|
266
|
+
}
|
|
267
|
+
if (problems.length > 0) {
|
|
268
|
+
findings.push({
|
|
269
|
+
message: `playbook "${name}" is malformed: ${problems.join("; ")}`,
|
|
270
|
+
remedy: `fix .doctrina/templates/${rel}, or delete it to fall back to the bundled playbook`,
|
|
271
|
+
});
|
|
272
|
+
} else {
|
|
273
|
+
ok.push(`playbook: ${name}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { findings, ok };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function normalizeBlock(s) {
|
|
281
|
+
return s.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trim();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function hasHeading(text, heading) {
|
|
285
|
+
// Match an exact h2 heading at line start (case-insensitive for the body
|
|
286
|
+
// after "## ", so "## Vision" matches "## VISION" or "## vision" too).
|
|
287
|
+
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
288
|
+
return new RegExp(`^${escaped}\\b`, "im").test(text);
|
|
289
|
+
}
|