doctrina-cli 0.16.0 → 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 +3 -2
- package/src/commands/analyze.js +8 -0
- package/src/commands/change.js +28 -77
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +65 -10
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +6 -4
- package/src/commands/decision.js +12 -0
- package/src/commands/doctor.js +1 -1
- package/src/commands/hooks.js +6 -1
- package/src/commands/index-rebuild.js +52 -2
- package/src/commands/init.js +19 -4
- package/src/commands/intake.js +55 -7
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +13 -5
- package/src/commands/next.js +1 -1
- package/src/commands/prime.js +4 -5
- package/src/commands/report.js +9 -20
- package/src/commands/skill.js +8 -15
- package/src/commands/spec.js +13 -7
- package/src/commands/status.js +39 -20
- package/src/commands/templates.js +4 -10
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +32 -8
- package/src/commands/work.js +10 -5
- package/src/index.js +34 -6
- package/src/lib/actions.js +30 -24
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +3 -1
- package/src/lib/commands.js +88 -15
- package/src/lib/coverage-model.js +40 -1
- package/src/lib/doc-model.js +47 -6
- package/src/lib/docs-impact.js +74 -0
- package/src/lib/ears.js +24 -1
- package/src/lib/gates.js +53 -9
- package/src/lib/index-json.js +40 -5
- package/src/lib/intake-model.js +71 -2
- package/src/lib/lexicon.js +38 -1
- package/src/lib/metrics-model.js +30 -0
- package/src/lib/project.js +49 -1
- package/src/lib/scan.js +126 -14
- package/src/lib/snapshot.js +23 -1
- package/src/lib/spec-ops.js +24 -5
- package/src/lib/templates-model.js +35 -8
- package/src/lib/validation-model.js +116 -34
- package/src/lib/views.js +4 -1
- package/src/lib/work-model.js +25 -0
- package/templates/AGENTS.md.template +3 -4
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +15 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +10 -0
- package/templates/playbooks/bootstrap.md.template +6 -2
- package/templates/playbooks/chore.md.template +9 -4
- package/templates/playbooks/work.md.template +10 -3
- package/src/commands/constitution.js +0 -68
package/src/lib/commands.js
CHANGED
|
@@ -16,7 +16,7 @@ export const COMMAND_NAMES = [
|
|
|
16
16
|
// authoring
|
|
17
17
|
"spec", "change", "contract", "decision", "skill", "intent", "adapter",
|
|
18
18
|
// read / orient
|
|
19
|
-
"prime", "context", "show", "search", "status", "next", "why", "handoff",
|
|
19
|
+
"prime", "context", "show", "search", "status", "next", "why", "handoff",
|
|
20
20
|
// gates
|
|
21
21
|
"analyze", "clarify", "validate", "coverage", "trace", "review", "verify", "close", "doctor",
|
|
22
22
|
// maintenance
|
|
@@ -46,7 +46,6 @@ export const OPERATIONS = [
|
|
|
46
46
|
["change archive", "Archive an applied change (batch ids ok)"],
|
|
47
47
|
["change check", "Pre-close dry-run: everything close would refuse, listed first"],
|
|
48
48
|
["change tick", "List/tick the unchecked boxes (tasks + verification; --all)"],
|
|
49
|
-
["change diff", "Preview spec deltas (line diff for MODIFIED)"],
|
|
50
49
|
["change abandon", "Discard an open change cleanly (recorded in the ledger)"],
|
|
51
50
|
["contract new", "Own the integration surface (ports, env, interfaces)"],
|
|
52
51
|
["contract list", "List contracts with status and last-updated date"],
|
|
@@ -82,7 +81,6 @@ export const OPERATIONS = [
|
|
|
82
81
|
["status", "One-glance project health dashboard"],
|
|
83
82
|
["why", "Explain provenance: a capability's chain, or an anchor's (SC1)"],
|
|
84
83
|
["handoff", "Markdown handoff note: open work, task state, resume command"],
|
|
85
|
-
["constitution", "Print the standing rules: accepted ADRs + product non-goals"],
|
|
86
84
|
["templates list", "List the templates shipped by the installed CLI"],
|
|
87
85
|
["templates check", "Compare the project against the recommended template shape"],
|
|
88
86
|
["templates update", "Additive fixer for check findings (preview; --write applies)"],
|
|
@@ -126,13 +124,12 @@ export const COMMAND_META = {
|
|
|
126
124
|
show: { moment: "Orient", when: "you need one requirement, criterion, or ADR, not a file", purpose: "point-read a single artifact by reference" },
|
|
127
125
|
search: { moment: "Orient", when: "you do not know which artifact mentions a term", purpose: "search the artifact tree, grouped by category" },
|
|
128
126
|
why: { moment: "Orient", when: "you need to justify or trace a capability's existence", purpose: "provenance: intent, proof, ADRs, and history" },
|
|
129
|
-
constitution: { moment: "Orient", when: "you need the standing rules before deciding something", purpose: "accepted ADRs and product non-goals" },
|
|
130
127
|
handoff: { moment: "Orient", when: "BEFORE compaction or handing over to another session", purpose: "a resume note: open work, task state, next command" },
|
|
131
128
|
|
|
132
129
|
triage: { moment: "Change", when: "a request arrives — BEFORE scaffolding, especially if it smells like an incident", purpose: "classify the lane (product/runtime/chore) and check the declared runtime surface" },
|
|
133
130
|
work: { moment: "Change", when: "a request arrives that changes behaviour (triage says PRODUCT)", purpose: "scaffold a change and print the playbook to execute" },
|
|
134
131
|
spec: { moment: "Change", when: "a capability needs creating or its headers advancing", purpose: "create, list, and edit capability specs" },
|
|
135
|
-
change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "
|
|
132
|
+
change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "drive a change by hand — check --verbose previews every delta" },
|
|
136
133
|
decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
|
|
137
134
|
contract: { moment: "Change", when: "the change touches ports, env vars, or public endpoints", purpose: "own and verify the integration surface" },
|
|
138
135
|
intent: { moment: "Change", when: "new product intent appears after the intake", purpose: "append and list product intent anchors" },
|
|
@@ -148,7 +145,7 @@ export const COMMAND_META = {
|
|
|
148
145
|
close: { moment: "Gate", when: "a change is implemented and ready to finish", purpose: "the whole closing sequence in one attested pass" },
|
|
149
146
|
doctor: { moment: "Gate", when: "something looks wrong and you do not know which gate to ask", purpose: "aggregate diagnostic with per-finding remedies" },
|
|
150
147
|
|
|
151
|
-
templates: { moment: "Maintain", when: "
|
|
148
|
+
templates: { moment: "Maintain", when: "customising a scaffold, to see which template file wins", purpose: "where each shipped template resolves from" },
|
|
152
149
|
upgrade: { moment: "Maintain", when: "you just updated the doctrina-cli package", purpose: "bring this project up to the installed CLI" },
|
|
153
150
|
index: { moment: "Maintain", when: "the index drifted from the tree", purpose: "regenerate index.json from the artifacts on disk" },
|
|
154
151
|
hooks: { moment: "Maintain", when: "setting up a repo so drift cannot be committed", purpose: "install the pre-commit gate" },
|
|
@@ -164,10 +161,49 @@ export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Maintain"];
|
|
|
164
161
|
|
|
165
162
|
// The Commands block of `doctrina --help`, generated from OPERATIONS so the
|
|
166
163
|
// printed surface can never omit an operation the catalog knows about.
|
|
164
|
+
//
|
|
165
|
+
// Grouped by the MOMENT an operation is reached for, in the same order as
|
|
166
|
+
// the AGENTS.md block, and opened by the two lines a newcomer needs: a flat
|
|
167
|
+
// list of sixty operations, deprecated ones mixed in, answered "where do I
|
|
168
|
+
// start?" with the whole catalog (change 0174). Deprecated operations stay
|
|
169
|
+
// listed — someone types those names — but last, each pointing at its
|
|
170
|
+
// replacement.
|
|
167
171
|
export function surfaceHelp() {
|
|
168
|
-
|
|
172
|
+
// A name longer than the column puts its text on the next line, aligned,
|
|
173
|
+
// so the summaries stay one scannable column at 80 characters.
|
|
174
|
+
const line = (op, text) => op.length < 20
|
|
175
|
+
? ` ${op.padEnd(21)}${text}`
|
|
176
|
+
: ` ${op}\n ${"".padEnd(21)}${text}`;
|
|
177
|
+
const out = [
|
|
178
|
+
"Start here:",
|
|
179
|
+
line("doctrina init", "set up this repository (asks for a one-line description)"),
|
|
180
|
+
line("doctrina next", "what to do now — your agent runs the rest from AGENTS.md"),
|
|
181
|
+
];
|
|
182
|
+
// One line per COMMAND, its subcommands joined: the summary a newcomer
|
|
183
|
+
// scans; `doctrina <command> --help` has each subcommand's detail.
|
|
184
|
+
const live = OPERATIONS.filter(([op]) => !DEPRECATED[op]);
|
|
185
|
+
for (const moment of MOMENTS) {
|
|
186
|
+
const cmds = [...new Set(live.map(([op]) => op.split(" ")[0]))]
|
|
187
|
+
.filter((cmd) => COMMAND_META[cmd]?.moment === moment);
|
|
188
|
+
if (cmds.length === 0) continue;
|
|
189
|
+
out.push("", `${moment}:`);
|
|
190
|
+
for (const cmd of cmds) {
|
|
191
|
+
const ops = live.filter(([op]) => op.split(" ")[0] === cmd);
|
|
192
|
+
const subs = ops.map(([op]) => op.split(" ")[1]).filter(Boolean);
|
|
193
|
+
const text = ops.length === 1 ? ops[0][1] : capitalise(COMMAND_META[cmd].purpose);
|
|
194
|
+
out.push(line(subs.length > 0 ? `${cmd} ${subs.join("|")}` : cmd, text));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const old = OPERATIONS.filter(([op]) => DEPRECATED[op]);
|
|
198
|
+
if (old.length > 0) {
|
|
199
|
+
out.push("", "Deprecated (still run, with a notice on stderr):",
|
|
200
|
+
...old.map(([op]) => line(op, `→ ${DEPRECATED[op].use}`)));
|
|
201
|
+
}
|
|
202
|
+
return out.join("\n");
|
|
169
203
|
}
|
|
170
204
|
|
|
205
|
+
const capitalise = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
206
|
+
|
|
171
207
|
// ---------------------------------------------------------------------------
|
|
172
208
|
// The CLI-owned AGENTS.md command-surface block (operator review 2026-07-19,
|
|
173
209
|
// meta-conclusion): for an agent, AGENTS.md IS the discovery interface — it
|
|
@@ -219,18 +255,48 @@ const SURFACE_HINTS = {
|
|
|
219
255
|
// leaves the surface block — an agent should not reach for it, and the block
|
|
220
256
|
// is the list of things to reach for. Removal is a later, separate change.
|
|
221
257
|
export const DEPRECATED = Object.freeze({
|
|
222
|
-
"
|
|
223
|
-
since: "0.
|
|
224
|
-
use: "doctrina
|
|
225
|
-
why: "
|
|
258
|
+
"analyze": {
|
|
259
|
+
since: "0.17.0",
|
|
260
|
+
use: "doctrina change check",
|
|
261
|
+
why: "check reports the same structural checks first, then the ops dry-run and the archive-gate preview — it answers 'would the close pass?', so it also exits 1 while a Verification box is open; `change apply` still refuses what analyze refused",
|
|
226
262
|
},
|
|
227
|
-
"
|
|
228
|
-
since: "0.
|
|
229
|
-
use: "doctrina
|
|
230
|
-
why: "
|
|
263
|
+
"report": {
|
|
264
|
+
since: "0.17.0",
|
|
265
|
+
use: "doctrina status --view report",
|
|
266
|
+
why: "status renders the same digest from the same collector; --agent-changelog is status --view agent-changelog",
|
|
231
267
|
},
|
|
268
|
+
"skill sync": {
|
|
269
|
+
since: "0.17.0",
|
|
270
|
+
use: "doctrina index rebuild",
|
|
271
|
+
why: "the rebuild registers every skill and mirrors its description, keeping a written one over a scaffold placeholder",
|
|
272
|
+
},
|
|
273
|
+
"templates check": {
|
|
274
|
+
since: "0.17.0",
|
|
275
|
+
use: "doctrina upgrade",
|
|
276
|
+
why: "the upgrade preview reports every template finding, each with its fix, and exits 1 while one is left",
|
|
277
|
+
},
|
|
278
|
+
"templates update": {
|
|
279
|
+
since: "0.17.0",
|
|
280
|
+
use: "doctrina upgrade --write",
|
|
281
|
+
why: "the upgrade's first step is this update, previewed without --write and applied with it",
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// A deprecated operation that has since been REMOVED. Typing the old name
|
|
286
|
+
// answers with its replacement instead of "unknown command, did you mean
|
|
287
|
+
// ...?" — whoever still has it in a script or a habit learns the one thing
|
|
288
|
+
// they need. Usage class: the fix is the invocation (ADR 0018).
|
|
289
|
+
export const REMOVED = Object.freeze({
|
|
290
|
+
"constitution": { since: "0.17.0", use: "doctrina prime --rules" },
|
|
291
|
+
"change diff": { since: "0.17.0", use: "doctrina change check --verbose" },
|
|
232
292
|
});
|
|
233
293
|
|
|
294
|
+
/** The removal record for an invocation, or null. */
|
|
295
|
+
export function removalFor(argv) {
|
|
296
|
+
const words = argv.filter((a) => !a.startsWith("-"));
|
|
297
|
+
return REMOVED[words.slice(0, 2).join(" ")] ?? REMOVED[words[0] ?? ""] ?? null;
|
|
298
|
+
}
|
|
299
|
+
|
|
234
300
|
/** The deprecation record for an invocation, or null. */
|
|
235
301
|
export function deprecationFor(argv) {
|
|
236
302
|
const words = argv.filter((a) => !a.startsWith("-"));
|
|
@@ -450,6 +516,13 @@ export const AGENT_CHANGELOG = {
|
|
|
450
516
|
// Each entry lists only ITS OWN delta. The series carries the rest
|
|
451
517
|
// forward (see agentChangelogEntries), so a patch never has to restate
|
|
452
518
|
// the minor that introduced the commands, and never silently erases it.
|
|
519
|
+
"0.17.0": [
|
|
520
|
+
"Preview a close with `doctrina change check <id>` (it exits 1 while a Verification box is open), then run `doctrina close <id>` — do not run `analyze`, `change apply` or `change archive` by hand; a chore closes the same way.",
|
|
521
|
+
"Deprecated names still run with a notice — use the replacement: `analyze` → `change check`, `report` → `status --view report`, `skill sync` → `index rebuild`, `templates check|update` → `upgrade [--write]`. `constitution` and `change diff` are removed (exit 2).",
|
|
522
|
+
"`doctrina upgrade` now reports an installed slash command that still teaches a retired command; run the fix it prints (`doctrina adapter add <agent> --force`).",
|
|
523
|
+
"`doctrina work \"<prompt>\" --design` also scaffolds design.md — no need to fall back to `change new` for a change with non-trivial choices.",
|
|
524
|
+
"A new tasks.md has no Closing steps — the close applies, archives and indexes. `intake --converted` refuses while `validate` reports an error; a change reference is its folder name, never a path (exit 2).",
|
|
525
|
+
],
|
|
453
526
|
"0.16.0": [
|
|
454
527
|
"A reference or path that does not resolve now answers exit 2 (USAGE) everywhere — `clarify`, `show`, `why`, `analyze`, `context`, `change check`, `spec set`, `decision accept|scope`. Correct the invocation; retrying it unchanged never succeeds.",
|
|
455
528
|
"The `--json` envelope names the OPERATION in `command` and carries arguments in `args` — branch on those two, not on a joined string. An undeclared flag now answers with `{ok: false, exit_code: 2}` instead of an empty stdout.",
|
|
@@ -272,6 +272,35 @@ function classifyOrchestration(criterion, n, projectRoot) {
|
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
// Decide whether a single criterion is covered, conditional, dangling, or bare.
|
|
275
|
+
// Where a criterion stops DESCRIBING and starts CITING.
|
|
276
|
+
//
|
|
277
|
+
// The tree's grammar for an acceptance criterion is "<observable signal> —
|
|
278
|
+
// verified by `<proof>`", and 212 of them across ten specs follow it. Both
|
|
279
|
+
// halves can contain backtick paths, and they mean opposite things: the paths
|
|
280
|
+
// after the marker are what the criterion offers as proof; the paths before it
|
|
281
|
+
// are the SCENARIO it describes.
|
|
282
|
+
//
|
|
283
|
+
// Change 0106 taught this module to report a cited path that does not resolve
|
|
284
|
+
// even when another one does — right, because one proof resolving does not
|
|
285
|
+
// make a second one true. But it could not see the halves, so a criterion that
|
|
286
|
+
// describes bad input by name was read as claiming that input as evidence.
|
|
287
|
+
// Criterion 72 of the gates spec says a loose `specs/legacy.md` draws a
|
|
288
|
+
// warning; those files exist only inside the test, and coverage reported them
|
|
289
|
+
// as unresolvable proof on every single run.
|
|
290
|
+
//
|
|
291
|
+
// A gate that always prints the same complaint teaches its reader to skip the
|
|
292
|
+
// line it prints it on, which costs more than the check was ever worth.
|
|
293
|
+
//
|
|
294
|
+
// Returns null for a criterion with no marker — then every backtick path is a
|
|
295
|
+
// claim, exactly as before, which is what a project not using this grammar
|
|
296
|
+
// gets.
|
|
297
|
+
const EVIDENCE_MARKER = /\b(?:verified|proven|evidenced|demonstrated)\s+by\b|\bverificad[oa]\s+por\b/i;
|
|
298
|
+
|
|
299
|
+
function evidenceClause(criterion) {
|
|
300
|
+
const m = EVIDENCE_MARKER.exec(criterion);
|
|
301
|
+
return m ? criterion.slice(m.index) : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
275
304
|
function classify(criterion, n, projectRoot, specDir) {
|
|
276
305
|
// An orchestration criterion is judged on its GUARD, not on whether a
|
|
277
306
|
// cited file exists — the whole point is that existence proves nothing here.
|
|
@@ -333,8 +362,18 @@ function classify(criterion, n, projectRoot, specDir) {
|
|
|
333
362
|
// `missing` rides along on a covered row: one citation resolving does
|
|
334
363
|
// not make the other one true, and the report names it (change 0106).
|
|
335
364
|
if (hasRealProof) {
|
|
365
|
+
// A `missing` entry may carry its reason — "x.md (a directory, not a
|
|
366
|
+
// file)" — so recover the bare token before asking where it was written.
|
|
367
|
+
const clause = evidenceClause(criterion);
|
|
368
|
+
const isClaimed = (entry) => {
|
|
369
|
+
if (clause === null) return true;
|
|
370
|
+
const token = entry.replace(/\s+\([^)]*\)$/, "");
|
|
371
|
+
return clause.includes(`\`${token}\``);
|
|
372
|
+
};
|
|
336
373
|
const claims = missing.filter((m) =>
|
|
337
|
-
!mentions.some((d) => m.startsWith(`${d} `))
|
|
374
|
+
!mentions.some((d) => m.startsWith(`${d} `))
|
|
375
|
+
&& /[\\/]/.test(m) && !/[\\/]$/.test(m)
|
|
376
|
+
&& isClaimed(m));
|
|
338
377
|
return { kind: "covered", n, evidence, unverified, missing: claims };
|
|
339
378
|
}
|
|
340
379
|
return { kind: "conditional", n, skipped: resolved.map((r) => r.token), evidence: [], unverified, missing };
|
package/src/lib/doc-model.js
CHANGED
|
@@ -93,7 +93,12 @@ function headerPattern(name) {
|
|
|
93
93
|
* line: number, conforming: boolean } | null}
|
|
94
94
|
*/
|
|
95
95
|
export function readHeader(text, name) {
|
|
96
|
-
|
|
96
|
+
// Match against the MASKED text so a header written inside an HTML comment
|
|
97
|
+
// — a template's example of the form, say — is never mistaken for one the
|
|
98
|
+
// author wrote. Masking replaces comment content with spaces and keeps
|
|
99
|
+
// every line terminator, so offsets and line numbers are the document's own
|
|
100
|
+
// and `raw` below is still the real line.
|
|
101
|
+
const m = headerPattern(name).exec(maskComments(String(text)));
|
|
97
102
|
if (!m) return null;
|
|
98
103
|
const [raw, indent, dash, value] = m;
|
|
99
104
|
const style = dash ? HEADER_STYLE.LIST : HEADER_STYLE.BARE;
|
|
@@ -133,7 +138,13 @@ export function setHeader(text, name, value, opts = {}) {
|
|
|
133
138
|
const useStyle = style ?? existing.style;
|
|
134
139
|
const prefix = useStyle === HEADER_STYLE.LIST ? "- " : "";
|
|
135
140
|
const replacement = `${existing.indent}${prefix}**${name}:** ${value}`.replace(/\s+$/, "");
|
|
136
|
-
|
|
141
|
+
// Write over the line `readHeader` actually found, by OFFSET. Replacing on
|
|
142
|
+
// a pattern would find the first textual match, which — now that reading
|
|
143
|
+
// skips annotation — may be an example inside a comment sitting above the
|
|
144
|
+
// real header. Masking preserves offsets, so the index is the real one.
|
|
145
|
+
const at = maskComments(String(text)).search(headerPattern(name));
|
|
146
|
+
if (at < 0) return null;
|
|
147
|
+
return String(text).slice(0, at) + String(text).slice(at).replace(headerPattern(name), replacement);
|
|
137
148
|
}
|
|
138
149
|
|
|
139
150
|
// The PREAMBLE: everything before the first `## ` section. Metadata headers
|
|
@@ -150,7 +161,22 @@ export function preamble(text) {
|
|
|
150
161
|
// Every header in the document's preamble, in order, with conformance
|
|
151
162
|
// recorded.
|
|
152
163
|
export function readAllHeaders(fullText) {
|
|
153
|
-
|
|
164
|
+
// AN HTML COMMENT IS ANNOTATION, NOT A HEADER.
|
|
165
|
+
//
|
|
166
|
+
// This module is the one owner of that rule — every other reader in the
|
|
167
|
+
// tree defers to it — and this function did not apply it. The preamble is
|
|
168
|
+
// exactly where a template puts its guidance, so an EXAMPLE header written
|
|
169
|
+
// to show an author the form counted as a header the author wrote.
|
|
170
|
+
//
|
|
171
|
+
// It showed as a disagreement rather than a wrong answer: `readHeader`
|
|
172
|
+
// takes the first match in the whole document, this one collects every
|
|
173
|
+
// match in the preamble, and a commented example made the two return
|
|
174
|
+
// different sets. A round-trip test on the real tree caught it the moment
|
|
175
|
+
// the proposal template gained such an example.
|
|
176
|
+
//
|
|
177
|
+
// Masking preserves line terminators, so the reported line numbers are the
|
|
178
|
+
// document's own either way.
|
|
179
|
+
const text = preamble(maskComments(fullText));
|
|
154
180
|
const out = [];
|
|
155
181
|
const re = /^([ \t]*)(-[ \t]+)?\*\*([A-Za-z][A-Za-z .-]*?)[ \t]*(?::\*\*|\*\*[ \t]*:)[ \t]*(.*?)[ \t]*$/gm;
|
|
156
182
|
let m;
|
|
@@ -393,15 +419,17 @@ export function isPlaceholderHeaderValue(value, opts = {}) {
|
|
|
393
419
|
*
|
|
394
420
|
* @param {string} text
|
|
395
421
|
* @param {{ section?: string|null }} [opts] limit to one `## ` section
|
|
396
|
-
* @returns {Array<{ line: number, checked: boolean, text: string, placeholder: boolean }>}
|
|
422
|
+
* @returns {Array<{ line: number, checked: boolean, text: string, placeholder: boolean, section: string|null }>}
|
|
397
423
|
*/
|
|
398
424
|
export function parseChecklist(text, opts = {}) {
|
|
399
425
|
const section = opts.section ?? null;
|
|
400
426
|
const lines = String(text).split(/\r?\n/);
|
|
401
427
|
const out = [];
|
|
402
428
|
let inSection = section === null;
|
|
429
|
+
let heading = null;
|
|
403
430
|
for (let i = 0; i < lines.length; i++) {
|
|
404
431
|
if (/^##\s+/.test(lines[i])) {
|
|
432
|
+
heading = lines[i].replace(/^##\s+/, "").trim();
|
|
405
433
|
if (section !== null) {
|
|
406
434
|
inSection = new RegExp(`^##\\s+${section.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i")
|
|
407
435
|
.test(lines[i]);
|
|
@@ -416,7 +444,7 @@ export function parseChecklist(text, opts = {}) {
|
|
|
416
444
|
const m = /^\s*[-*+]\s*\[([ xX])\]\s*(.*)$/.exec(lines[i]);
|
|
417
445
|
if (!m) continue;
|
|
418
446
|
const body = m[2].trim();
|
|
419
|
-
out.push({ line: i, checked: m[1] !== " ", text: body, placeholder: body === "" });
|
|
447
|
+
out.push({ line: i, checked: m[1] !== " ", text: body, placeholder: body === "", section: heading });
|
|
420
448
|
}
|
|
421
449
|
return out;
|
|
422
450
|
}
|
|
@@ -430,8 +458,16 @@ export function parseChecklist(text, opts = {}) {
|
|
|
430
458
|
* @param {{ section?: string|null }} [opts]
|
|
431
459
|
* @returns {{ total: number, done: number, open: string[], placeholders: number }}
|
|
432
460
|
*/
|
|
461
|
+
// A tasks.md scaffolded before change 0195 ends with "## Closing steps" —
|
|
462
|
+
// apply, archive, update the index. Those are what `doctrina close` DOES,
|
|
463
|
+
// so asking for them ticked before the close ran was asking the agent to
|
|
464
|
+
// claim three things that had not happened yet. The template no longer
|
|
465
|
+
// writes them; a change that still carries them is not held to them.
|
|
466
|
+
const CLOSING_STEPS = /^closing steps$/i;
|
|
467
|
+
|
|
433
468
|
export function checklistProgress(text, opts = {}) {
|
|
434
|
-
const boxes = parseChecklist(text, opts)
|
|
469
|
+
const boxes = parseChecklist(text, opts)
|
|
470
|
+
.filter((b) => opts.section != null || !CLOSING_STEPS.test(b.section ?? ""));
|
|
435
471
|
return {
|
|
436
472
|
total: boxes.length,
|
|
437
473
|
done: boxes.filter((b) => b.checked).length,
|
|
@@ -611,3 +647,8 @@ export function parseChangeTitle(text) {
|
|
|
611
647
|
const title = m?.[1]?.trim();
|
|
612
648
|
return title ? title : null;
|
|
613
649
|
}
|
|
650
|
+
|
|
651
|
+
/** A value still wrapped in the template's angle brackets: `<one-sentence …>`. */
|
|
652
|
+
export function isScaffoldValue(value) {
|
|
653
|
+
return /^<[^<>]*>$/.test(String(value ?? "").trim());
|
|
654
|
+
}
|
package/src/lib/docs-impact.js
CHANGED
|
@@ -163,6 +163,40 @@ export function documentedSurfaceSignals(changeDir, projectRoot = null) {
|
|
|
163
163
|
// the finding — and had to close with --force (change 0088).
|
|
164
164
|
if (/chore/i.test(listHeader(text, "Lane") ?? "")) return signals;
|
|
165
165
|
|
|
166
|
+
// THE AUTHOR MAY DECLARE THAT A NAME IS A MENTION, NOT A CHANGE.
|
|
167
|
+
//
|
|
168
|
+
// Everything below reads NAMES out of the authored text, and a name is
|
|
169
|
+
// all it can see: a change that says "coverage no longer knows this
|
|
170
|
+
// test exists" is describing an effect, and one whose Scope boundaries
|
|
171
|
+
// say "does not touch `verify`" is describing an absence, but both read
|
|
172
|
+
// to this gate exactly like a change that alters the command. Three
|
|
173
|
+
// closes in one session were forced for that reason, and a gate that is
|
|
174
|
+
// routinely forced stops being a gate — the ledger fills with gaps that
|
|
175
|
+
// were never gaps, and a real one stops standing out.
|
|
176
|
+
//
|
|
177
|
+
// No smarter extraction settles it, because the difference is semantic
|
|
178
|
+
// and ADR 0005 puts semantics outside a deterministic gate. So the
|
|
179
|
+
// author declares it, in the grammar the tree already uses for exactly
|
|
180
|
+
// this — `Realizes: n/a — <why>`: a bare `n/a` is an assertion nobody
|
|
181
|
+
// wrote, and only `n/a` WITH a reason is a decision.
|
|
182
|
+
// Read it from AUTHORED text only. An HTML comment is annotation — the
|
|
183
|
+
// rule this module already applies everywhere else, and the one this
|
|
184
|
+
// header shipped without: a `Documented surface:` line inside a comment
|
|
185
|
+
// counted, so the example in the proposal template silenced the gate for
|
|
186
|
+
// every change scaffolded from it. A declaration nobody wrote is not a
|
|
187
|
+
// declaration, exactly as a bare `n/a` is not one.
|
|
188
|
+
const declaredNone = listHeader(maskComments(text), "Documented surface");
|
|
189
|
+
if (declaredNone !== null) {
|
|
190
|
+
// The separator is punctuation, not a reason: "n/a —" says no more than
|
|
191
|
+
// "n/a". Strip the word, then the dash, and require words to remain.
|
|
192
|
+
const rest = declaredNone.trim()
|
|
193
|
+
.replace(/^(n\/a|none)\b/i, (m) => (m ? "" : m))
|
|
194
|
+
.replace(/^\s*[—–-]+\s*/, "")
|
|
195
|
+
.trim();
|
|
196
|
+
const word = declaredNone.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
197
|
+
if ((word === "n/a" || word === "none") && rest.length > 0) return signals;
|
|
198
|
+
}
|
|
199
|
+
|
|
166
200
|
sources.push(authored(withoutVerification(text)));
|
|
167
201
|
}
|
|
168
202
|
for (const p of walk(path.join(changeDir, "specs"))) {
|
|
@@ -265,6 +299,46 @@ export function isGitRepo(projectRoot) {
|
|
|
265
299
|
return isRepo(projectRoot);
|
|
266
300
|
}
|
|
267
301
|
|
|
302
|
+
// A CHANGELOG IS A DIFFERENT OBLIGATION FROM DOCUMENTATION.
|
|
303
|
+
//
|
|
304
|
+
// The docs gate asks "can a reader learn how this works". The changelog asks
|
|
305
|
+
// "can a reader learn that it changed" — and the second is not answered by
|
|
306
|
+
// the first, because prose describing the new behaviour reads exactly like
|
|
307
|
+
// prose that always described it.
|
|
308
|
+
//
|
|
309
|
+
// Nothing asked for it, so it drifted immediately: thirteen changes landed in
|
|
310
|
+
// one session, every one of them through `close`, and `## [Unreleased]` was
|
|
311
|
+
// still empty at the end of it. The file's own first line says every notable
|
|
312
|
+
// change is recorded there.
|
|
313
|
+
//
|
|
314
|
+
// Silent for a project that keeps no CHANGELOG.md — this gate reports a
|
|
315
|
+
// promise the project made, and never invents one it did not.
|
|
316
|
+
export function checkChangelogImpact(projectRoot, changeDir) {
|
|
317
|
+
const signals = documentedSurfaceSignals(changeDir, projectRoot);
|
|
318
|
+
if (signals.length === 0) {
|
|
319
|
+
return { ok: true, signals, touched: [], reason: "touches no documented surface" };
|
|
320
|
+
}
|
|
321
|
+
if (!isFile(path.join(projectRoot, CHANGELOG))) {
|
|
322
|
+
return { ok: true, signals, touched: [], reason: `no ${CHANGELOG} in this project` };
|
|
323
|
+
}
|
|
324
|
+
if (!isRepo(projectRoot)) {
|
|
325
|
+
return { ok: true, signals, touched: [], reason: "not a git repository — cannot tell what moved" };
|
|
326
|
+
}
|
|
327
|
+
const touched = changedFiles(projectRoot, { mergeBase: true })
|
|
328
|
+
.files.filter((f) => f === CHANGELOG);
|
|
329
|
+
if (touched.length > 0) {
|
|
330
|
+
return { ok: true, signals, touched, reason: `recorded in ${CHANGELOG}` };
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
ok: false,
|
|
334
|
+
signals,
|
|
335
|
+
touched,
|
|
336
|
+
reason: `alters a documented surface but ${CHANGELOG} does not say so`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const CHANGELOG = "CHANGELOG.md";
|
|
341
|
+
|
|
268
342
|
// Where THIS project keeps its documentation, read off its own tree.
|
|
269
343
|
//
|
|
270
344
|
// The gate is portable; the instruction it printed was not. It named
|
package/src/lib/ears.js
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
// word-level smells; this catches grammar-shape problems per EARS section.
|
|
4
4
|
// All findings are warnings: specs evolve, validation should not block.
|
|
5
5
|
|
|
6
|
+
// A prohibition negates its own modal. Either the modal directly
|
|
7
|
+
// ("shall not", "shall never") or the object the verb takes, immediately
|
|
8
|
+
// ("shall do no work", "shall carry neither X nor Y").
|
|
9
|
+
//
|
|
10
|
+
// The gap is ONE word wide on purpose. Allowing two lets "shall report when
|
|
11
|
+
// no contracts are declared" back in — a report, not a prohibition, and the
|
|
12
|
+
// exact shape this check exists to keep out of the must-not section.
|
|
13
|
+
const UNWANTED_NEGATION =
|
|
14
|
+
/\bshall\s+(?:not|never)\b|\bshall\s+\w+\s+(?:no|neither|nothing)\b/i;
|
|
15
|
+
|
|
6
16
|
const SECTION_RULES = [
|
|
7
17
|
{
|
|
8
18
|
match: /^###\s+Ubiquitous/i,
|
|
@@ -38,7 +48,20 @@ const SECTION_RULES = [
|
|
|
38
48
|
name: "Unwanted-behavior",
|
|
39
49
|
check(req) {
|
|
40
50
|
if (!/\bshall\b/i.test(req)) return "missing \"shall\" — unwanted-behavior form is \"The system shall not ...\"";
|
|
41
|
-
|
|
51
|
+
// The negation has to hang off the MODAL, not merely appear somewhere in
|
|
52
|
+
// the sentence. Searching the whole line let "no" the determiner stand
|
|
53
|
+
// in for a prohibition: "the system shall report it as a finding, because
|
|
54
|
+
// a claim over code that is not there..." forbids nothing and passed.
|
|
55
|
+
// Two requirements of this repository's own specs were positive
|
|
56
|
+
// obligations filed under must-not for exactly that reason.
|
|
57
|
+
//
|
|
58
|
+
// Both attachments count: "shall not/never <verb>", and the form that
|
|
59
|
+
// negates the object instead — "shall do no work", "shall carry neither".
|
|
60
|
+
if (!UNWANTED_NEGATION.test(req)) {
|
|
61
|
+
return "missing a negation on \"shall\" — unwanted-behavior forbids something "
|
|
62
|
+
+ "(\"shall not ...\", \"shall never ...\", \"shall <verb> no ...\"); a "
|
|
63
|
+
+ "requirement that only reports or accepts belongs in another section";
|
|
64
|
+
}
|
|
42
65
|
return null;
|
|
43
66
|
},
|
|
44
67
|
},
|
package/src/lib/gates.js
CHANGED
|
@@ -24,10 +24,10 @@ import { appendLedgerLine, forcedLine } from "./ledger.js";
|
|
|
24
24
|
// A gate is a named precondition returning human-readable blockers.
|
|
25
25
|
export const GATES = {
|
|
26
26
|
// Structural validity: the change's own files parse and point at real
|
|
27
|
-
// targets. The same checks `doctrina
|
|
27
|
+
// targets. The same checks `doctrina change check` reports first.
|
|
28
28
|
structure: {
|
|
29
29
|
label: "structure",
|
|
30
|
-
rerun: (id) => `doctrina
|
|
30
|
+
rerun: (id) => `doctrina change check ${id}`,
|
|
31
31
|
blockers(projectRoot, changeDir) {
|
|
32
32
|
return collectAnalysis(projectRoot, changeDir)
|
|
33
33
|
.filter((r) => r.kind === "fail")
|
|
@@ -49,7 +49,7 @@ export const GATES = {
|
|
|
49
49
|
// history recorded six hollow proposals reaching the archive.
|
|
50
50
|
integrity: {
|
|
51
51
|
label: "integrity",
|
|
52
|
-
rerun: (id) => `doctrina
|
|
52
|
+
rerun: (id) => `doctrina change check ${id}`,
|
|
53
53
|
blockers(projectRoot, changeDir) {
|
|
54
54
|
return collectAnalysis(projectRoot, changeDir)
|
|
55
55
|
.filter((r) => r.kind === "fail" && r.scope !== "pre-apply")
|
|
@@ -58,8 +58,9 @@ export const GATES = {
|
|
|
58
58
|
},
|
|
59
59
|
|
|
60
60
|
// Verification: the work is claimed complete. Every checkbox in
|
|
61
|
-
// tasks.md
|
|
62
|
-
// "##
|
|
61
|
+
// tasks.md and in the proposal's "## Verification" section — except a
|
|
62
|
+
// legacy "## Closing steps" list, which names what the close itself does
|
|
63
|
+
// (change 0195).
|
|
63
64
|
verification: {
|
|
64
65
|
label: "verification",
|
|
65
66
|
rerun: (id) => `doctrina change tick ${id}`,
|
|
@@ -72,7 +73,7 @@ export const GATES = {
|
|
|
72
73
|
const tasksPath = path.join(changeDir, "tasks.md");
|
|
73
74
|
if (isFile(tasksPath)) {
|
|
74
75
|
const n = countUnchecked(read(tasksPath));
|
|
75
|
-
if (n > 0) out.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md
|
|
76
|
+
if (n > 0) out.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md`);
|
|
76
77
|
}
|
|
77
78
|
const proposalPath = path.join(changeDir, "proposal.md");
|
|
78
79
|
if (isFile(proposalPath)) {
|
|
@@ -158,7 +159,11 @@ export const SEQUENCES = {
|
|
|
158
159
|
// The closing sequence. `close` runs these in order, in-process where it
|
|
159
160
|
// can, stopping at the first blocking failure with the step's rerun line.
|
|
160
161
|
close: [
|
|
161
|
-
|
|
162
|
+
// The structural pre-flight. Its id stays "analyze" (the module it runs
|
|
163
|
+
// in-process), but a reader sees "structure", the gate it holds: change
|
|
164
|
+
// 0182 deprecated `doctrina analyze`, and the command a person reruns is
|
|
165
|
+
// `change check`, which reports the same checks first (change 0183).
|
|
166
|
+
{ id: "analyze", label: "structure", level: "blocking", argv: ["analyze", "<id>"], rerun: "doctrina change check <id>" },
|
|
162
167
|
{ id: "adr-checkpoint", label: "ADR checkpoint (advisory)", level: "advisory", argv: ["decision", "list"] },
|
|
163
168
|
// Conformance review (audit finding F3). It is the richest analysis the
|
|
164
169
|
// project has — capabilities whose code moved while their spec did not,
|
|
@@ -179,6 +184,20 @@ export const SEQUENCES = {
|
|
|
179
184
|
{ id: "trace", label: "trace", level: "advisory", argv: ["trace"] },
|
|
180
185
|
{ id: "docs", label: "docs", level: "forceable", argv: null, rerun: "document the change, then rerun" },
|
|
181
186
|
{ id: "archive", label: "archive", level: "blocking", argv: ["change", "archive", "<id>"] },
|
|
187
|
+
// AFTER the archive, because the archive is the last step that WRITES the
|
|
188
|
+
// index, and a gate placed before the step it guards cannot guard it.
|
|
189
|
+
//
|
|
190
|
+
// The drift check already ran, inside `verify` — and that is exactly why
|
|
191
|
+
// it missed: `verify` is five steps earlier, so it certified an index the
|
|
192
|
+
// archive had not yet rewritten. Change 0138 closed green on a tree whose
|
|
193
|
+
// index had drifted, the commit shipped, and all six test legs of CI went
|
|
194
|
+
// red on the next push. The close was not wrong about what it checked; it
|
|
195
|
+
// checked before the damage.
|
|
196
|
+
//
|
|
197
|
+
// `validate` cannot stand in for it: drift of that kind is only visible by
|
|
198
|
+
// rebuilding the index and comparing, which validate deliberately does not
|
|
199
|
+
// do.
|
|
200
|
+
{ id: "index-drift", label: "index drift", level: "blocking", argv: ["index", "rebuild", "--check"] },
|
|
182
201
|
{ id: "validate", label: "validate", level: "blocking", argv: ["validate"] },
|
|
183
202
|
],
|
|
184
203
|
|
|
@@ -191,7 +210,7 @@ export const SEQUENCES = {
|
|
|
191
210
|
{ id: "coverage", label: "coverage", level: "advisory", argv: ["coverage"] },
|
|
192
211
|
{ id: "trace", label: "trace", level: "advisory", argv: ["trace"] },
|
|
193
212
|
{ id: "clean-checkout", label: "clean-checkout", level: "blocking", argv: ["verify", "--clean"] },
|
|
194
|
-
{ id: "templates", label: "templates", level: "advisory", argv: ["
|
|
213
|
+
{ id: "templates", label: "templates", level: "advisory", argv: ["upgrade"] },
|
|
195
214
|
{ id: "runtime", label: "runtime", level: "blocking", argv: ["contract", "check"] },
|
|
196
215
|
{ id: "local-env", label: "local .env", level: "blocking", argv: null, rerun: "doctrina doctor --env", flag: "env" },
|
|
197
216
|
{ id: "verify-config", label: "verify config", level: "blocking", argv: ["verify", "--init"] },
|
|
@@ -265,6 +284,31 @@ export function sequence(name) {
|
|
|
265
284
|
return steps;
|
|
266
285
|
}
|
|
267
286
|
|
|
287
|
+
/**
|
|
288
|
+
* The sequence as a reader sees it: each step's label, in order, with the
|
|
289
|
+
* forceable ones marked the way the advisory ones already are in their own
|
|
290
|
+
* labels.
|
|
291
|
+
*
|
|
292
|
+
* Every surface that SHOWS the sequence renders it from here. Four copies of
|
|
293
|
+
* the close sequence were written out by hand — `close --help`, two adapter
|
|
294
|
+
* command files, and the flow page in both languages — and all four had gone
|
|
295
|
+
* stale, each to a different list: one named four steps of thirteen, another
|
|
296
|
+
* eleven. A sequence a reader can count is a sequence a reader will trust, so
|
|
297
|
+
* it has exactly one author.
|
|
298
|
+
*/
|
|
299
|
+
export function sequenceLabels(name) {
|
|
300
|
+
// The marker comes from `level`, never from the label text: labels had
|
|
301
|
+
// already drifted among themselves — `trace` is advisory and did not say so
|
|
302
|
+
// while `ADR checkpoint` did — so any marker already written in is stripped
|
|
303
|
+
// and re-derived. One rule, applied to every step.
|
|
304
|
+
return sequence(name).map((s) => {
|
|
305
|
+
const base = s.label.replace(/\s*\((?:advisory|forceable)\)\s*$/, "");
|
|
306
|
+
if (s.level === "advisory") return `${base} (advisory)`;
|
|
307
|
+
if (s.level === "forceable") return `${base} (forceable)`;
|
|
308
|
+
return base;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
268
312
|
/**
|
|
269
313
|
* The command that runs one step on its own — what a surface prints when it
|
|
270
314
|
* tells the operator how to clear the gate. Derived from `argv` so the
|
|
@@ -272,7 +316,7 @@ export function sequence(name) {
|
|
|
272
316
|
* not a single command.
|
|
273
317
|
*/
|
|
274
318
|
export function stepRerun(step, changeId = "<id>") {
|
|
275
|
-
if (step.rerun) return step.rerun;
|
|
319
|
+
if (step.rerun) return step.rerun.replace(/<id>/g, changeId);
|
|
276
320
|
if (!step.argv) return `doctrina ${step.id}`;
|
|
277
321
|
return `doctrina ${step.argv.map((a) => (a === "<id>" ? changeId : a)).join(" ")}`;
|
|
278
322
|
}
|