doctrina-cli 0.13.0 → 0.14.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 +3 -2
- package/src/commands/adapter.js +205 -0
- package/src/commands/analyze.js +66 -19
- package/src/commands/change.js +100 -42
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +57 -2
- package/src/commands/completion.js +6 -0
- package/src/commands/constitution.js +8 -1
- package/src/commands/context.js +359 -53
- package/src/commands/contract.js +17 -24
- package/src/commands/coverage.js +16 -4
- package/src/commands/decision.js +172 -40
- package/src/commands/doctor.js +38 -7
- package/src/commands/handoff.js +8 -1
- package/src/commands/hooks.js +6 -0
- package/src/commands/index-rebuild.js +8 -1
- package/src/commands/init.js +139 -6
- package/src/commands/intake.js +11 -2
- package/src/commands/intent.js +8 -1
- package/src/commands/metrics.js +80 -7
- package/src/commands/next.js +14 -2
- package/src/commands/prime.js +8 -1
- package/src/commands/report.js +8 -1
- package/src/commands/review.js +8 -1
- package/src/commands/search.js +8 -1
- package/src/commands/show.js +8 -1
- package/src/commands/skill.js +16 -6
- package/src/commands/spec.js +13 -6
- package/src/commands/status.js +14 -2
- package/src/commands/templates.js +193 -59
- package/src/commands/trace.js +15 -3
- package/src/commands/upgrade.js +8 -1
- package/src/commands/validate.js +58 -2
- package/src/commands/verify.js +12 -2
- package/src/commands/watch.js +8 -1
- package/src/commands/why.js +10 -24
- package/src/commands/work.js +8 -1
- package/src/index.js +60 -6
- package/src/lib/adapters.js +140 -0
- package/src/lib/adr-guard.js +1 -0
- package/src/lib/args.js +10 -0
- package/src/lib/clarity.js +1 -0
- package/src/lib/colors.js +1 -0
- package/src/lib/commands.js +239 -26
- package/src/lib/criteria.js +1 -0
- package/src/lib/dates.js +1 -0
- package/src/lib/diff.js +1 -0
- package/src/lib/doc-model.js +287 -0
- package/src/lib/docs-impact.js +167 -0
- package/src/lib/ears.js +1 -0
- package/src/lib/exit-codes.js +103 -0
- package/src/lib/flag-catalog.js +106 -0
- package/src/lib/fs-ops.js +1 -0
- package/src/lib/gates.js +123 -0
- package/src/lib/git.js +97 -0
- package/src/lib/index-json.js +14 -6
- package/src/lib/json-out.js +83 -0
- package/src/lib/prompt.js +18 -1
- package/src/lib/scan.js +81 -33
- package/src/lib/spec-ops.js +94 -9
- package/src/lib/suggest.js +1 -0
- package/src/lib/templates.js +89 -0
- package/src/lib/usage.js +89 -0
- package/src/lib/version.js +1 -0
- package/templates/AGENTS.md.template +59 -53
package/src/lib/commands.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
// Single canonical list of top-level `doctrina` commands.
|
|
2
3
|
//
|
|
3
4
|
// This is the one place the command surface is named. `src/index.js` builds
|
|
@@ -13,7 +14,7 @@ export const COMMAND_NAMES = [
|
|
|
13
14
|
// bootstrap / day-to-day
|
|
14
15
|
"init", "intake", "work",
|
|
15
16
|
// authoring
|
|
16
|
-
"spec", "change", "contract", "decision", "skill", "intent",
|
|
17
|
+
"spec", "change", "contract", "decision", "skill", "intent", "adapter",
|
|
17
18
|
// read / orient
|
|
18
19
|
"prime", "context", "show", "search", "status", "next", "why", "handoff", "constitution",
|
|
19
20
|
// gates
|
|
@@ -54,10 +55,14 @@ export const OPERATIONS = [
|
|
|
54
55
|
["decision land", "Record that an accepted ADR is now implemented (non-mutating)"],
|
|
55
56
|
["decision supersede", "Create a new ADR that supersedes an existing one"],
|
|
56
57
|
["decision list", "List ADRs with status, date, and title"],
|
|
58
|
+
["decision scope", "Show and propose the capabilities each ADR governs"],
|
|
57
59
|
["skill new", "Scaffold an on-demand procedural memory skill"],
|
|
58
60
|
["skill list", "List skills with their descriptions"],
|
|
59
61
|
["skill sync", "Mirror skill frontmatter descriptions into index.json"],
|
|
60
62
|
["skill suggest", "Surface fix-shaped lessons worth a skill (--write scaffolds)"],
|
|
63
|
+
["adapter list", "Inventory agent adapters: installed / available / native"],
|
|
64
|
+
["adapter add", "Install one agent adapter (additive; never touches AGENTS.md)"],
|
|
65
|
+
["adapter remove", "Delete the files an adapter installed"],
|
|
61
66
|
["intent add", "Append a new product intent anchor post-intake (- [SC5] ...)"],
|
|
62
67
|
["intent list", "List product.md intent anchors in document order"],
|
|
63
68
|
["analyze", "Inspect a change folder before applying"],
|
|
@@ -90,6 +95,69 @@ export const OPERATIONS = [
|
|
|
90
95
|
["upgrade", "Bring an existing project up to the installed CLI (--write applies)"],
|
|
91
96
|
];
|
|
92
97
|
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Per-command PURPOSE and WHEN (audit item M2).
|
|
100
|
+
//
|
|
101
|
+
// The generated surface block was a name index. An agent reading
|
|
102
|
+
// "`doctrina trace --strict`" learns that the command exists and nothing
|
|
103
|
+
// about what triggers it, what it costs, or what it returns — so learning
|
|
104
|
+
// the surface meant running `--help` 35 times, roughly the whole context
|
|
105
|
+
// budget spent on a lookup.
|
|
106
|
+
//
|
|
107
|
+
// Doctrina already solved this for SKILLS: `context` prints a description
|
|
108
|
+
// and a `when:` trigger per skill and tells the agent to load the body only
|
|
109
|
+
// when the trigger fires. Commands get the same treatment.
|
|
110
|
+
//
|
|
111
|
+
// `when` is the moment you reach for it, in the agent's own terms.
|
|
112
|
+
// `purpose` is one line on what it does. Both are required: a command that
|
|
113
|
+
// cannot state its trigger has not earned a place on the surface, and
|
|
114
|
+
// `templates check` fails when either is missing.
|
|
115
|
+
export const COMMAND_META = {
|
|
116
|
+
init: { moment: "Bootstrap", when: "starting a project that has no AGENTS.md yet", purpose: "scaffold AGENTS.md and .doctrina/" },
|
|
117
|
+
intake: { moment: "Bootstrap", when: "you have a full project description and no specs yet", purpose: "store the intent and print the bootstrap playbook" },
|
|
118
|
+
adapter: { moment: "Bootstrap", when: "adding or removing an agent's pointer files", purpose: "install/remove agent adapters (additive)" },
|
|
119
|
+
|
|
120
|
+
prime: { moment: "Orient", when: "at the START of every session", purpose: "gates, standing rules, and open work in one read" },
|
|
121
|
+
status: { moment: "Orient", when: "you need the health of the tree at a glance", purpose: "index, coverage, trace, and artifact counts" },
|
|
122
|
+
next: { moment: "Orient", when: "you finished something and do not know what follows", purpose: "the recommended next workflow action" },
|
|
123
|
+
context: { moment: "Orient", when: "before working on any task, to load the right files", purpose: "the read pack, assembled to fit a token budget" },
|
|
124
|
+
show: { moment: "Orient", when: "you need one requirement, criterion, or ADR, not a file", purpose: "point-read a single artifact by reference" },
|
|
125
|
+
search: { moment: "Orient", when: "you do not know which artifact mentions a term", purpose: "search the artifact tree, grouped by category" },
|
|
126
|
+
why: { moment: "Orient", when: "you need to justify or trace a capability's existence", purpose: "provenance: intent, proof, ADRs, and history" },
|
|
127
|
+
constitution: { moment: "Orient", when: "you need the standing rules before deciding something", purpose: "accepted ADRs and product non-goals" },
|
|
128
|
+
handoff: { moment: "Orient", when: "BEFORE compaction or handing over to another session", purpose: "a resume note: open work, task state, next command" },
|
|
129
|
+
|
|
130
|
+
work: { moment: "Change", when: "any request arrives that changes behaviour", purpose: "scaffold a change and print the playbook to execute" },
|
|
131
|
+
spec: { moment: "Change", when: "a capability needs creating or its headers advancing", purpose: "create, list, and edit capability specs" },
|
|
132
|
+
change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "new / apply / archive / check / tick / diff / abandon" },
|
|
133
|
+
decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
|
|
134
|
+
contract: { moment: "Change", when: "the change touches ports, env vars, or public endpoints", purpose: "own and verify the integration surface" },
|
|
135
|
+
intent: { moment: "Change", when: "new product intent appears after the intake", purpose: "append and list product intent anchors" },
|
|
136
|
+
skill: { moment: "Change", when: "a lesson is worth not relearning", purpose: "capture on-demand procedural memory" },
|
|
137
|
+
|
|
138
|
+
analyze: { moment: "Gate", when: "before applying a change", purpose: "structural pre-flight on a change folder" },
|
|
139
|
+
clarify: { moment: "Gate", when: "before applying, or before opening a PR", purpose: "smell-test Markdown for ambiguity" },
|
|
140
|
+
validate: { moment: "Gate", when: "after any artifact edit, and before considering work done", purpose: "schema, structure, EARS, and index drift" },
|
|
141
|
+
coverage: { moment: "Gate", when: "before claiming a capability is proven", purpose: "acceptance criteria against cited evidence" },
|
|
142
|
+
trace: { moment: "Gate", when: "checking that product intent still maps to capabilities", purpose: "intent provenance across the tree" },
|
|
143
|
+
review: { moment: "Gate", when: "before handing work back, to self-review it", purpose: "conformance of your changes vs specs/ADRs/contracts" },
|
|
144
|
+
verify: { moment: "Gate", when: "the real build gate must run", purpose: "the project's declared typecheck/test/build checks" },
|
|
145
|
+
close: { moment: "Gate", when: "a change is implemented and ready to finish", purpose: "the whole closing sequence in one attested pass" },
|
|
146
|
+
doctor: { moment: "Gate", when: "something looks wrong and you do not know which gate to ask", purpose: "aggregate diagnostic with per-finding remedies" },
|
|
147
|
+
|
|
148
|
+
templates: { moment: "Maintain", when: "after upgrading the CLI, or to customise a scaffold", purpose: "inspect, check, and refresh the template shape" },
|
|
149
|
+
upgrade: { moment: "Maintain", when: "you just updated the doctrina-cli package", purpose: "bring this project up to the installed CLI" },
|
|
150
|
+
index: { moment: "Maintain", when: "the index drifted from the tree", purpose: "regenerate index.json from the artifacts on disk" },
|
|
151
|
+
hooks: { moment: "Maintain", when: "setting up a repo so drift cannot be committed", purpose: "install the pre-commit gate" },
|
|
152
|
+
watch: { moment: "Maintain", when: "you want the tree re-synced on every save", purpose: "re-run validate --fix and next continuously" },
|
|
153
|
+
metrics: { moment: "Maintain", when: "reporting adoption over a period", purpose: "local git-derived adoption metrics" },
|
|
154
|
+
report: { moment: "Maintain", when: "summarising a period for a human", purpose: "a Markdown digest of changes, gates, and git" },
|
|
155
|
+
completion: { moment: "Maintain", when: "setting up a human's shell", purpose: "print shell completions from the catalog" },
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// The order moments appear in the generated block.
|
|
159
|
+
export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Maintain"];
|
|
160
|
+
|
|
93
161
|
// The Commands block of `doctrina --help`, generated from OPERATIONS so the
|
|
94
162
|
// printed surface can never omit an operation the catalog knows about.
|
|
95
163
|
export function surfaceHelp() {
|
|
@@ -112,23 +180,13 @@ export const SURFACE_BEGIN =
|
|
|
112
180
|
"<!-- doctrina:surface:begin — CLI-owned block, generated from the installed command catalog. Refreshed by `doctrina upgrade --write`; edits inside are overwritten. -->";
|
|
113
181
|
export const SURFACE_END = "<!-- doctrina:surface:end -->";
|
|
114
182
|
|
|
115
|
-
// Workflow grouping for the generated block. A test asserts the flattened
|
|
116
|
-
// names equal COMMAND_NAMES, so a new command cannot ship ungrouped (and
|
|
117
|
-
// therefore cannot ship invisible to AGENTS.md).
|
|
118
|
-
export const SURFACE_GROUPS = [
|
|
119
|
-
["Start", ["init", "intake", "work"]],
|
|
120
|
-
["Author", ["spec", "change", "contract", "decision", "skill", "intent"]],
|
|
121
|
-
["Read / orient", ["prime", "context", "show", "search", "status", "next", "why", "handoff", "constitution"]],
|
|
122
|
-
["Gates", ["analyze", "clarify", "validate", "coverage", "trace", "review", "verify", "close", "doctor"]],
|
|
123
|
-
["Maintain", ["templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade"]],
|
|
124
|
-
];
|
|
125
183
|
|
|
126
184
|
// Per-command usage hints appended to the generated reference — arguments and
|
|
127
185
|
// the flags an agent reaches for daily. Cosmetic only; the operation list
|
|
128
186
|
// itself always comes from OPERATIONS.
|
|
129
187
|
const SURFACE_HINTS = {
|
|
130
188
|
work: '"<prompt>" (--capability · --chore · --from-diff · --quiet)',
|
|
131
|
-
context: "[<cap>] --concat",
|
|
189
|
+
context: "[<cap>] --for \"<task>\" --concat",
|
|
132
190
|
validate: "(--fix)",
|
|
133
191
|
coverage: "--strict",
|
|
134
192
|
trace: "--strict",
|
|
@@ -139,7 +197,25 @@ const SURFACE_HINTS = {
|
|
|
139
197
|
handoff: "(before compaction/handover)",
|
|
140
198
|
};
|
|
141
199
|
|
|
200
|
+
// The declared size budget for the generated block. A surface that cannot
|
|
201
|
+
// describe itself in this many lines is too large — the answer is to cut
|
|
202
|
+
// commands (M8), never to raise the number.
|
|
203
|
+
export const SURFACE_LINE_BUDGET = 40;
|
|
204
|
+
|
|
205
|
+
// Moments an agent works IN get a trigger per command. "Maintain" is
|
|
206
|
+
// compressed to one line: those commands are reached for by a human after
|
|
207
|
+
// an upgrade or when wiring a repo, not by an agent mid-loop, and giving
|
|
208
|
+
// each its own line pushed the block past its budget. The budget is the
|
|
209
|
+
// forcing function — a surface that cannot describe itself in 40 lines is
|
|
210
|
+
// too large, and the answer is to cut commands (M8), not to raise it.
|
|
211
|
+
const COMPACT_MOMENTS = new Set(["Maintain"]);
|
|
212
|
+
|
|
142
213
|
// The full managed section, heading included, between the markers.
|
|
214
|
+
//
|
|
215
|
+
// Organised by MOMENT rather than by category, and each line carries the
|
|
216
|
+
// trigger: an agent scanning it learns WHEN to reach for a command, which
|
|
217
|
+
// a name index never told it. Same shape `context` prints for skills —
|
|
218
|
+
// description plus `when:`, body on demand.
|
|
143
219
|
export function surfaceMarkdown() {
|
|
144
220
|
const subsByCmd = new Map();
|
|
145
221
|
for (const [op] of OPERATIONS) {
|
|
@@ -147,26 +223,36 @@ export function surfaceMarkdown() {
|
|
|
147
223
|
if (!subsByCmd.has(cmd)) subsByCmd.set(cmd, []);
|
|
148
224
|
if (sub) subsByCmd.get(cmd).push(sub);
|
|
149
225
|
}
|
|
150
|
-
const
|
|
226
|
+
const invocation = (cmd) => {
|
|
151
227
|
const subs = subsByCmd.get(cmd) ?? [];
|
|
152
228
|
const hint = SURFACE_HINTS[cmd] ? ` ${SURFACE_HINTS[cmd]}` : "";
|
|
153
|
-
return subs.length > 0 ?
|
|
229
|
+
return subs.length > 0 ? `${cmd} ${subs.join("|")}` : `${cmd}${hint}`;
|
|
154
230
|
};
|
|
231
|
+
|
|
155
232
|
const lines = [
|
|
156
233
|
"## Doctrina command surface (generated — reach for these, don't hand-author)",
|
|
157
234
|
"",
|
|
158
|
-
"Every
|
|
159
|
-
"
|
|
160
|
-
"
|
|
161
|
-
"",
|
|
235
|
+
"Every command, with the moment you reach for it. The CLI scaffolds from",
|
|
236
|
+
"canonical templates and keeps `index.json` in sync — prefer it over",
|
|
237
|
+
"hand-authoring. Flags and detail: `doctrina <command> --help`.",
|
|
162
238
|
];
|
|
163
|
-
for (const
|
|
164
|
-
|
|
239
|
+
for (const moment of MOMENTS) {
|
|
240
|
+
const cmds = COMMAND_NAMES.filter((n) => COMMAND_META[n]?.moment === moment);
|
|
241
|
+
if (cmds.length === 0) continue;
|
|
242
|
+
if (COMPACT_MOMENTS.has(moment)) {
|
|
243
|
+
// Each name still carries the `doctrina ` prefix: the compact line is
|
|
244
|
+
// read by validate's drift gate too, which only recognises a command
|
|
245
|
+
// reference in that form.
|
|
246
|
+
lines.push(`**${moment}** (triggers: \`doctrina <command> --help\`) — ` +
|
|
247
|
+
cmds.map((n) => `\`doctrina ${invocation(n)}\``).join(" · "));
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
lines.push(`**${moment}**`);
|
|
251
|
+
for (const cmd of cmds) {
|
|
252
|
+
const meta = COMMAND_META[cmd];
|
|
253
|
+
lines.push(`- \`doctrina ${invocation(cmd)}\` — ${meta.purpose}. *When:* ${meta.when}.`);
|
|
254
|
+
}
|
|
165
255
|
}
|
|
166
|
-
lines.push("");
|
|
167
|
-
lines.push("Session bookends: `doctrina prime` to orient at session start;");
|
|
168
|
-
lines.push("`doctrina handoff` before compaction or handover. Continuous:");
|
|
169
|
-
lines.push("`doctrina watch`. Capture lessons: `doctrina skill suggest`.");
|
|
170
256
|
return lines.join("\n");
|
|
171
257
|
}
|
|
172
258
|
|
|
@@ -175,13 +261,87 @@ export function surfaceBlock() {
|
|
|
175
261
|
return `${SURFACE_BEGIN}\n${surfaceMarkdown()}\n${SURFACE_END}`;
|
|
176
262
|
}
|
|
177
263
|
|
|
264
|
+
// The block's CANONICAL POSITION, defined once — by the shipped template,
|
|
265
|
+
// which is the only place the intended layout exists.
|
|
266
|
+
//
|
|
267
|
+
// `templates update` used to APPEND the block, so the same CLI produced two
|
|
268
|
+
// different AGENTS.md layouts: third section on a fresh `init`, dead last
|
|
269
|
+
// after an `upgrade` — behind 117 lines the agent reads first, which is the
|
|
270
|
+
// attention problem design principle #2 exists to prevent, and with no
|
|
271
|
+
// heading of its own before it, so a hierarchical parse read the command
|
|
272
|
+
// surface as content of "## What never goes in this file" (audit item C4).
|
|
273
|
+
//
|
|
274
|
+
// Returns { after, before }: the heading text the block follows in the
|
|
275
|
+
// template, and the heading it precedes. Either may be null.
|
|
276
|
+
export function surfaceAnchors(templateText) {
|
|
277
|
+
const block = findSurfaceBlock(templateText);
|
|
278
|
+
if (!block) return { after: null, before: null };
|
|
279
|
+
const headingsBefore = [...templateText.slice(0, block.start).matchAll(/^##\s+.*$/gm)];
|
|
280
|
+
const headingAfter = templateText.slice(block.end).match(/^##\s+.*$/m);
|
|
281
|
+
return {
|
|
282
|
+
after: headingsBefore.length > 0 ? headingsBefore[headingsBefore.length - 1][0].trim() : null,
|
|
283
|
+
before: headingAfter ? headingAfter[0].trim() : null,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Place `block` into `text` at the canonical position, returning the new
|
|
288
|
+
// text. Used by both `init` (via the template, which already has it there)
|
|
289
|
+
// and `templates update` (on a project that has none), so the two produce
|
|
290
|
+
// the same layout and running it twice is a no-op.
|
|
291
|
+
export function placeSurfaceBlock(text, block, anchors) {
|
|
292
|
+
const existing = findSurfaceBlock(text);
|
|
293
|
+
if (existing) return text.slice(0, existing.start) + block + text.slice(existing.end);
|
|
294
|
+
|
|
295
|
+
const lines = text.split("\n");
|
|
296
|
+
const headingIndex = (heading) =>
|
|
297
|
+
heading ? lines.findIndex((l) => l.trim() === heading) : -1;
|
|
298
|
+
|
|
299
|
+
// A section ends at the next heading OR at the start of another CLI-owned
|
|
300
|
+
// block. The marker comment precedes that block's heading, so scanning for
|
|
301
|
+
// headings alone would place this block BETWEEN a marker and its own
|
|
302
|
+
// heading — nesting the two, after which the next regeneration treats the
|
|
303
|
+
// outer span as stale and deletes everything inside it. That corrupted a
|
|
304
|
+
// real file before this guard existed.
|
|
305
|
+
const isBoundary = (line) => /^##\s+/.test(line) || /^\s*<!--\s*doctrina:\w+:begin/.test(line);
|
|
306
|
+
|
|
307
|
+
// Preferred: immediately after the section the template puts it after.
|
|
308
|
+
const afterIdx = headingIndex(anchors.after);
|
|
309
|
+
if (afterIdx >= 0) {
|
|
310
|
+
let end = afterIdx + 1;
|
|
311
|
+
while (end < lines.length && !isBoundary(lines[end])) end += 1;
|
|
312
|
+
const head = lines.slice(0, end).join("\n").replace(/\s+$/, "");
|
|
313
|
+
const tail = lines.slice(end).join("\n");
|
|
314
|
+
return `${head}\n\n${block}\n\n${tail}`.replace(/\n{3,}/g, "\n\n");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Next: immediately before the section the template puts it before.
|
|
318
|
+
const beforeIdx = headingIndex(anchors.before);
|
|
319
|
+
if (beforeIdx >= 0) {
|
|
320
|
+
const head = lines.slice(0, beforeIdx).join("\n").replace(/\s+$/, "");
|
|
321
|
+
const tail = lines.slice(beforeIdx).join("\n");
|
|
322
|
+
return `${head}\n\n${block}\n\n${tail}`.replace(/\n{3,}/g, "\n\n");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Last resort: append, with a trailing newline so the file stays well-formed.
|
|
326
|
+
return `${text.replace(/\s+$/, "")}\n\n${block}\n`;
|
|
327
|
+
}
|
|
328
|
+
|
|
178
329
|
// Locate the managed block in an AGENTS.md text. Returns
|
|
179
330
|
// { start, end, inner } (string offsets; inner excludes the marker lines)
|
|
180
331
|
// or null when either marker is absent.
|
|
181
332
|
export function findSurfaceBlock(text) {
|
|
182
|
-
|
|
333
|
+
return findMarkedBlock(text, "surface");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Shared locator for the CLI-owned marker blocks (surface, changed).
|
|
337
|
+
// Returns { start, end, inner } as string offsets, or null when either
|
|
338
|
+
// marker is missing.
|
|
339
|
+
function findMarkedBlock(text, name) {
|
|
340
|
+
// Note the doubled backslashes: these are TEMPLATE literals, so `\s`
|
|
341
|
+
// would collapse to a bare "s" before the RegExp ever sees it.
|
|
342
|
+
const begin = text.match(new RegExp(`<!--\\s*doctrina:${name}:begin[\\s\\S]*?-->`));
|
|
183
343
|
if (!begin) return null;
|
|
184
|
-
const endRe =
|
|
344
|
+
const endRe = new RegExp(`<!--\\s*doctrina:${name}:end\\s*-->`);
|
|
185
345
|
const end = endRe.exec(text.slice(begin.index + begin[0].length));
|
|
186
346
|
if (!end) return null;
|
|
187
347
|
const innerStart = begin.index + begin[0].length;
|
|
@@ -214,3 +374,56 @@ export function referencedCommands(markdown) {
|
|
|
214
374
|
}
|
|
215
375
|
return out;
|
|
216
376
|
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// The agent-facing changelog (M2, part 3).
|
|
380
|
+
//
|
|
381
|
+
// Reported from real use: when a new command shipped, the LLM driving
|
|
382
|
+
// Doctrina had no idea the capability existed. It only found out by running
|
|
383
|
+
// `doctrina --help` — which it had no reason to run, because nothing told
|
|
384
|
+
// it anything had changed.
|
|
385
|
+
//
|
|
386
|
+
// CHANGELOG.md is 46 KB of human prose and far too large to carry into a
|
|
387
|
+
// context window. This is three to six lines stating only what alters
|
|
388
|
+
// AGENT behaviour: a new capability, a changed trigger, a removed command.
|
|
389
|
+
// `upgrade --write` refreshes it, so an agent reading AGENTS.md after an
|
|
390
|
+
// upgrade learns what is new without being told to look.
|
|
391
|
+
|
|
392
|
+
export const AGENT_CHANGELOG_BEGIN =
|
|
393
|
+
"<!-- doctrina:changed:begin — CLI-owned. Regenerated by `doctrina upgrade --write`. -->";
|
|
394
|
+
export const AGENT_CHANGELOG_END = "<!-- doctrina:changed:end -->";
|
|
395
|
+
|
|
396
|
+
// What changed for an AGENT, per version. Keyed by the version that
|
|
397
|
+
// introduced it. Only entries that alter what an agent should DO belong
|
|
398
|
+
// here — a bug fix nobody's behaviour depends on does not.
|
|
399
|
+
export const AGENT_CHANGELOG = {
|
|
400
|
+
"0.14.0": [
|
|
401
|
+
"`doctrina adapter add|remove|list` — add an agent without re-scaffolding; `init --force` no longer overwrites authored AGENTS.md/product.md.",
|
|
402
|
+
"`doctrina change apply` now refuses what `analyze` refuses; `change check` previews a close, `change tick` checks boxes in bulk.",
|
|
403
|
+
"Exit codes are a 5-class contract: 1 gate, 2 usage, 3 precondition, 4 environment. Branch on the code, not the prose.",
|
|
404
|
+
"A change that alters a documented surface must carry its docs — `close` refuses otherwise (`--force` records the gap).",
|
|
405
|
+
"Project templates under `.doctrina/templates/` now override the bundled ones, per file.",
|
|
406
|
+
],
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
// The block written into AGENTS.md for a given version. Empty string when
|
|
410
|
+
// that version has no agent-visible changes.
|
|
411
|
+
export function agentChangelogMarkdown(version) {
|
|
412
|
+
const entries = AGENT_CHANGELOG[version];
|
|
413
|
+
if (!entries || entries.length === 0) return "";
|
|
414
|
+
return [
|
|
415
|
+
`## What changed in ${version}`,
|
|
416
|
+
"",
|
|
417
|
+
...entries.map((e) => `- ${e}`),
|
|
418
|
+
].join("\n");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function agentChangelogBlock(version) {
|
|
422
|
+
const md = agentChangelogMarkdown(version);
|
|
423
|
+
if (!md) return "";
|
|
424
|
+
return `${AGENT_CHANGELOG_BEGIN}\n${md}\n${AGENT_CHANGELOG_END}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function findAgentChangelogBlock(text) {
|
|
428
|
+
return findMarkedBlock(text, "changed");
|
|
429
|
+
}
|
package/src/lib/criteria.js
CHANGED
package/src/lib/dates.js
CHANGED
package/src/lib/diff.js
CHANGED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The one owner of the on-disk artifact grammar (audit item M3).
|
|
3
|
+
//
|
|
4
|
+
// `.doctrina/` is Doctrina's public API, and its specification had ten
|
|
5
|
+
// homes. The ADR `Status:` grammar existed as ten regex literals across
|
|
6
|
+
// three files, and they were NOT equivalent — some captured, one only
|
|
7
|
+
// tested, two were anchored to end-of-line and so silently failed on a
|
|
8
|
+
// trailing space. Section extraction was reimplemented six times under six
|
|
9
|
+
// names: `extractSection` (twice), `sectionOf`, `sectionParagraph`,
|
|
10
|
+
// `productSection`, and the sections map in `spec-ops`.
|
|
11
|
+
//
|
|
12
|
+
// This violates the framework's own design principle #1 — "single
|
|
13
|
+
// ownership of every fact; no information has two homes."
|
|
14
|
+
//
|
|
15
|
+
// The contract here:
|
|
16
|
+
//
|
|
17
|
+
// LENIENT ON READ. `- **Status**: x` and `**Status:** x` both parse, and
|
|
18
|
+
// a non-canonical form is REPORTED (so `validate --fix` can repair it)
|
|
19
|
+
// rather than silently accepted or silently missed.
|
|
20
|
+
// STRICT ON WRITE. One canonical form per artifact kind, always.
|
|
21
|
+
|
|
22
|
+
// Artifact kinds and the header form each one canonically uses. Specs and
|
|
23
|
+
// contracts use bare bold; ADRs, proposals, and the intake use list items.
|
|
24
|
+
// Getting this backwards was a documented, recurring authoring mistake, so
|
|
25
|
+
// the CLI now decides it rather than the author remembering.
|
|
26
|
+
export const HEADER_STYLE = Object.freeze({
|
|
27
|
+
BARE: "bare", // **Status:** active
|
|
28
|
+
LIST: "list", // - **Status:** accepted
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const ARTIFACT_KIND = Object.freeze({
|
|
32
|
+
SPEC: "spec",
|
|
33
|
+
CONTRACT: "contract",
|
|
34
|
+
DECISION: "decision",
|
|
35
|
+
PROPOSAL: "proposal",
|
|
36
|
+
INTAKE: "intake",
|
|
37
|
+
PRODUCT: "product",
|
|
38
|
+
UNKNOWN: "unknown",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const STYLE_BY_KIND = {
|
|
42
|
+
[ARTIFACT_KIND.SPEC]: HEADER_STYLE.BARE,
|
|
43
|
+
[ARTIFACT_KIND.CONTRACT]: HEADER_STYLE.BARE,
|
|
44
|
+
[ARTIFACT_KIND.PRODUCT]: HEADER_STYLE.BARE,
|
|
45
|
+
[ARTIFACT_KIND.DECISION]: HEADER_STYLE.LIST,
|
|
46
|
+
[ARTIFACT_KIND.PROPOSAL]: HEADER_STYLE.LIST,
|
|
47
|
+
[ARTIFACT_KIND.INTAKE]: HEADER_STYLE.LIST,
|
|
48
|
+
[ARTIFACT_KIND.UNKNOWN]: HEADER_STYLE.BARE,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function canonicalStyle(kind) {
|
|
52
|
+
return STYLE_BY_KIND[kind] ?? HEADER_STYLE.BARE;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Infer the artifact kind from its path. Deterministic; never reads content.
|
|
56
|
+
export function kindFromPath(relPathPosix) {
|
|
57
|
+
const p = String(relPathPosix).replace(/\\/g, "/");
|
|
58
|
+
if (/\/specs\/[^/]+\/spec\.md$/.test(p)) return ARTIFACT_KIND.SPEC;
|
|
59
|
+
if (/\/contracts\/[^/]+\.md$/.test(p)) return ARTIFACT_KIND.CONTRACT;
|
|
60
|
+
if (/\/decisions\/\d{4}-.*\.md$/.test(p)) return ARTIFACT_KIND.DECISION;
|
|
61
|
+
if (/\/changes\/.*\/proposal\.md$/.test(p)) return ARTIFACT_KIND.PROPOSAL;
|
|
62
|
+
if (/\/intake\.md$/.test(p)) return ARTIFACT_KIND.INTAKE;
|
|
63
|
+
if (/\/product\.md$/.test(p)) return ARTIFACT_KIND.PRODUCT;
|
|
64
|
+
return ARTIFACT_KIND.UNKNOWN;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function escapeRe(s) {
|
|
68
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// THE header grammar. One definition.
|
|
72
|
+
//
|
|
73
|
+
// Lenient by construction: the leading "- " is optional, the colon may sit
|
|
74
|
+
// inside or outside the bold span (`**Status:**` or `**Status**:`), and the
|
|
75
|
+
// value runs to end of line with surrounding space trimmed by the caller.
|
|
76
|
+
// Anchoring to `$` without allowing trailing whitespace is what made two of
|
|
77
|
+
// the old copies fail on files saved with a trailing space.
|
|
78
|
+
// A metadata header REQUIRES a colon, inside or outside the bold span.
|
|
79
|
+
// Without that, bold text used as a pseudo-heading in prose — the
|
|
80
|
+
// `**Positive**` / `**Negative**` / `**Neutral**` blocks every ADR carries —
|
|
81
|
+
// parses as a header and floods the conformance report.
|
|
82
|
+
function headerPattern(name) {
|
|
83
|
+
const n = escapeRe(name);
|
|
84
|
+
return new RegExp(`^([ \\t]*)(-[ \\t]+)?\\*\\*${n}[ \\t]*(?::\\*\\*|\\*\\*[ \\t]*:)[ \\t]*(.*?)[ \\t]*$`, "m");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Read one header. Returns { value, style, conforming, line } or null.
|
|
88
|
+
/**
|
|
89
|
+
* @returns {{ value: string, style: string, indent: string, raw: string,
|
|
90
|
+
* line: number, conforming: boolean } | null}
|
|
91
|
+
*/
|
|
92
|
+
export function readHeader(text, name) {
|
|
93
|
+
const m = headerPattern(name).exec(String(text));
|
|
94
|
+
if (!m) return null;
|
|
95
|
+
const [raw, indent, dash, value] = m;
|
|
96
|
+
const style = dash ? HEADER_STYLE.LIST : HEADER_STYLE.BARE;
|
|
97
|
+
// Canonical is exactly `**Name:** value` (or `- **Name:** value`).
|
|
98
|
+
const canonical = `${dash ? "- " : ""}**${name}:** ${value}`.replace(/\s+$/, "");
|
|
99
|
+
return {
|
|
100
|
+
value: value.trim(),
|
|
101
|
+
style,
|
|
102
|
+
indent,
|
|
103
|
+
raw,
|
|
104
|
+
line: text.slice(0, m.index).split("\n").length,
|
|
105
|
+
conforming: raw.trim() === canonical.trim(),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The value only, or null. The replacement for specHeader/listHeader and
|
|
110
|
+
// every ad-hoc `match(/^-\s+\*\*Status:\*\*.../)`.
|
|
111
|
+
export function getHeader(text, name) {
|
|
112
|
+
return readHeader(text, name)?.value ?? null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Write a header in the canonical form for `style`, preserving the style
|
|
116
|
+
// already present when the header exists. Returns the new text, or null
|
|
117
|
+
// when the header is absent — a set that silently does nothing is exactly
|
|
118
|
+
// the drift this prevents.
|
|
119
|
+
/**
|
|
120
|
+
* @param {string} text
|
|
121
|
+
* @param {string} name
|
|
122
|
+
* @param {string} value
|
|
123
|
+
* @param {{ style?: string }} [opts] Force a header style; defaults to the
|
|
124
|
+
* style already present, so a set never silently reformats the artifact.
|
|
125
|
+
*/
|
|
126
|
+
export function setHeader(text, name, value, opts = {}) {
|
|
127
|
+
const { style } = opts;
|
|
128
|
+
const existing = readHeader(text, name);
|
|
129
|
+
if (!existing) return null;
|
|
130
|
+
const useStyle = style ?? existing.style;
|
|
131
|
+
const prefix = useStyle === HEADER_STYLE.LIST ? "- " : "";
|
|
132
|
+
const replacement = `${existing.indent}${prefix}**${name}:** ${value}`.replace(/\s+$/, "");
|
|
133
|
+
return String(text).replace(headerPattern(name), replacement);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The PREAMBLE: everything before the first `## ` section. Metadata headers
|
|
137
|
+
// live there in every Doctrina artifact, and only there. Bold text ending in
|
|
138
|
+
// a colon further down is a prose lead-in ("**Standing constraints lived in
|
|
139
|
+
// agent memory:** ..."), not a header — scoping conformance to the preamble
|
|
140
|
+
// is what tells the two apart without guessing.
|
|
141
|
+
export function preamble(text) {
|
|
142
|
+
const s = String(text);
|
|
143
|
+
const m = /^##\s+/m.exec(s);
|
|
144
|
+
return m ? s.slice(0, m.index) : s;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Every header in the document's preamble, in order, with conformance
|
|
148
|
+
// recorded.
|
|
149
|
+
export function readAllHeaders(fullText) {
|
|
150
|
+
const text = preamble(fullText);
|
|
151
|
+
const out = [];
|
|
152
|
+
const re = /^([ \t]*)(-[ \t]+)?\*\*([A-Za-z][A-Za-z .-]*?)[ \t]*(?::\*\*|\*\*[ \t]*:)[ \t]*(.*?)[ \t]*$/gm;
|
|
153
|
+
let m;
|
|
154
|
+
while ((m = re.exec(String(text)))) {
|
|
155
|
+
const [raw, indent, dash, name, value] = m;
|
|
156
|
+
const style = dash ? HEADER_STYLE.LIST : HEADER_STYLE.BARE;
|
|
157
|
+
const canonical = `${indent}${dash ? "- " : ""}**${name}:** ${value}`.replace(/\s+$/, "");
|
|
158
|
+
out.push({
|
|
159
|
+
name, value: value.trim(), style, indent,
|
|
160
|
+
line: String(text).slice(0, m.index).split("\n").length,
|
|
161
|
+
conforming: raw.replace(/\s+$/, "") === canonical,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Headers whose written form is recognised but not canonical, optionally
|
|
168
|
+
// also flagging the wrong style for a known artifact kind. This is what
|
|
169
|
+
// `validate` reports and `validate --fix` repairs.
|
|
170
|
+
/** @param {string} text @param {string} [kind] */
|
|
171
|
+
export function nonConformingHeaders(text, kind = ARTIFACT_KIND.UNKNOWN) {
|
|
172
|
+
const want = canonicalStyle(kind);
|
|
173
|
+
const out = [];
|
|
174
|
+
for (const h of readAllHeaders(text)) {
|
|
175
|
+
const wrongStyle = kind !== ARTIFACT_KIND.UNKNOWN && h.style !== want;
|
|
176
|
+
if (!h.conforming || wrongStyle) {
|
|
177
|
+
out.push({ ...h, wrongStyle, expectedStyle: want });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Rewrite every recognised header into the canonical form for `kind`.
|
|
184
|
+
// Content is never touched — only the header's own punctuation and, when
|
|
185
|
+
// the kind is known, its style.
|
|
186
|
+
/** @param {string} text @param {string} [kind] */
|
|
187
|
+
export function repairHeaders(text, kind = ARTIFACT_KIND.UNKNOWN) {
|
|
188
|
+
const want = canonicalStyle(kind);
|
|
189
|
+
const lines = String(text).split("\n");
|
|
190
|
+
// Repair only the PREAMBLE, and only lines that carry a colon — the same
|
|
191
|
+
// two constraints reading uses. Without them this rewrote ADR prose:
|
|
192
|
+
// `**Positive**` became `- **Positive:**`, and a bulleted definition list
|
|
193
|
+
// `- **dropped intent** — ...` became `- **dropped intent:** — ...`.
|
|
194
|
+
// A repair that edits prose is worse than the drift it fixes.
|
|
195
|
+
const firstSection = lines.findIndex((l) => /^##\s+/.test(l));
|
|
196
|
+
const limit = firstSection < 0 ? lines.length : firstSection;
|
|
197
|
+
let repaired = 0;
|
|
198
|
+
for (let i = 0; i < limit; i++) {
|
|
199
|
+
// Match against the line without its carriage return. On a CRLF
|
|
200
|
+
// checkout the trailing \r is not [ \t], so anchoring to $ silently
|
|
201
|
+
// matched nothing and repair became a no-op on exactly the files most
|
|
202
|
+
// likely to need it.
|
|
203
|
+
const line = lines[i].replace(/\r$/, "");
|
|
204
|
+
const eol = lines[i].endsWith("\r") ? "\r" : "";
|
|
205
|
+
const m = /^([ \t]*)(-[ \t]+)?\*\*([A-Za-z][A-Za-z .-]*?)[ \t]*(?::\*\*|\*\*[ \t]*:)[ \t]*(.*?)[ \t]*$/.exec(line);
|
|
206
|
+
if (!m) continue;
|
|
207
|
+
const [, indent, dash, name, value] = m;
|
|
208
|
+
const style = kind === ARTIFACT_KIND.UNKNOWN ? (dash ? HEADER_STYLE.LIST : HEADER_STYLE.BARE) : want;
|
|
209
|
+
const canonical = `${indent}${style === HEADER_STYLE.LIST ? "- " : ""}**${name}:** ${value}`.replace(/[ \t]+$/, "");
|
|
210
|
+
if (line.replace(/[ \t]+$/, "") !== canonical) {
|
|
211
|
+
// Preserve the file's existing line ending: repairing a header must
|
|
212
|
+
// not rewrite every line of a CRLF file as a side effect.
|
|
213
|
+
lines[i] = canonical + eol;
|
|
214
|
+
repaired += 1;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { text: lines.join("\n"), repaired };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------- sections
|
|
221
|
+
|
|
222
|
+
// The body of a `## <name>` section: lines after the heading, up to the
|
|
223
|
+
// next heading of the same or higher level. Replaces six near-identical
|
|
224
|
+
// helpers that disagreed about level handling and trailing whitespace.
|
|
225
|
+
export function getSection(text, name, { level = 2 } = {}) {
|
|
226
|
+
const lines = String(text).split(/\r?\n/);
|
|
227
|
+
const head = new RegExp(`^#{${level}}\\s+${escapeRe(name)}\\b`, "i");
|
|
228
|
+
const boundary = new RegExp(`^#{1,${level}}\\s+`);
|
|
229
|
+
const out = [];
|
|
230
|
+
let inSection = false;
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
if (inSection && boundary.test(line)) break;
|
|
233
|
+
if (!inSection && head.test(line)) { inSection = true; continue; }
|
|
234
|
+
if (inSection) out.push(line);
|
|
235
|
+
}
|
|
236
|
+
return inSection ? out.join("\n") : "";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function hasSection(text, name, { level = 2 } = {}) {
|
|
240
|
+
return new RegExp(`^#{${level}}\\s+${escapeRe(name)}\\b`, "im").test(String(text));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The first non-empty, non-comment paragraph of a section — what `why` and
|
|
244
|
+
// `skill suggest` actually wanted when they each rolled their own.
|
|
245
|
+
export function getSectionParagraph(text, name, { level = 2 } = {}) {
|
|
246
|
+
const body = getSection(text, name, { level });
|
|
247
|
+
const para = [];
|
|
248
|
+
for (const line of body.split("\n")) {
|
|
249
|
+
const trimmed = line.trim();
|
|
250
|
+
if (trimmed.startsWith("<!--")) continue;
|
|
251
|
+
if (trimmed === "") {
|
|
252
|
+
if (para.length > 0) break;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
para.push(trimmed);
|
|
256
|
+
}
|
|
257
|
+
return para.join(" ");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Every `## ` heading in document order.
|
|
261
|
+
export function listSections(text, { level = 2 } = {}) {
|
|
262
|
+
const re = new RegExp(`^#{${level}}\\s+(.+)$`, "gm");
|
|
263
|
+
const out = [];
|
|
264
|
+
let m;
|
|
265
|
+
while ((m = re.exec(String(text)))) out.push(m[1].trim());
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The document's H1 title, or null.
|
|
270
|
+
export function getTitle(text) {
|
|
271
|
+
const m = /^#\s+(.+)$/m.exec(String(text));
|
|
272
|
+
return m ? m[1].trim() : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// One parse of an artifact: title, headers, section names. The shape
|
|
276
|
+
// `parseArtifact` promises, so callers stop re-deriving it.
|
|
277
|
+
export function parseArtifact(text, { kind = ARTIFACT_KIND.UNKNOWN } = {}) {
|
|
278
|
+
const headers = readAllHeaders(text);
|
|
279
|
+
return {
|
|
280
|
+
kind,
|
|
281
|
+
title: getTitle(text),
|
|
282
|
+
headers,
|
|
283
|
+
headerMap: new Map(headers.map((h) => [h.name, h.value])),
|
|
284
|
+
sections: listSections(text),
|
|
285
|
+
nonConforming: nonConformingHeaders(text, kind),
|
|
286
|
+
};
|
|
287
|
+
}
|