doctrina-cli 0.15.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -72
- package/package.json +3 -3
- package/src/commands/adapter.js +27 -3
- package/src/commands/analyze.js +16 -212
- package/src/commands/change.js +144 -267
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +65 -21
- package/src/commands/close.js +349 -45
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +284 -111
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +66 -7
- package/src/commands/doctor.js +293 -130
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +17 -3
- package/src/commands/index-rebuild.js +55 -44
- package/src/commands/init.js +88 -16
- package/src/commands/intake.js +85 -99
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +65 -88
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +23 -91
- package/src/commands/report.js +40 -109
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +23 -39
- package/src/commands/spec.js +38 -11
- package/src/commands/status.js +69 -171
- package/src/commands/templates.js +35 -148
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +44 -877
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +243 -267
- package/src/index.js +139 -12
- package/src/lib/actions.js +490 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +133 -0
- package/src/lib/commands.js +135 -10
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +431 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +370 -3
- package/src/lib/docs-impact.js +270 -27
- package/src/lib/ears.js +24 -1
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +231 -29
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +42 -24
- package/src/lib/intake-model.js +155 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +228 -0
- package/src/lib/metrics-model.js +189 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +105 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +328 -25
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +231 -0
- package/src/lib/spec-ops.js +72 -8
- package/src/lib/templates-model.js +289 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1381 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +413 -0
- package/src/lib/work-model.js +102 -0
- package/templates/AGENTS.md.template +21 -28
- package/templates/README.md +35 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +16 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +16 -2
- package/templates/playbooks/bootstrap.md.template +47 -0
- package/templates/playbooks/chore.md.template +24 -0
- package/templates/playbooks/work.md.template +83 -0
- package/src/commands/constitution.js +0 -145
package/src/lib/commands.js
CHANGED
|
@@ -16,11 +16,11 @@ 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
|
|
23
|
-
"templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade",
|
|
23
|
+
"templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade", "ci",
|
|
24
24
|
];
|
|
25
25
|
|
|
26
26
|
// The full operation surface — every `doctrina <command> [<subcommand>]` a
|
|
@@ -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)"],
|
|
@@ -93,6 +91,7 @@ export const OPERATIONS = [
|
|
|
93
91
|
["metrics", "Local git-derived adoption metrics (no network)"],
|
|
94
92
|
["report", "Markdown digest for a period: changes, gates, git summary"],
|
|
95
93
|
["completion", "Print bash/zsh/pwsh completions (generated from the catalog)"],
|
|
94
|
+
["ci", "Emit the CI pipeline for the declared gate sequence (--emit github)"],
|
|
96
95
|
["upgrade", "Bring an existing project up to the installed CLI (--write applies)"],
|
|
97
96
|
];
|
|
98
97
|
|
|
@@ -118,20 +117,19 @@ export const COMMAND_META = {
|
|
|
118
117
|
intake: { moment: "Bootstrap", when: "you have a full project description and no specs yet", purpose: "store the intent and print the bootstrap playbook" },
|
|
119
118
|
adapter: { moment: "Bootstrap", when: "adding or removing an agent's pointer files", purpose: "install/remove agent adapters (additive)" },
|
|
120
119
|
|
|
121
|
-
prime: { moment: "Orient", when: "at the START of every session", purpose: "gates,
|
|
120
|
+
prime: { moment: "Orient", when: "at the START of every session", purpose: "gates, open work and next steps in one read (--rules for the full standing rules)" },
|
|
122
121
|
status: { moment: "Orient", when: "you need the health of the tree at a glance", purpose: "index, coverage, trace, and artifact counts" },
|
|
123
122
|
next: { moment: "Orient", when: "you finished something and do not know what follows", purpose: "the recommended next workflow action" },
|
|
124
123
|
context: { moment: "Orient", when: "before working on any task, to load the right files", purpose: "the read pack, assembled to fit a token budget" },
|
|
125
124
|
show: { moment: "Orient", when: "you need one requirement, criterion, or ADR, not a file", purpose: "point-read a single artifact by reference" },
|
|
126
125
|
search: { moment: "Orient", when: "you do not know which artifact mentions a term", purpose: "search the artifact tree, grouped by category" },
|
|
127
126
|
why: { moment: "Orient", when: "you need to justify or trace a capability's existence", purpose: "provenance: intent, proof, ADRs, and history" },
|
|
128
|
-
constitution: { moment: "Orient", when: "you need the standing rules before deciding something", purpose: "accepted ADRs and product non-goals" },
|
|
129
127
|
handoff: { moment: "Orient", when: "BEFORE compaction or handing over to another session", purpose: "a resume note: open work, task state, next command" },
|
|
130
128
|
|
|
131
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" },
|
|
132
130
|
work: { moment: "Change", when: "a request arrives that changes behaviour (triage says PRODUCT)", purpose: "scaffold a change and print the playbook to execute" },
|
|
133
131
|
spec: { moment: "Change", when: "a capability needs creating or its headers advancing", purpose: "create, list, and edit capability specs" },
|
|
134
|
-
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" },
|
|
135
133
|
decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
|
|
136
134
|
contract: { moment: "Change", when: "the change touches ports, env vars, or public endpoints", purpose: "own and verify the integration surface" },
|
|
137
135
|
intent: { moment: "Change", when: "new product intent appears after the intake", purpose: "append and list product intent anchors" },
|
|
@@ -147,7 +145,7 @@ export const COMMAND_META = {
|
|
|
147
145
|
close: { moment: "Gate", when: "a change is implemented and ready to finish", purpose: "the whole closing sequence in one attested pass" },
|
|
148
146
|
doctor: { moment: "Gate", when: "something looks wrong and you do not know which gate to ask", purpose: "aggregate diagnostic with per-finding remedies" },
|
|
149
147
|
|
|
150
|
-
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" },
|
|
151
149
|
upgrade: { moment: "Maintain", when: "you just updated the doctrina-cli package", purpose: "bring this project up to the installed CLI" },
|
|
152
150
|
index: { moment: "Maintain", when: "the index drifted from the tree", purpose: "regenerate index.json from the artifacts on disk" },
|
|
153
151
|
hooks: { moment: "Maintain", when: "setting up a repo so drift cannot be committed", purpose: "install the pre-commit gate" },
|
|
@@ -155,6 +153,7 @@ export const COMMAND_META = {
|
|
|
155
153
|
metrics: { moment: "Maintain", when: "reporting adoption over a period", purpose: "local git-derived adoption metrics" },
|
|
156
154
|
report: { moment: "Maintain", when: "summarising a period for a human", purpose: "a Markdown digest of changes, gates, and git" },
|
|
157
155
|
completion: { moment: "Maintain", when: "setting up a human's shell", purpose: "print shell completions from the catalog" },
|
|
156
|
+
ci: { moment: "Maintain", when: "the declared gate sequence changed and CI must follow", purpose: "emit the CI pipeline from the gate declaration" },
|
|
158
157
|
};
|
|
159
158
|
|
|
160
159
|
// The order moments appear in the generated block.
|
|
@@ -162,10 +161,49 @@ export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Maintain"];
|
|
|
162
161
|
|
|
163
162
|
// The Commands block of `doctrina --help`, generated from OPERATIONS so the
|
|
164
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.
|
|
165
171
|
export function surfaceHelp() {
|
|
166
|
-
|
|
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");
|
|
167
203
|
}
|
|
168
204
|
|
|
205
|
+
const capitalise = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
206
|
+
|
|
169
207
|
// ---------------------------------------------------------------------------
|
|
170
208
|
// The CLI-owned AGENTS.md command-surface block (operator review 2026-07-19,
|
|
171
209
|
// meta-conclusion): for an agent, AGENTS.md IS the discovery interface — it
|
|
@@ -195,10 +233,78 @@ const SURFACE_HINTS = {
|
|
|
195
233
|
clarify: "--all (--lang pt|en)",
|
|
196
234
|
close: "<id...>",
|
|
197
235
|
upgrade: "--write",
|
|
236
|
+
ci: "--emit github",
|
|
198
237
|
prime: "(session start)",
|
|
199
238
|
handoff: "(before compaction/handover)",
|
|
200
239
|
};
|
|
201
240
|
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Deprecations (change 0049).
|
|
243
|
+
//
|
|
244
|
+
// The surface has a hard 40-line budget precisely so that adding a command
|
|
245
|
+
// forces the question "what comes off?" — and for two releases the answer was
|
|
246
|
+
// "nothing", while the budget was met by compressing a whole moment onto one
|
|
247
|
+
// line (audit finding F23). These are the first two removals, and both are
|
|
248
|
+
// MERGES: the surviving command already does everything the retired one did,
|
|
249
|
+
// which is why they can go without waiting for usage evidence. Usage counts
|
|
250
|
+
// can tell you a command is unloved; only redundancy can tell you it is
|
|
251
|
+
// unnecessary, and an unloved command that does something nothing else does
|
|
252
|
+
// has to stay.
|
|
253
|
+
//
|
|
254
|
+
// A deprecated operation keeps working, warns once on the way through, and
|
|
255
|
+
// leaves the surface block — an agent should not reach for it, and the block
|
|
256
|
+
// is the list of things to reach for. Removal is a later, separate change.
|
|
257
|
+
export const DEPRECATED = Object.freeze({
|
|
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",
|
|
262
|
+
},
|
|
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",
|
|
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" },
|
|
292
|
+
});
|
|
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
|
+
|
|
300
|
+
/** The deprecation record for an invocation, or null. */
|
|
301
|
+
export function deprecationFor(argv) {
|
|
302
|
+
const words = argv.filter((a) => !a.startsWith("-"));
|
|
303
|
+
const two = words.slice(0, 2).join(" ");
|
|
304
|
+
const one = words[0] ?? "";
|
|
305
|
+
return DEPRECATED[two] ?? DEPRECATED[one] ?? null;
|
|
306
|
+
}
|
|
307
|
+
|
|
202
308
|
// The declared size budget for the generated block. A surface that cannot
|
|
203
309
|
// describe itself in this many lines is too large — the answer is to cut
|
|
204
310
|
// commands (M8), never to raise the number.
|
|
@@ -219,8 +325,13 @@ const COMPACT_MOMENTS = new Set(["Maintain"]);
|
|
|
219
325
|
// a name index never told it. Same shape `context` prints for skills —
|
|
220
326
|
// description plus `when:`, body on demand.
|
|
221
327
|
export function surfaceMarkdown() {
|
|
328
|
+
// A deprecated operation stays in the catalog — `--help` and the shell
|
|
329
|
+
// completions must still know the name someone types — but leaves this
|
|
330
|
+
// block: it is the list of commands to REACH FOR, and pointing an agent at
|
|
331
|
+
// a name that warns when used is worse than not listing it.
|
|
222
332
|
const subsByCmd = new Map();
|
|
223
333
|
for (const [op] of OPERATIONS) {
|
|
334
|
+
if (DEPRECATED[op]) continue;
|
|
224
335
|
const [cmd, sub] = op.split(" ");
|
|
225
336
|
if (!subsByCmd.has(cmd)) subsByCmd.set(cmd, []);
|
|
226
337
|
if (sub) subsByCmd.get(cmd).push(sub);
|
|
@@ -242,7 +353,7 @@ export function surfaceMarkdown() {
|
|
|
242
353
|
"canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.",
|
|
243
354
|
];
|
|
244
355
|
for (const moment of MOMENTS) {
|
|
245
|
-
const cmds = COMMAND_NAMES.filter((n) => COMMAND_META[n]?.moment === moment);
|
|
356
|
+
const cmds = COMMAND_NAMES.filter((n) => COMMAND_META[n]?.moment === moment && !DEPRECATED[n]);
|
|
246
357
|
if (cmds.length === 0) continue;
|
|
247
358
|
if (COMPACT_MOMENTS.has(moment)) {
|
|
248
359
|
// Each name still carries the `doctrina ` prefix: the compact line is
|
|
@@ -405,6 +516,20 @@ export const AGENT_CHANGELOG = {
|
|
|
405
516
|
// Each entry lists only ITS OWN delta. The series carries the rest
|
|
406
517
|
// forward (see agentChangelogEntries), so a patch never has to restate
|
|
407
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
|
+
],
|
|
526
|
+
"0.16.0": [
|
|
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.",
|
|
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.",
|
|
529
|
+
"`analyze` executes a MODIFIED delta's `ops` block, so `analyze`, `change apply` and `close` all refuse the same unappliable block. Fix the ops before closing; a delta with no ops block is still a legitimate manual merge.",
|
|
530
|
+
"`validate` refuses more: an active spec declaring no acceptance criteria, an empty artifact, a scaffold section left unwritten, a reference that resolves to nothing. Write the criteria before flipping a spec to `active`.",
|
|
531
|
+
"`change new <id>` requires the `NNNN-slug` shape (lowercase letters, digits, hyphens) and writes nothing otherwise — `doctrina work \"<prompt>\"` derives one for you.",
|
|
532
|
+
],
|
|
408
533
|
"0.15.1": [
|
|
409
534
|
"Exporting a variable under a name that is not its source's is routine — declare it in the Wiring row's Origin cell as `<origin>:<source>` (e.g. `secrets:NPM_TOKEN`) and RT02 stays silent until either side moves.",
|
|
410
535
|
],
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The project's configuration, in one place, with WHERE each value came from.
|
|
3
|
+
//
|
|
4
|
+
// Five files configured a Doctrina project and only one of them had an
|
|
5
|
+
// `--init`: `index.json` (the context budget, under `config`), `config.json`
|
|
6
|
+
// (the language, read by `clarify` alone), `rules.json` (project rules,
|
|
7
|
+
// read by `validate` alone), `verify.json`, and `verify.signoffs.json`.
|
|
8
|
+
// `config.json` and `rules.json` were created by nothing, documented in no
|
|
9
|
+
// surface block, and reported by no command — they existed only for someone
|
|
10
|
+
// who had read the source. The symptom that found this (audit finding F19):
|
|
11
|
+
// a pt-BR project sits permanently red under `clarify` with no clue why.
|
|
12
|
+
//
|
|
13
|
+
// `.doctrina/config.json` is now the declared home. The old locations are
|
|
14
|
+
// still READ — a project that configured a budget in `index.json` keeps
|
|
15
|
+
// working, and removal is announced rather than performed here — but every
|
|
16
|
+
// consumer asks this module, and every value carries its source so `doctor`
|
|
17
|
+
// can print "configured" or "default" instead of leaving a project to guess.
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { isFile, read } from "./fs-ops.js";
|
|
20
|
+
|
|
21
|
+
export const CONFIG_REL = ".doctrina/config.json";
|
|
22
|
+
export const RULES_REL = ".doctrina/rules.json";
|
|
23
|
+
|
|
24
|
+
// The built-in default for every option. `language: null` means "detect per
|
|
25
|
+
// file", which is what the absence of a declaration has always meant.
|
|
26
|
+
export const DEFAULTS = Object.freeze({
|
|
27
|
+
language: null,
|
|
28
|
+
context_budget: 15000,
|
|
29
|
+
rules: [],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Where a value came from, most authoritative first. The strings are printed
|
|
33
|
+
// by `doctor`, so they name FILES a reader can open, not internal ids.
|
|
34
|
+
export const SOURCES = Object.freeze({
|
|
35
|
+
config: CONFIG_REL,
|
|
36
|
+
index: ".doctrina/index.json (config block — legacy home)",
|
|
37
|
+
rules: RULES_REL + " (legacy home)",
|
|
38
|
+
default: "default",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export function configPath(projectRoot) {
|
|
42
|
+
return path.join(projectRoot, ".doctrina", "config.json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read the effective configuration.
|
|
47
|
+
*
|
|
48
|
+
* Precedence, per option: `.doctrina/config.json` → the legacy home for that
|
|
49
|
+
* option → the built-in default. Malformed JSON is REPORTED, never thrown:
|
|
50
|
+
* a project cannot be left unable to assemble a context pack because someone
|
|
51
|
+
* left a trailing comma in a preferences file — `validate` is where that
|
|
52
|
+
* becomes an error.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} projectRoot
|
|
55
|
+
* @returns {{
|
|
56
|
+
* language: string|null, context_budget: number, rules: any[],
|
|
57
|
+
* sources: {language: string, context_budget: string, rules: string},
|
|
58
|
+
* errors: string[],
|
|
59
|
+
* unknown: string[]
|
|
60
|
+
* }}
|
|
61
|
+
*/
|
|
62
|
+
export function loadConfig(projectRoot) {
|
|
63
|
+
const errors = [];
|
|
64
|
+
const cfg = readJson(configPath(projectRoot), CONFIG_REL, errors);
|
|
65
|
+
// Keys the CLI does not know (change 0115). `{"context_budjet": 10}` was
|
|
66
|
+
// silence at every door, and the author believed the budget was set.
|
|
67
|
+
// Reported, never rejected: the effective values are unchanged.
|
|
68
|
+
const KNOWN_KEYS = new Set(["language", "context_budget", "rules", "$comment", "$schema"]);
|
|
69
|
+
const unknown = cfg && typeof cfg === "object" && !Array.isArray(cfg)
|
|
70
|
+
? Object.keys(cfg).filter((k) => !KNOWN_KEYS.has(k))
|
|
71
|
+
: [];
|
|
72
|
+
/** @type {{language: string, context_budget: string, rules: string}} */
|
|
73
|
+
const sources = { language: SOURCES.default, context_budget: SOURCES.default, rules: SOURCES.default };
|
|
74
|
+
|
|
75
|
+
// language — config.json has always been its only home.
|
|
76
|
+
//
|
|
77
|
+
// PRESENCE is what makes an option configured, not the value: a key
|
|
78
|
+
// scaffolded as its default is still a declaration, and reporting it as
|
|
79
|
+
// "default" would tell a reader the file does not say what it plainly
|
|
80
|
+
// says. Only a key that is absent, or one whose value is rejected, falls
|
|
81
|
+
// back to the default.
|
|
82
|
+
let language = DEFAULTS.language;
|
|
83
|
+
if (cfg && "language" in cfg) {
|
|
84
|
+
const rawLang = cfg.language;
|
|
85
|
+
sources.language = SOURCES.config;
|
|
86
|
+
if (rawLang === null) {
|
|
87
|
+
language = null; // an explicit "detect per file"
|
|
88
|
+
} else if (typeof rawLang === "string" && normalizeLanguage(rawLang) !== null) {
|
|
89
|
+
language = normalizeLanguage(rawLang);
|
|
90
|
+
} else {
|
|
91
|
+
errors.push(`${CONFIG_REL}: unknown language ${JSON.stringify(rawLang)} (use "en" or "pt", or null to detect per file)`);
|
|
92
|
+
sources.language = SOURCES.default;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// context_budget — declared here, historically in index.json's config block.
|
|
97
|
+
let context_budget = DEFAULTS.context_budget;
|
|
98
|
+
if (cfg && "context_budget" in cfg && isPositiveInt(cfg.context_budget)) {
|
|
99
|
+
context_budget = cfg.context_budget;
|
|
100
|
+
sources.context_budget = SOURCES.config;
|
|
101
|
+
} else {
|
|
102
|
+
if (cfg && "context_budget" in cfg) {
|
|
103
|
+
errors.push(`${CONFIG_REL}: context_budget must be a positive integer (got ${JSON.stringify(cfg.context_budget)})`);
|
|
104
|
+
}
|
|
105
|
+
const fromIndex = readJson(path.join(projectRoot, ".doctrina", "index.json"), ".doctrina/index.json", [])?.config?.context_budget;
|
|
106
|
+
if (isPositiveInt(fromIndex)) {
|
|
107
|
+
context_budget = fromIndex;
|
|
108
|
+
sources.context_budget = SOURCES.index;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// rules — declared here, historically a file of their own.
|
|
113
|
+
let rules = DEFAULTS.rules;
|
|
114
|
+
if (cfg && Array.isArray(cfg.rules)) {
|
|
115
|
+
rules = cfg.rules;
|
|
116
|
+
sources.rules = SOURCES.config;
|
|
117
|
+
} else {
|
|
118
|
+
if (cfg && "rules" in cfg) {
|
|
119
|
+
errors.push(`${CONFIG_REL}: rules must be an array`);
|
|
120
|
+
}
|
|
121
|
+
const rulesPath = path.join(projectRoot, ".doctrina", "rules.json");
|
|
122
|
+
if (isFile(rulesPath)) {
|
|
123
|
+
const legacy = readJson(rulesPath, RULES_REL, errors);
|
|
124
|
+
if (legacy && !Array.isArray(legacy.rules)) {
|
|
125
|
+
errors.push(`${RULES_REL}: expected { "rules": [...] }`);
|
|
126
|
+
} else if (legacy) {
|
|
127
|
+
rules = legacy.rules;
|
|
128
|
+
sources.rules = SOURCES.rules;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { language, context_budget, rules, sources, errors, unknown };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The configuration as rows a reader can scan: option, effective value, and
|
|
138
|
+
* where it came from. `doctor` prints these; the point is that a project can
|
|
139
|
+
* see what it configured without reading the CLI's source.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} projectRoot
|
|
142
|
+
* @returns {{option: string, value: string, source: string, configured: boolean}[]}
|
|
143
|
+
*/
|
|
144
|
+
export function configRows(projectRoot) {
|
|
145
|
+
const cfg = loadConfig(projectRoot);
|
|
146
|
+
const show = (v) => (v === null ? "auto-detect per file" : String(v));
|
|
147
|
+
return [
|
|
148
|
+
{ option: "language", value: show(cfg.language), source: cfg.sources.language, configured: cfg.sources.language !== SOURCES.default },
|
|
149
|
+
{ option: "context_budget", value: `${cfg.context_budget} tokens`, source: cfg.sources.context_budget, configured: cfg.sources.context_budget !== SOURCES.default },
|
|
150
|
+
{ option: "rules", value: `${cfg.rules.length} rule${cfg.rules.length === 1 ? "" : "s"}`, source: cfg.sources.rules, configured: cfg.sources.rules !== SOURCES.default },
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** "pt-BR" → "pt", "en-GB" → "en", anything else → null. */
|
|
155
|
+
export function normalizeLanguage(raw) {
|
|
156
|
+
const lang = String(raw ?? "").toLowerCase();
|
|
157
|
+
if (lang.startsWith("pt")) return "pt";
|
|
158
|
+
if (lang.startsWith("en")) return "en";
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isPositiveInt(n) {
|
|
163
|
+
return Number.isInteger(n) && n > 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A malformed file is a finding, not an exception: every caller of this
|
|
167
|
+
// module is either a gate that will report it or a command that must keep
|
|
168
|
+
// working without it.
|
|
169
|
+
function readJson(file, rel, errors) {
|
|
170
|
+
if (!isFile(file)) return null;
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(read(file));
|
|
173
|
+
} catch (err) {
|
|
174
|
+
errors.push(`${rel} is not valid JSON: ${err.message}`);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The file `init` writes, from the same module that reads it.
|
|
181
|
+
*
|
|
182
|
+
* Written from the definition rather than from a template, for the reason
|
|
183
|
+
* index.json is: a template copy is how a project comes to be born already
|
|
184
|
+
* needing a scaffold update (audit item C5).
|
|
185
|
+
*
|
|
186
|
+
* It declares NOTHING. A scaffold that restated every default would make the
|
|
187
|
+
* file lie in two directions: `doctor` could no longer tell a choice from a
|
|
188
|
+
* default, and a written default would silently outrank a legacy declaration
|
|
189
|
+
* — a project that had set its budget in index.json would find it ignored by
|
|
190
|
+
* a file it never edited. So the scaffold is documentation of the options,
|
|
191
|
+
* and the first key a project adds is the first thing it has actually chosen.
|
|
192
|
+
* JSON carries no comments, hence `$comment`.
|
|
193
|
+
*
|
|
194
|
+
* @returns {string} the file's contents, newline-terminated
|
|
195
|
+
*/
|
|
196
|
+
export function scaffoldConfig() {
|
|
197
|
+
return JSON.stringify({
|
|
198
|
+
$comment:
|
|
199
|
+
"Doctrina project configuration. Nothing is declared here yet — add a key to " +
|
|
200
|
+
"change it. Options: \"language\" (\"en\" | \"pt\", or null to detect per file); " +
|
|
201
|
+
`"context_budget" (positive integer, default ${DEFAULTS.context_budget}); ` +
|
|
202
|
+
"\"rules\" (array of { id, forbid, paths, message } — permanent project " +
|
|
203
|
+
"constraints enforced by `doctrina validate`). Run `doctrina doctor` to see the " +
|
|
204
|
+
"effective value of each option and where it came from.",
|
|
205
|
+
}, null, 2) + "\n";
|
|
206
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, read } from "./fs-ops.js";
|
|
5
|
+
import { listHeader } from "./scan.js";
|
|
6
|
+
|
|
7
|
+
// The standing-rules MODEL: the accepted ADRs and the named sections of
|
|
8
|
+
// product.md that together are the project's constitution. `constitution`
|
|
9
|
+
// renders them in full and `prime` digests them to titles, so the reading
|
|
10
|
+
// belongs here rather than in either command (audit finding F7).
|
|
11
|
+
|
|
12
|
+
// Accepted ADRs (NNNN-slug.md with Status: accepted), oldest first, with the
|
|
13
|
+
// title read from the `# ADR NNNN — <title>` heading. Exported for `prime`,
|
|
14
|
+
// which digests the same standing rules into the session primer.
|
|
15
|
+
export function acceptedDecisions(projectRoot) {
|
|
16
|
+
const dir = path.join(projectRoot, ".doctrina", "decisions");
|
|
17
|
+
const out = [];
|
|
18
|
+
if (!isDir(dir)) return out;
|
|
19
|
+
for (const f of readdirSync(dir).sort()) {
|
|
20
|
+
const m = f.match(/^(\d{4})-.*\.md$/);
|
|
21
|
+
if (!m) continue;
|
|
22
|
+
const text = read(path.join(dir, f));
|
|
23
|
+
if ((listHeader(text, "Status") ?? "").toLowerCase() !== "accepted") continue;
|
|
24
|
+
const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
|
|
25
|
+
out.push({ id: m[1], title: titleMatch ? titleMatch[1].trim() : f });
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Bullets under a named `## <section>` of product.md, each accumulated across
|
|
31
|
+
// its continuation lines so a wrapped bullet reads as one rule. Exported for
|
|
32
|
+
// `prime`.
|
|
33
|
+
/**
|
|
34
|
+
* Does `product.md` carry this `## ` section at all? Separate from whether it
|
|
35
|
+
* holds anything, so a finding can name the remedy that actually resolves it
|
|
36
|
+
* (rule C2): "add the section" and "fill the section" are different asks.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} projectRoot
|
|
39
|
+
* @param {string} name
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function hasProductSection(projectRoot, name) {
|
|
43
|
+
const p = path.join(projectRoot, ".doctrina", "product.md");
|
|
44
|
+
if (!isFile(p)) return false;
|
|
45
|
+
return new RegExp(`^##\\s+${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "im")
|
|
46
|
+
.test(read(p));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function productSection(projectRoot, name) {
|
|
50
|
+
const p = path.join(projectRoot, ".doctrina", "product.md");
|
|
51
|
+
if (!isFile(p)) return [];
|
|
52
|
+
const lines = read(p).split(/\r?\n/);
|
|
53
|
+
let inSection = false;
|
|
54
|
+
let inComment = false;
|
|
55
|
+
const out = [];
|
|
56
|
+
let cur = null;
|
|
57
|
+
const flush = () => {
|
|
58
|
+
if (cur) out.push(cur.replace(/\s+/g, " ").trim());
|
|
59
|
+
cur = null;
|
|
60
|
+
};
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
if (/^##\s+/.test(line)) {
|
|
63
|
+
if (inSection) {
|
|
64
|
+
flush();
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
if (new RegExp(`^##\\s+${name}\\b`, "i").test(line)) inSection = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!inSection) continue;
|
|
71
|
+
// An HTML comment is annotation, not content (change 0055): the template's
|
|
72
|
+
// own instruction for this section must never read as a declared item.
|
|
73
|
+
if (inComment) {
|
|
74
|
+
if (line.includes("-->")) inComment = false;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (line.trim().startsWith("<!--")) {
|
|
78
|
+
if (!line.includes("-->")) inComment = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const m = line.match(/^\s*[-*]\s+(.+)$/);
|
|
82
|
+
if (m) {
|
|
83
|
+
flush();
|
|
84
|
+
cur = m[1];
|
|
85
|
+
} else if (cur && line.trim() !== "") {
|
|
86
|
+
cur += " " + line.trim();
|
|
87
|
+
} else if (line.trim() === "") {
|
|
88
|
+
flush();
|
|
89
|
+
} else {
|
|
90
|
+
// PROSE (change 0069). The section's own template comment invites it —
|
|
91
|
+
// "Explicit things this project will NOT try to be" asks for no bullets
|
|
92
|
+
// — and reading only bullets made `prime --rules` report a filled
|
|
93
|
+
// section as "none declared" and tell the author to add a section that
|
|
94
|
+
// was already there. Rule C2: a finding may only name a remedy that
|
|
95
|
+
// resolves it, and this one named an act already done. A paragraph is
|
|
96
|
+
// one item; a blank line ends it, exactly as it ends a bullet.
|
|
97
|
+
cur = line.trim();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
flush();
|
|
101
|
+
return out;
|
|
102
|
+
}
|