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.
- package/README.md +31 -73
- package/package.json +3 -3
- package/src/commands/adapter.js +3 -2
- package/src/commands/analyze.js +8 -0
- package/src/commands/change.js +86 -80
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +136 -34
- 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 +2 -1
- package/src/commands/prime.js +4 -5
- package/src/commands/report.js +9 -20
- package/src/commands/review.js +47 -8
- package/src/commands/skill.js +8 -139
- 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/verify.js +59 -6
- package/src/commands/work.js +63 -8
- package/src/index.js +34 -6
- package/src/lib/actions.js +38 -28
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +95 -1
- package/src/lib/commands.js +96 -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/git.js +59 -2
- 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/playbook.js +1 -1
- package/src/lib/project.js +49 -1
- package/src/lib/runtime.js +6 -0
- package/src/lib/scan.js +139 -14
- package/src/lib/signoff.js +24 -0
- package/src/lib/skill-candidates.js +162 -0
- package/src/lib/snapshot.js +61 -2
- package/src/lib/spec-ops.js +141 -6
- package/src/lib/templates-model.js +35 -8
- package/src/lib/validation-model.js +117 -35
- package/src/lib/verify-cache.js +76 -0
- package/src/lib/views.js +10 -2
- package/src/lib/work-model.js +57 -12
- package/templates/AGENTS.md.template +3 -4
- package/templates/README.md +2 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +15 -0
- package/templates/change/spec-delta.md.template +2 -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/consolidate.md.template +28 -0
- package/templates/playbooks/work.md.template +13 -3
- 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;
|
package/src/lib/change-ops.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
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,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: "
|
|
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: "
|
|
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
|
-
|
|
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
|
-
"
|
|
223
|
-
since: "0.
|
|
224
|
-
use: "doctrina
|
|
225
|
-
why: "
|
|
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
|
-
"
|
|
228
|
-
since: "0.
|
|
229
|
-
use: "doctrina
|
|
230
|
-
why: "
|
|
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} `))
|
|
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
|
},
|