doctrina-cli 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +31 -73
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +3 -2
  4. package/src/commands/analyze.js +8 -0
  5. package/src/commands/change.js +86 -80
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +136 -34
  8. package/src/commands/completion.js +8 -2
  9. package/src/commands/context.js +6 -4
  10. package/src/commands/decision.js +12 -0
  11. package/src/commands/doctor.js +1 -1
  12. package/src/commands/hooks.js +6 -1
  13. package/src/commands/index-rebuild.js +52 -2
  14. package/src/commands/init.js +19 -4
  15. package/src/commands/intake.js +55 -7
  16. package/src/commands/intent.js +14 -1
  17. package/src/commands/metrics.js +13 -5
  18. package/src/commands/next.js +2 -1
  19. package/src/commands/prime.js +4 -5
  20. package/src/commands/report.js +9 -20
  21. package/src/commands/review.js +47 -8
  22. package/src/commands/skill.js +8 -139
  23. package/src/commands/spec.js +13 -7
  24. package/src/commands/status.js +39 -20
  25. package/src/commands/templates.js +4 -10
  26. package/src/commands/upgrade.js +31 -6
  27. package/src/commands/validate.js +32 -8
  28. package/src/commands/verify.js +59 -6
  29. package/src/commands/work.js +63 -8
  30. package/src/index.js +34 -6
  31. package/src/lib/actions.js +38 -28
  32. package/src/lib/args.js +14 -0
  33. package/src/lib/change-ops.js +95 -1
  34. package/src/lib/commands.js +96 -15
  35. package/src/lib/coverage-model.js +40 -1
  36. package/src/lib/doc-model.js +47 -6
  37. package/src/lib/docs-impact.js +74 -0
  38. package/src/lib/ears.js +24 -1
  39. package/src/lib/gates.js +53 -9
  40. package/src/lib/git.js +59 -2
  41. package/src/lib/index-json.js +40 -5
  42. package/src/lib/intake-model.js +71 -2
  43. package/src/lib/lexicon.js +38 -1
  44. package/src/lib/metrics-model.js +30 -0
  45. package/src/lib/playbook.js +1 -1
  46. package/src/lib/project.js +49 -1
  47. package/src/lib/runtime.js +6 -0
  48. package/src/lib/scan.js +139 -14
  49. package/src/lib/signoff.js +24 -0
  50. package/src/lib/skill-candidates.js +162 -0
  51. package/src/lib/snapshot.js +61 -2
  52. package/src/lib/spec-ops.js +141 -6
  53. package/src/lib/templates-model.js +35 -8
  54. package/src/lib/validation-model.js +117 -35
  55. package/src/lib/verify-cache.js +76 -0
  56. package/src/lib/views.js +10 -2
  57. package/src/lib/work-model.js +57 -12
  58. package/templates/AGENTS.md.template +3 -4
  59. package/templates/README.md +2 -0
  60. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  61. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  62. package/templates/change/proposal.md.template +15 -0
  63. package/templates/change/spec-delta.md.template +2 -0
  64. package/templates/change/tasks.md.template +3 -8
  65. package/templates/contract.md.template +2 -2
  66. package/templates/hooks/pre-commit.sample +10 -0
  67. package/templates/playbooks/bootstrap.md.template +6 -2
  68. package/templates/playbooks/chore.md.template +9 -4
  69. package/templates/playbooks/consolidate.md.template +28 -0
  70. package/templates/playbooks/work.md.template +13 -3
  71. package/src/commands/constitution.js +0 -68
package/src/lib/args.js CHANGED
@@ -74,9 +74,23 @@ export function parseArgs(argv, opts = {}) {
74
74
  function isNextTokenFlag(token) {
75
75
  if (token === undefined) return true;
76
76
  if (typeof token !== "string") return true;
77
+ // `--budget -5` is a value, not a flag named "5": no flag name starts with
78
+ // a digit, and reading it as one answered "unknown flag --5, did you mean
79
+ // --h?" instead of letting the command say the number is out of range.
80
+ if (/^-\d/.test(token)) return false;
77
81
  return token.startsWith("-");
78
82
  }
79
83
 
84
+ // A count read off a flag: digits only, above zero, or null. `parseInt`
85
+ // reads "7x" as 7 and "1.5" as 1, so a typo became a different request that
86
+ // ran without a word.
87
+ export function parsePositiveInt(raw) {
88
+ const s = String(raw ?? "").trim();
89
+ if (!/^\d+$/.test(s)) return null;
90
+ const n = Number(s);
91
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
92
+ }
93
+
80
94
  export function flagString(flags, name, fallback = undefined) {
81
95
  const v = flags.get(name);
82
96
  if (v === undefined || v === true) return fallback;
@@ -15,6 +15,10 @@ import { flagBool, flagString } from "./args.js";
15
15
  import { c } from "./colors.js";
16
16
  import { ensureDoctrinaProject, isChangeId, resolveWithinProject } from "./project.js";
17
17
  import { changeEntry } from "./scan.js";
18
+ import { parseOp, insertOps, extractOps, applyOps } from "./spec-ops.js";
19
+ import { parseOperation } from "./doc-model.js";
20
+ import { substitute } from "./templates.js";
21
+ import { EXIT } from "./exit-codes.js";
18
22
  export function changeNew(args, flags) {
19
23
  const id = args[0];
20
24
  const title = args.slice(1).join(" ").trim();
@@ -96,8 +100,10 @@ export function changeNew(args, flags) {
96
100
 
97
101
  console.log("");
98
102
  if (chore) {
103
+ // A chore is still a change: it closes through the one definition of
104
+ // done, not a hand-run apply and archive (change 0180).
99
105
  console.log(c.bold("Chore opened.") + " No spec deltas expected — implement, check the tasks, then " +
100
- c.cyan(`doctrina change apply ${id}`) + " and " + c.cyan(`doctrina change archive ${id}`) + ".");
106
+ c.cyan(`doctrina close ${id}`) + ".");
101
107
  } else {
102
108
  console.log(c.bold("Change opened.") + " Add spec deltas under " +
103
109
  c.cyan(`.doctrina/changes/${id}/specs/<capability>/delta.md`));
@@ -129,3 +135,91 @@ export function reindexChange(projectRoot, id, date = today()) {
129
135
  idx.touch(index, date);
130
136
  idx.save(projectRoot, index);
131
137
  }
138
+
139
+ /**
140
+ * Add op lines to a change's MODIFIED delta for one capability (change 0205).
141
+ *
142
+ * A field review of 0.17 asked for "a command to add a requirement and a
143
+ * criterion without writing the ops blocks by hand". The grammar is
144
+ * `apply`'s (ADR 0021): every line is parsed by the same parser, and the
145
+ * whole block — what was there plus what is added — is dry-run against the
146
+ * current spec before anything is written, so an op that would not apply is
147
+ * refused now rather than at the close. Creates the delta from the template
148
+ * when the change has none for the capability yet; a second `bump-version`
149
+ * replaces the first instead of bumping twice.
150
+ *
151
+ * @param {string} projectRoot
152
+ * @param {string} id change id (already refused/resolved by the caller)
153
+ * @param {string} capability
154
+ * @param {string[]} lines op lines, one operation each
155
+ * @returns {{ code: number, error?: string, hint?: string, details?: string[],
156
+ * deltaRel?: string, created?: boolean, block?: string[] }}
157
+ */
158
+ export function addDeltaOps(projectRoot, id, capability, lines) {
159
+ if (lines.length === 0) {
160
+ return { code: EXIT.USAGE, error: "nothing to add",
161
+ hint: "pass --requirement \"<section>: <text>\", --criterion \"<text>\", --bump <level> or an op line" };
162
+ }
163
+ const bad = lines.map((l) => ({ l, op: parseOp(l) })).filter((x) => x.op.error);
164
+ if (bad.length > 0) {
165
+ return { code: EXIT.USAGE, error: "not an operation `apply` can read", details: bad.map((x) => x.op.error) };
166
+ }
167
+ const specPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
168
+ if (!isFile(specPath)) {
169
+ return { code: EXIT.USAGE, error: `capability "${capability}" has no spec at ${relPath(projectRoot, specPath)}`,
170
+ hint: `a NEW capability is an ADDED delta with the full body — \`doctrina spec new ${capability}\`, then write it` };
171
+ }
172
+ const deltaPath = path.join(projectRoot, ".doctrina", "changes", id, "specs", capability, "delta.md");
173
+ const created = !isFile(deltaPath);
174
+ let text;
175
+ if (created) {
176
+ const tpl = read(path.join(locateTemplatesDir(), "change", "spec-delta.md.template"));
177
+ text = substitute(tpl, { CAPABILITY: capability }).replace(/^\*\*Operation:\*\*.*$/m, "**Operation:** MODIFIED");
178
+ } else {
179
+ text = read(deltaPath);
180
+ const op = parseOperation(text);
181
+ if (op && op !== "MODIFIED") {
182
+ return { code: EXIT.PRECONDITION, error: `${relPath(projectRoot, deltaPath)} is an ${op} delta — ops apply only to a MODIFIED one`,
183
+ hint: op === "ADDED" ? "write the requirement into the ADDED body itself" : "a REMOVED delta deletes the spec; there is nothing to add to" };
184
+ }
185
+ }
186
+ const next = insertOps(text, lines);
187
+ const dry = applyOps(read(specPath), extractOps(next));
188
+ if (dry.errors.length > 0) {
189
+ return { code: EXIT.GATE, error: `the ops block would not apply to ${relPath(projectRoot, specPath)} — nothing written`, details: dry.errors };
190
+ }
191
+ mkdirp(path.dirname(deltaPath));
192
+ write(deltaPath, next, { force: true });
193
+ addAffectedSpec(projectRoot, id, capability);
194
+ return { code: EXIT.OK, deltaRel: relPath(projectRoot, deltaPath), created, block: extractOps(next).map((o) => o.verb) };
195
+ }
196
+
197
+ /**
198
+ * Name `capability` in the proposal's `Affects specs:` header when the
199
+ * header does not already (change 0207). A change whose delta targets a
200
+ * capability affects that spec, and a header left empty while the delta
201
+ * folder said otherwise was the one fact `prime`, `status` and the review
202
+ * of the proposal disagreed on. A chore's "(none — chore)" is left alone:
203
+ * that is a declaration, and the structure gate is where a chore carrying a
204
+ * delta gets questioned.
205
+ *
206
+ * @param {string} projectRoot
207
+ * @param {string} id
208
+ * @param {string} capability
209
+ * @returns {boolean} whether the header changed
210
+ */
211
+ export function addAffectedSpec(projectRoot, id, capability) {
212
+ const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
213
+ if (!isFile(proposalPath)) return false;
214
+ const text = read(proposalPath);
215
+ const m = text.match(/^(-[ \t]+\*\*Affects specs:\*\*)([^\r\n]*)/m);
216
+ if (!m) return false;
217
+ const current = m[2].trim();
218
+ if (/^\(none/i.test(current)) return false;
219
+ const named = current.split(",").map((s) => s.trim().replace(/`/g, "")).filter(Boolean);
220
+ if (named.includes(capability)) return false;
221
+ const value = [...named, capability].join(", ");
222
+ write(proposalPath, text.replace(m[0], `${m[1]} ${value}`), { force: true });
223
+ reindexChange(projectRoot, id);
224
+ return true;
225
+ }
@@ -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", "constitution",
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,8 +46,8 @@ 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)"],
50
+ ["change delta", "Add a requirement/criterion/bump to a change's delta (ops written for you)"],
51
51
  ["contract new", "Own the integration surface (ports, env, interfaces)"],
52
52
  ["contract list", "List contracts with status and last-updated date"],
53
53
  ["contract check", "Verify port collisions, env drift, referenced specs"],
@@ -82,7 +82,6 @@ export const OPERATIONS = [
82
82
  ["status", "One-glance project health dashboard"],
83
83
  ["why", "Explain provenance: a capability's chain, or an anchor's (SC1)"],
84
84
  ["handoff", "Markdown handoff note: open work, task state, resume command"],
85
- ["constitution", "Print the standing rules: accepted ADRs + product non-goals"],
86
85
  ["templates list", "List the templates shipped by the installed CLI"],
87
86
  ["templates check", "Compare the project against the recommended template shape"],
88
87
  ["templates update", "Additive fixer for check findings (preview; --write applies)"],
@@ -126,13 +125,12 @@ export const COMMAND_META = {
126
125
  show: { moment: "Orient", when: "you need one requirement, criterion, or ADR, not a file", purpose: "point-read a single artifact by reference" },
127
126
  search: { moment: "Orient", when: "you do not know which artifact mentions a term", purpose: "search the artifact tree, grouped by category" },
128
127
  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
128
  handoff: { moment: "Orient", when: "BEFORE compaction or handing over to another session", purpose: "a resume note: open work, task state, next command" },
131
129
 
132
130
  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
131
  work: { moment: "Change", when: "a request arrives that changes behaviour (triage says PRODUCT)", purpose: "scaffold a change and print the playbook to execute" },
134
132
  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: "new / apply / archive / check (--verbose) / tick / abandon" },
133
+ change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "drive a change by hand — check --verbose previews every delta" },
136
134
  decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
137
135
  contract: { moment: "Change", when: "the change touches ports, env vars, or public endpoints", purpose: "own and verify the integration surface" },
138
136
  intent: { moment: "Change", when: "new product intent appears after the intake", purpose: "append and list product intent anchors" },
@@ -148,7 +146,7 @@ export const COMMAND_META = {
148
146
  close: { moment: "Gate", when: "a change is implemented and ready to finish", purpose: "the whole closing sequence in one attested pass" },
149
147
  doctor: { moment: "Gate", when: "something looks wrong and you do not know which gate to ask", purpose: "aggregate diagnostic with per-finding remedies" },
150
148
 
151
- templates: { moment: "Maintain", when: "after upgrading the CLI, or to customise a scaffold", purpose: "inspect, check, and refresh the template shape" },
149
+ templates: { moment: "Maintain", when: "customising a scaffold, to see which template file wins", purpose: "where each shipped template resolves from" },
152
150
  upgrade: { moment: "Maintain", when: "you just updated the doctrina-cli package", purpose: "bring this project up to the installed CLI" },
153
151
  index: { moment: "Maintain", when: "the index drifted from the tree", purpose: "regenerate index.json from the artifacts on disk" },
154
152
  hooks: { moment: "Maintain", when: "setting up a repo so drift cannot be committed", purpose: "install the pre-commit gate" },
@@ -164,10 +162,49 @@ export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Maintain"];
164
162
 
165
163
  // The Commands block of `doctrina --help`, generated from OPERATIONS so the
166
164
  // printed surface can never omit an operation the catalog knows about.
165
+ //
166
+ // Grouped by the MOMENT an operation is reached for, in the same order as
167
+ // the AGENTS.md block, and opened by the two lines a newcomer needs: a flat
168
+ // list of sixty operations, deprecated ones mixed in, answered "where do I
169
+ // start?" with the whole catalog (change 0174). Deprecated operations stay
170
+ // listed — someone types those names — but last, each pointing at its
171
+ // replacement.
167
172
  export function surfaceHelp() {
168
- return OPERATIONS.map(([op, summary]) => ` ${op.padEnd(21)}${summary}`).join("\n");
173
+ // A name longer than the column puts its text on the next line, aligned,
174
+ // so the summaries stay one scannable column at 80 characters.
175
+ const line = (op, text) => op.length < 20
176
+ ? ` ${op.padEnd(21)}${text}`
177
+ : ` ${op}\n ${"".padEnd(21)}${text}`;
178
+ const out = [
179
+ "Start here:",
180
+ line("doctrina init", "set up this repository (asks for a one-line description)"),
181
+ line("doctrina next", "what to do now — your agent runs the rest from AGENTS.md"),
182
+ ];
183
+ // One line per COMMAND, its subcommands joined: the summary a newcomer
184
+ // scans; `doctrina <command> --help` has each subcommand's detail.
185
+ const live = OPERATIONS.filter(([op]) => !DEPRECATED[op]);
186
+ for (const moment of MOMENTS) {
187
+ const cmds = [...new Set(live.map(([op]) => op.split(" ")[0]))]
188
+ .filter((cmd) => COMMAND_META[cmd]?.moment === moment);
189
+ if (cmds.length === 0) continue;
190
+ out.push("", `${moment}:`);
191
+ for (const cmd of cmds) {
192
+ const ops = live.filter(([op]) => op.split(" ")[0] === cmd);
193
+ const subs = ops.map(([op]) => op.split(" ")[1]).filter(Boolean);
194
+ const text = ops.length === 1 ? ops[0][1] : capitalise(COMMAND_META[cmd].purpose);
195
+ out.push(line(subs.length > 0 ? `${cmd} ${subs.join("|")}` : cmd, text));
196
+ }
197
+ }
198
+ const old = OPERATIONS.filter(([op]) => DEPRECATED[op]);
199
+ if (old.length > 0) {
200
+ out.push("", "Deprecated (still run, with a notice on stderr):",
201
+ ...old.map(([op]) => line(op, `→ ${DEPRECATED[op].use}`)));
202
+ }
203
+ return out.join("\n");
169
204
  }
170
205
 
206
+ const capitalise = (s) => s.charAt(0).toUpperCase() + s.slice(1);
207
+
171
208
  // ---------------------------------------------------------------------------
172
209
  // The CLI-owned AGENTS.md command-surface block (operator review 2026-07-19,
173
210
  // meta-conclusion): for an agent, AGENTS.md IS the discovery interface — it
@@ -219,18 +256,48 @@ const SURFACE_HINTS = {
219
256
  // leaves the surface block — an agent should not reach for it, and the block
220
257
  // is the list of things to reach for. Removal is a later, separate change.
221
258
  export const DEPRECATED = Object.freeze({
222
- "constitution": {
223
- since: "0.16.0",
224
- use: "doctrina prime --rules",
225
- why: "prime renders the same standing-rules view, from the same collection",
259
+ "analyze": {
260
+ since: "0.17.0",
261
+ use: "doctrina change check",
262
+ 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",
263
+ },
264
+ "report": {
265
+ since: "0.17.0",
266
+ use: "doctrina status --view report",
267
+ why: "status renders the same digest from the same collector; --agent-changelog is status --view agent-changelog",
226
268
  },
227
- "change diff": {
228
- since: "0.16.0",
229
- use: "doctrina change check --verbose",
230
- why: "check runs every ops block against the target spec and now prints the same per-delta preview",
269
+ "skill sync": {
270
+ since: "0.17.0",
271
+ use: "doctrina index rebuild",
272
+ why: "the rebuild registers every skill and mirrors its description, keeping a written one over a scaffold placeholder",
273
+ },
274
+ "templates check": {
275
+ since: "0.17.0",
276
+ use: "doctrina upgrade",
277
+ why: "the upgrade preview reports every template finding, each with its fix, and exits 1 while one is left",
278
+ },
279
+ "templates update": {
280
+ since: "0.17.0",
281
+ use: "doctrina upgrade --write",
282
+ why: "the upgrade's first step is this update, previewed without --write and applied with it",
231
283
  },
232
284
  });
233
285
 
286
+ // A deprecated operation that has since been REMOVED. Typing the old name
287
+ // answers with its replacement instead of "unknown command, did you mean
288
+ // ...?" — whoever still has it in a script or a habit learns the one thing
289
+ // they need. Usage class: the fix is the invocation (ADR 0018).
290
+ export const REMOVED = Object.freeze({
291
+ "constitution": { since: "0.17.0", use: "doctrina prime --rules" },
292
+ "change diff": { since: "0.17.0", use: "doctrina change check --verbose" },
293
+ });
294
+
295
+ /** The removal record for an invocation, or null. */
296
+ export function removalFor(argv) {
297
+ const words = argv.filter((a) => !a.startsWith("-"));
298
+ return REMOVED[words.slice(0, 2).join(" ")] ?? REMOVED[words[0] ?? ""] ?? null;
299
+ }
300
+
234
301
  /** The deprecation record for an invocation, or null. */
235
302
  export function deprecationFor(argv) {
236
303
  const words = argv.filter((a) => !a.startsWith("-"));
@@ -450,6 +517,20 @@ export const AGENT_CHANGELOG = {
450
517
  // Each entry lists only ITS OWN delta. The series carries the rest
451
518
  // forward (see agentChangelogEntries), so a patch never has to restate
452
519
  // the minor that introduced the commands, and never silently erases it.
520
+ "0.18.0": [
521
+ "`doctrina change delta <id> <capability> --requirement \"<section>: <text>\" --criterion \"<text>\" --bump minor` writes the ops lines for you and dry-runs them against the spec — stop hand-writing ops blocks.",
522
+ "`close` runs `verify --reuse`: running `doctrina verify` yourself right before `doctrina close` is no longer paid twice, and a batch close runs the suite once. Use `verify --affected` while working when checks declare `\"paths\"`.",
523
+ "The review inside `close` counts the change's own deltas as spec updates and reports code under other capabilities in ONE line — read it: either this change forgot a delta or other work is lying in the tree.",
524
+ "`close` ends by listing other open changes that look finished or are a week old; close them with the `doctrina close <id>` it prints. `next` no longer suggests `change archive`.",
525
+ "A spec can shrink: `remove-requirement <section> <n>` / `remove-criterion <n>`, and `doctrina work --consolidate <cap>` when a spec has grown past what it needs to say.",
526
+ ],
527
+ "0.17.0": [
528
+ "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.",
529
+ "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).",
530
+ "`doctrina upgrade` now reports an installed slash command that still teaches a retired command; run the fix it prints (`doctrina adapter add <agent> --force`).",
531
+ "`doctrina work \"<prompt>\" --design` also scaffolds design.md — no need to fall back to `change new` for a change with non-trivial choices.",
532
+ "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).",
533
+ ],
453
534
  "0.16.0": [
454
535
  "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
536
  "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} `)) && /[\\/]/.test(m) && !/[\\/]$/.test(m));
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 };
@@ -93,7 +93,12 @@ function headerPattern(name) {
93
93
  * line: number, conforming: boolean } | null}
94
94
  */
95
95
  export function readHeader(text, name) {
96
- const m = headerPattern(name).exec(String(text));
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
- return String(text).replace(headerPattern(name), replacement);
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
- const text = preamble(fullText);
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
+ }
@@ -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
- if (!/\b(not|no|never)\b/i.test(req)) return "missing a negation — unwanted-behavior requirements forbid something";
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
  },