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
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, read, relPath, walk } from "./fs-ops.js";
|
|
5
|
+
import * as idx from "./index-json.js";
|
|
6
|
+
import { deriveIndex, indexesMatch, listHeader, specHeader } from "./scan.js";
|
|
7
|
+
import { collectRuntimeFindings } from "./runtime.js";
|
|
8
|
+
import { FIX_SHAPED } from "./lexicon.js";
|
|
9
|
+
import { summarize as coverageSummary } from "./coverage-model.js";
|
|
10
|
+
import { summarize as traceSummary } from "./trace-model.js";
|
|
11
|
+
|
|
12
|
+
// What comes next, as DATA (change 0032).
|
|
13
|
+
//
|
|
14
|
+
// This used to live in `commands/next.js` and return strings: the CLI
|
|
15
|
+
// assembled `"doctrina change archive 0031 — applied but not archived"`
|
|
16
|
+
// character by character, and then handed the agent a sentence to
|
|
17
|
+
// re-interpret in order to re-issue the command it had just built. Even
|
|
18
|
+
// `--json` returned that same prose inside quotes. Every other part of the
|
|
19
|
+
// framework exists to keep a machine from having to read English; this was
|
|
20
|
+
// the one place the deterministic chain leaked back into it.
|
|
21
|
+
//
|
|
22
|
+
// So an action is a record. `text` is the human rendering, built HERE from
|
|
23
|
+
// the same fields, so the sentence and the structure can never disagree —
|
|
24
|
+
// one builder owns both. A machine branches on `command`/`args`; a person
|
|
25
|
+
// reads `text`; `next --run` executes the first `runnable` one.
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {object} Action
|
|
29
|
+
* @property {string} id Stable slug for the KIND of action, not the instance.
|
|
30
|
+
* @property {string|null} command The doctrina operation ("change archive"), or null
|
|
31
|
+
* when the next step is authorship, not a command.
|
|
32
|
+
* @property {string[]} args
|
|
33
|
+
* @property {string} why One line: why this is next.
|
|
34
|
+
* @property {string|null} gate The gate this action clears, when it clears one.
|
|
35
|
+
* @property {"blocking"|"advisory"} severity
|
|
36
|
+
* @property {boolean} runnable Whether `next --run` may execute it unattended.
|
|
37
|
+
* @property {string} text The rendered line. Derived from the fields above.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build one action. `text` is derived unless the line needs a shape the
|
|
42
|
+
* default does not cover (a command embedded mid-sentence, a placeholder
|
|
43
|
+
* argument the caller must fill in).
|
|
44
|
+
*
|
|
45
|
+
* `runnable` defaults to FALSE and is opted into deliberately. An action is
|
|
46
|
+
* runnable only when running it unattended is both safe and the whole of
|
|
47
|
+
* what the action asks for. Anything that needs a human to DECIDE —
|
|
48
|
+
* accepting an ADR, writing a proposal, capturing a skill, ticking a box —
|
|
49
|
+
* is not runnable however mechanical the edit would be, because the edit is
|
|
50
|
+
* not the work.
|
|
51
|
+
*
|
|
52
|
+
* @param {Partial<Action> & { id: string, why: string }} spec
|
|
53
|
+
* @returns {Action}
|
|
54
|
+
*/
|
|
55
|
+
export function action(spec) {
|
|
56
|
+
const command = spec.command ?? null;
|
|
57
|
+
const args = spec.args ?? [];
|
|
58
|
+
return {
|
|
59
|
+
id: spec.id,
|
|
60
|
+
command,
|
|
61
|
+
args,
|
|
62
|
+
why: spec.why,
|
|
63
|
+
gate: spec.gate ?? null,
|
|
64
|
+
severity: spec.severity ?? "blocking",
|
|
65
|
+
runnable: spec.runnable ?? false,
|
|
66
|
+
text: spec.text ?? defaultText(command, args, spec.why),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The invocation a person would type, or null when there is no command. */
|
|
71
|
+
export function invocation(a) {
|
|
72
|
+
if (!a.command) return null;
|
|
73
|
+
return `doctrina ${[a.command, ...a.args].join(" ")}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function defaultText(command, args, why) {
|
|
77
|
+
const cmd = command ? `doctrina ${[command, ...args].join(" ")}` : null;
|
|
78
|
+
return cmd ? `${cmd} — ${why}` : why;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The priority-ordered action list. Shared by `next`, `prime`, `handoff`
|
|
83
|
+
* and `watch`.
|
|
84
|
+
*
|
|
85
|
+
* `gates` is the already-collected gate state, passed in by `snapshot.js`
|
|
86
|
+
* so the whole read costs one collection (change 0037). Omitted — as `next`
|
|
87
|
+
* omits it — the collectors run here instead; the answer is the same either
|
|
88
|
+
* way, which is the point.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} projectRoot
|
|
91
|
+
* @param {{ coverage?: any, trace?: any, verify?: any }} [gates]
|
|
92
|
+
* @returns {Action[]}
|
|
93
|
+
*/
|
|
94
|
+
export function computeActions(projectRoot, gates = {}) {
|
|
95
|
+
/** @type {Action[]} */
|
|
96
|
+
const actions = [];
|
|
97
|
+
|
|
98
|
+
// RUNTIME FIRST (change 0029). A declared wiring that does not hold is
|
|
99
|
+
// not a queue item — it is the reason the last run lied. After a job goes
|
|
100
|
+
// green having executed nothing, "open a change on the observability
|
|
101
|
+
// capability" sends the agent to polish the Markdown of an empty-state
|
|
102
|
+
// message while the cause sits in a file no other action names. A broken
|
|
103
|
+
// declaration outranks every artifact chore below it.
|
|
104
|
+
try {
|
|
105
|
+
const runtime = collectRuntimeFindings(projectRoot);
|
|
106
|
+
const errs = runtime.findings.filter((f) => f.level === "error");
|
|
107
|
+
if (errs.length > 0) {
|
|
108
|
+
actions.push(action({
|
|
109
|
+
id: "runtime-declarations",
|
|
110
|
+
command: "triage",
|
|
111
|
+
gate: "runtime",
|
|
112
|
+
runnable: true,
|
|
113
|
+
why:
|
|
114
|
+
`${errs.length} runtime declaration${errs.length === 1 ? " does" : "s do"} not hold ` +
|
|
115
|
+
`(first: ${errs[0].code} ${errs[0].message.slice(0, 80)}${errs[0].message.length > 80 ? "…" : ""})`,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// A malformed contract is `validate`'s finding to make, not a reason
|
|
120
|
+
// for `next` to fall over.
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A pending intake is the very first thing to resolve: until it is
|
|
124
|
+
// converted, product.md and the specs are still empty scaffolding.
|
|
125
|
+
const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
|
|
126
|
+
const intakeExists = isFile(intakePath);
|
|
127
|
+
if (intakeExists) {
|
|
128
|
+
const status = (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase();
|
|
129
|
+
if (status !== "converted") {
|
|
130
|
+
actions.push(action({
|
|
131
|
+
id: "intake-pending",
|
|
132
|
+
command: "intake",
|
|
133
|
+
runnable: true,
|
|
134
|
+
why: "a pending intake awaits conversion into product.md and specs",
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
} else if (specCapabilities(projectRoot).length === 0) {
|
|
138
|
+
// And the state BEFORE that one, which had no action at all (second
|
|
139
|
+
// audit). `init` does not write an intake.md — deliberately: an empty
|
|
140
|
+
// pending intake would be a mould passing for content, which change 0065
|
|
141
|
+
// just made a refusable thing. So the condition that means "the specs are
|
|
142
|
+
// not written yet" is read from what IS on disk: no capability spec.
|
|
143
|
+
//
|
|
144
|
+
// Without this the bootstrap door was invisible at exactly the moment it
|
|
145
|
+
// was needed. The hub tells the agent to check a file `init` never
|
|
146
|
+
// creates, and `prime`, `next` and `doctor` named `intake` nowhere.
|
|
147
|
+
actions.push(action({
|
|
148
|
+
id: "bootstrap-unspecced",
|
|
149
|
+
command: "intake",
|
|
150
|
+
args: ["--text", '"<what this project is>"'],
|
|
151
|
+
severity: "blocking",
|
|
152
|
+
why: "no capability is specced yet, so nothing states what this project must do",
|
|
153
|
+
text: 'doctrina intake --text "<what this project is>" — no capability is specced yet; ' +
|
|
154
|
+
"for an existing codebase, `doctrina work --from-diff` backfills from the code instead",
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Index drift comes right after the intake (change 0193). It used to be
|
|
159
|
+
// surfaced LAST as "silent rot" — but `validate` reports it as an error,
|
|
160
|
+
// the close's index-drift step blocks on it, the fix is one runnable
|
|
161
|
+
// command, and the actions below read the very index that drifted. Ranked
|
|
162
|
+
// fifth, under advisory nudges, it told the agent to polish product.md
|
|
163
|
+
// before running the one command that unblocked everything.
|
|
164
|
+
try {
|
|
165
|
+
const current = idx.load(projectRoot);
|
|
166
|
+
if (!indexesMatch(deriveIndex(projectRoot, current), current)) {
|
|
167
|
+
actions.push(action({
|
|
168
|
+
id: "index-drift",
|
|
169
|
+
command: "index rebuild",
|
|
170
|
+
gate: "index",
|
|
171
|
+
runnable: true,
|
|
172
|
+
why: "index.json has drifted from the tree",
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
actions.push(action({
|
|
177
|
+
id: "index-unreadable",
|
|
178
|
+
command: "index rebuild",
|
|
179
|
+
gate: "index",
|
|
180
|
+
runnable: true,
|
|
181
|
+
why: "index.json is missing or unreadable",
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
// Open changes drive the loop: finish what is started before opening more.
|
|
187
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
188
|
+
if (isDir(changesDir)) {
|
|
189
|
+
for (const id of readdirSync(changesDir).sort()) {
|
|
190
|
+
if (id === "archive" || id.startsWith(".")) continue;
|
|
191
|
+
if (!isDir(path.join(changesDir, id))) continue;
|
|
192
|
+
const proposalPath = path.join(changesDir, id, "proposal.md");
|
|
193
|
+
if (!isFile(proposalPath)) {
|
|
194
|
+
actions.push(action({
|
|
195
|
+
id: "change-missing-proposal",
|
|
196
|
+
why: `add a proposal.md to .doctrina/changes/${id}/ (open changes need one)`,
|
|
197
|
+
}));
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const status = listHeader(read(proposalPath), "Status") ?? "proposed";
|
|
201
|
+
if (status === "applied") {
|
|
202
|
+
actions.push(action({
|
|
203
|
+
id: "change-archive-pending",
|
|
204
|
+
command: "change archive",
|
|
205
|
+
args: [id],
|
|
206
|
+
gate: "archive",
|
|
207
|
+
runnable: true,
|
|
208
|
+
why: "applied but not archived",
|
|
209
|
+
}));
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const tasksPath = path.join(changesDir, id, "tasks.md");
|
|
213
|
+
const unchecked = isFile(tasksPath)
|
|
214
|
+
? (read(tasksPath).match(/^-\s+\[ \]/gm) ?? []).length
|
|
215
|
+
: 0;
|
|
216
|
+
if (unchecked > 0) {
|
|
217
|
+
// Deliberately NOT runnable, and deliberately without a command:
|
|
218
|
+
// the only thing that clears this is doing the work. `change tick`
|
|
219
|
+
// would tick the box, which is the dishonesty every gate here
|
|
220
|
+
// exists to prevent.
|
|
221
|
+
actions.push(action({
|
|
222
|
+
id: "change-tasks-open",
|
|
223
|
+
why: `complete ${unchecked} open task${unchecked === 1 ? "" : "s"} in .doctrina/changes/${id}/tasks.md`,
|
|
224
|
+
}));
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const deltas = walk(path.join(changesDir, id, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
228
|
+
if (deltas.length > 0) {
|
|
229
|
+
actions.push(action({
|
|
230
|
+
id: "change-apply-ready",
|
|
231
|
+
command: "change check",
|
|
232
|
+
args: [id],
|
|
233
|
+
gate: "structure",
|
|
234
|
+
runnable: true,
|
|
235
|
+
why: `tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`,
|
|
236
|
+
text: `doctrina change check ${id}, then doctrina close ${id} — ` +
|
|
237
|
+
`tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`,
|
|
238
|
+
}));
|
|
239
|
+
} else {
|
|
240
|
+
actions.push(action({
|
|
241
|
+
id: "change-deltas-missing",
|
|
242
|
+
command: "change apply",
|
|
243
|
+
args: [id],
|
|
244
|
+
why: `add spec deltas under .doctrina/changes/${id}/specs/, or apply as metadata-only`,
|
|
245
|
+
text: `add spec deltas under .doctrina/changes/${id}/specs/, ` +
|
|
246
|
+
`or apply as metadata-only: doctrina change apply ${id}`,
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ADRs stuck in proposed need a human decision; accepted-but-bare ADRs are
|
|
253
|
+
// the rot the review flagged — a decision with nothing behind it (no Evidence,
|
|
254
|
+
// no Landed) is drift waiting to be superseded. Surface the proposed ones
|
|
255
|
+
// first (a pending decision blocks more than a missing stamp).
|
|
256
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
257
|
+
/** @type {Action[]} */
|
|
258
|
+
const landNudges = [];
|
|
259
|
+
const isBareValue = (v) => {
|
|
260
|
+
const t = (v ?? "").trim();
|
|
261
|
+
return t === "" || t === "—" || t === "-";
|
|
262
|
+
};
|
|
263
|
+
for (const f of walk(adrDir)) {
|
|
264
|
+
if (!f.endsWith(".md")) continue;
|
|
265
|
+
const m = path.basename(f).match(/^(\d{4})-/);
|
|
266
|
+
if (!m) continue;
|
|
267
|
+
const text = read(f);
|
|
268
|
+
const status = listHeader(text, "Status");
|
|
269
|
+
if (status && status.toLowerCase() === "proposed") {
|
|
270
|
+
// Accepting a decision is the human's call, not a step to automate:
|
|
271
|
+
// `runnable` stays false however mechanical the header edit is.
|
|
272
|
+
actions.push(action({
|
|
273
|
+
id: "adr-proposed",
|
|
274
|
+
command: "decision accept",
|
|
275
|
+
args: [m[1]],
|
|
276
|
+
why: `ADR ${m[1]} is still proposed`,
|
|
277
|
+
text: `review ADR ${m[1]} (${relPath(projectRoot, f)}): doctrina decision accept ${m[1]}, or supersede it`,
|
|
278
|
+
}));
|
|
279
|
+
} else if (status && status.toLowerCase() === "accepted") {
|
|
280
|
+
// Bare only when BOTH anchors are empty (Evidence header present-but-bare
|
|
281
|
+
// and no Landed stamp). An ADR that opts out of Evidence entirely
|
|
282
|
+
// (header absent) is not nagged.
|
|
283
|
+
const evidence = listHeader(text, "Evidence");
|
|
284
|
+
const landed = listHeader(text, "Landed");
|
|
285
|
+
if (evidence !== null && isBareValue(evidence) && isBareValue(landed)) {
|
|
286
|
+
landNudges.push(action({
|
|
287
|
+
id: "adr-unproven",
|
|
288
|
+
command: "decision land",
|
|
289
|
+
args: [m[1]],
|
|
290
|
+
severity: "advisory",
|
|
291
|
+
why: `nothing proves ADR ${m[1]}`,
|
|
292
|
+
text: `record what proves ADR ${m[1]}: cite its **Evidence:**, or once it ships, doctrina decision land ${m[1]}`,
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
for (const n of landNudges) actions.push(n);
|
|
298
|
+
|
|
299
|
+
// No procedural memory captured yet, but the history shows a fix-shaped
|
|
300
|
+
// change — exactly the lesson a skill exists to keep from being relearned
|
|
301
|
+
// (review §5: the change-0003 "tolerate LLM code fences" case). One gentle
|
|
302
|
+
// nudge, only when skills are empty, so it never nags a project that opted in.
|
|
303
|
+
const skillNudge = suggestSkillCapture(projectRoot);
|
|
304
|
+
if (skillNudge) actions.push(skillNudge);
|
|
305
|
+
|
|
306
|
+
// GATE SIGNALS. `next` used to answer "no open work" over a tree where
|
|
307
|
+
// `doctor` reported five findings with a named remedy each: it knew the
|
|
308
|
+
// change/ADR/index/intake/runtime/skill lifecycle and nothing about whether
|
|
309
|
+
// the gates were satisfied. The command whose entire job is to answer "what
|
|
310
|
+
// now?" was the one that said "nothing" (second audit).
|
|
311
|
+
//
|
|
312
|
+
// Change 0037 unified the views so they could not disagree; this is that
|
|
313
|
+
// unification finally reaching `next`. Same collections, same numbers — the
|
|
314
|
+
// only difference is that a view REPORTS and an action RECOMMENDS.
|
|
315
|
+
// The gates measure capabilities, so they say nothing before any exists. A
|
|
316
|
+
// freshly initialised project must be told to run `intake`, not to write
|
|
317
|
+
// acceptance criteria for capabilities it has not named yet — three tidy
|
|
318
|
+
// recommendations ahead of the one that matters is the noise this whole
|
|
319
|
+
// change exists to remove.
|
|
320
|
+
const hasSpecs = specCapabilities(projectRoot).length > 0;
|
|
321
|
+
|
|
322
|
+
const cov = hasSpecs ? (gates.coverage ?? safely(() => coverageSummary(projectRoot))) : null;
|
|
323
|
+
if (cov) {
|
|
324
|
+
if (cov.totalDangling > 0) {
|
|
325
|
+
actions.push(action({
|
|
326
|
+
id: "coverage-dangling",
|
|
327
|
+
command: "coverage",
|
|
328
|
+
gate: "coverage",
|
|
329
|
+
why: `${cov.totalDangling} acceptance criterion(s) cite evidence missing on disk`,
|
|
330
|
+
}));
|
|
331
|
+
} else if (cov.totalCriteria === 0) {
|
|
332
|
+
actions.push(action({
|
|
333
|
+
id: "coverage-none",
|
|
334
|
+
command: null,
|
|
335
|
+
gate: "coverage",
|
|
336
|
+
severity: "advisory",
|
|
337
|
+
why: "no acceptance criteria declared yet — nothing proves any capability",
|
|
338
|
+
text: "write acceptance criteria with cited evidence — no capability is proven yet " +
|
|
339
|
+
"(`doctrina coverage` reports them once they exist)",
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const tr = hasSpecs ? (gates.trace ?? safely(() => traceSummary(projectRoot))) : null;
|
|
345
|
+
if (tr) {
|
|
346
|
+
if (tr.dropped > 0) {
|
|
347
|
+
actions.push(action({
|
|
348
|
+
id: "trace-dropped",
|
|
349
|
+
command: "trace",
|
|
350
|
+
gate: "trace",
|
|
351
|
+
why: `${tr.dropped} product intent anchor(s) are realized by no spec`,
|
|
352
|
+
}));
|
|
353
|
+
} else if (tr.anchors === 0) {
|
|
354
|
+
actions.push(action({
|
|
355
|
+
id: "trace-no-anchors",
|
|
356
|
+
command: null,
|
|
357
|
+
gate: "trace",
|
|
358
|
+
severity: "advisory",
|
|
359
|
+
why: "no intent anchors declared in product.md, so nothing traces to product intent",
|
|
360
|
+
text: 'tag product.md success criteria as "- [SC1] ..." and cite them with ' +
|
|
361
|
+
"**Realizes:** in each spec — nothing traces to product intent yet",
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// An active spec still `planned` with no note is an inventory claim: the
|
|
367
|
+
// document says the capability is current and the axis says nothing is built.
|
|
368
|
+
const inventoryClaims = activeButUnbuilt(projectRoot);
|
|
369
|
+
if (inventoryClaims.length > 0) {
|
|
370
|
+
actions.push(action({
|
|
371
|
+
id: "spec-inventory-claim",
|
|
372
|
+
command: "spec set",
|
|
373
|
+
args: [inventoryClaims[0], "--implementation", "auto"],
|
|
374
|
+
gate: "validate",
|
|
375
|
+
why: `${inventoryClaims.length} active spec(s) are still "planned" with no note ` +
|
|
376
|
+
`(${inventoryClaims.slice(0, 3).join(", ")})`,
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// The build gate undeclared is the quietest failure of all: `close` skips
|
|
381
|
+
// step 7 and every change ships without the project's own tests ever running.
|
|
382
|
+
const verify = hasSpecs ? (gates.verify ?? readVerifyState(projectRoot)) : { configured: true, invalid: false };
|
|
383
|
+
if (verify.invalid) {
|
|
384
|
+
actions.push(action({
|
|
385
|
+
id: "verify-invalid",
|
|
386
|
+
command: null,
|
|
387
|
+
gate: "verify",
|
|
388
|
+
why: ".doctrina/verify.json is not valid JSON, so the build gate cannot run",
|
|
389
|
+
text: "fix .doctrina/verify.json — it is not valid JSON, so `doctrina verify` " +
|
|
390
|
+
"and the close's build gate cannot run",
|
|
391
|
+
}));
|
|
392
|
+
} else if (!verify.configured) {
|
|
393
|
+
actions.push(action({
|
|
394
|
+
id: "verify-unconfigured",
|
|
395
|
+
command: "verify",
|
|
396
|
+
args: ["--init"],
|
|
397
|
+
gate: "verify",
|
|
398
|
+
// NOT runnable, though the command is one call: `verify --init` writes a
|
|
399
|
+
// fail-closed placeholder that a person still has to replace with the
|
|
400
|
+
// project's real commands, so running it unattended is not the whole of
|
|
401
|
+
// what the action asks for. Marking it runnable in change 0064 was a
|
|
402
|
+
// mistake — `next --run` picked it, found no runner registered, and
|
|
403
|
+
// errored where it should have said the next step needs a person.
|
|
404
|
+
why: "no .doctrina/verify.json — the close skips its build gate entirely",
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return actions;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Return a single skill-capture nudge, or null. Fires only when no skill has
|
|
412
|
+
// been written yet (skills/ holds nothing but .gitkeep) AND the archive shows a
|
|
413
|
+
// fix-shaped change whose lesson is the textbook case for a skill. Deterministic
|
|
414
|
+
// pattern match on the archived folder name — a hint, never a decision (ADR 0005).
|
|
415
|
+
|
|
416
|
+
function suggestSkillCapture(projectRoot) {
|
|
417
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
418
|
+
if (isDir(skillsDir)) {
|
|
419
|
+
const hasSkill = walk(skillsDir).some((f) => f.endsWith(".md"));
|
|
420
|
+
if (hasSkill) return null; // opted in already — never nag
|
|
421
|
+
}
|
|
422
|
+
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
423
|
+
if (!isDir(archiveDir)) return null;
|
|
424
|
+
for (const name of readdirSync(archiveDir).sort()) {
|
|
425
|
+
if (!isDir(path.join(archiveDir, name))) continue;
|
|
426
|
+
const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
427
|
+
if (FIX_SHAPED.test(id)) {
|
|
428
|
+
return action({
|
|
429
|
+
id: "skill-capture",
|
|
430
|
+
command: "skill new",
|
|
431
|
+
args: ["<slug>"],
|
|
432
|
+
severity: "advisory",
|
|
433
|
+
why: "a past fix went uncaptured and no skill exists yet",
|
|
434
|
+
text: `capture a skill from past fixes (e.g. "${id}"): doctrina skill new <slug> — ` +
|
|
435
|
+
`on-demand procedural memory so the next agent does not relearn it (none exist yet)`,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// A collector that throws is a finding for `validate` to make, never a reason
|
|
443
|
+
// for `next` to fall over — the same posture the runtime block above takes.
|
|
444
|
+
function safely(fn) {
|
|
445
|
+
try {
|
|
446
|
+
return fn();
|
|
447
|
+
} catch {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Whether the project declares its build gate. Deliberately the same three
|
|
453
|
+
// states `doctor` reports — configured, absent, invalid — read the same way.
|
|
454
|
+
function readVerifyState(projectRoot) {
|
|
455
|
+
const p = path.join(projectRoot, ".doctrina", "verify.json");
|
|
456
|
+
if (!isFile(p)) return { configured: false, invalid: false };
|
|
457
|
+
try {
|
|
458
|
+
const cfg = JSON.parse(read(p));
|
|
459
|
+
return { configured: Array.isArray(cfg?.checks) && cfg.checks.length > 0, invalid: false };
|
|
460
|
+
} catch {
|
|
461
|
+
return { configured: false, invalid: true };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Active capability specs whose Implementation is still a bare "planned".
|
|
466
|
+
// A note after the value ("planned — backend deferred to Q3") is the declared
|
|
467
|
+
// gap and is left alone, exactly as `validate` leaves it alone.
|
|
468
|
+
function specCapabilities(projectRoot) {
|
|
469
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
470
|
+
if (!isDir(specsDir)) return [];
|
|
471
|
+
return readdirSync(specsDir, { withFileTypes: true })
|
|
472
|
+
.filter((e) => e.isDirectory() && isFile(path.join(specsDir, e.name, "spec.md")))
|
|
473
|
+
.map((e) => e.name)
|
|
474
|
+
.sort();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function activeButUnbuilt(projectRoot) {
|
|
478
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
479
|
+
const out = [];
|
|
480
|
+
for (const cap of specCapabilities(projectRoot)) {
|
|
481
|
+
const entry = { name: cap };
|
|
482
|
+
const file = path.join(specsDir, entry.name, "spec.md");
|
|
483
|
+
const text = read(file);
|
|
484
|
+
const status = (specHeader(text, "Status") ?? "").toLowerCase();
|
|
485
|
+
const impl = (specHeader(text, "Implementation") ?? "").trim();
|
|
486
|
+
if (status !== "active") continue;
|
|
487
|
+
if (impl.toLowerCase() === "planned") out.push(entry.name);
|
|
488
|
+
}
|
|
489
|
+
return out.sort();
|
|
490
|
+
}
|
package/src/lib/adapters.js
CHANGED
|
@@ -126,15 +126,4 @@ export function renderAdapterFile(file, tokens) {
|
|
|
126
126
|
});
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
// Is this project-relative path one that some adapter owns? Used by
|
|
130
|
-
// `adapter remove` so it deletes only what an adapter created, and by
|
|
131
|
-
// `init` so it never counts adapter files as user content.
|
|
132
|
-
export function isAdapterOwnedPath(projectRoot, relPathPosix) {
|
|
133
|
-
for (const name of listAdapterNames(projectRoot)) {
|
|
134
|
-
const spec = adapterFiles(projectRoot, name);
|
|
135
|
-
if (spec?.files.some((f) => f.relativePath === relPathPosix)) return true;
|
|
136
|
-
}
|
|
137
|
-
return false;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
129
|
export { exists };
|
package/src/lib/adr-guard.js
CHANGED
|
@@ -12,6 +12,25 @@ import { listHeader } from "./scan.js";
|
|
|
12
12
|
// (`decision supersede` / `decision new`) instead of drifting past it.
|
|
13
13
|
// A word match on capability names — a hint, never a decision (ADR 0005),
|
|
14
14
|
// and never a blocker (an ADR merely MENTIONING a capability is normal).
|
|
15
|
+
/**
|
|
16
|
+
* How many ADRs on disk are accepted.
|
|
17
|
+
*
|
|
18
|
+
* The checkpoint prints "no accepted ADR cites the touched capabilities",
|
|
19
|
+
* which is conformance only when there ARE accepted ADRs to cite them. With
|
|
20
|
+
* none, the sentence is vacuously true and reads as a decision checked
|
|
21
|
+
* (change 0089), so the caller needs the count to tell the two apart.
|
|
22
|
+
*/
|
|
23
|
+
export function acceptedDecisionCount(projectRoot) {
|
|
24
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
25
|
+
if (!isDir(adrDir)) return 0;
|
|
26
|
+
let n = 0;
|
|
27
|
+
for (const f of walk(adrDir)) {
|
|
28
|
+
if (!/^\d{4}-.*\.md$/.test(path.basename(f))) continue;
|
|
29
|
+
if ((listHeader(read(f), "Status") ?? "").toLowerCase() === "accepted") n += 1;
|
|
30
|
+
}
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
33
|
+
|
|
15
34
|
export function adrAdvisories(projectRoot, capabilities) {
|
|
16
35
|
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
17
36
|
if (!isDir(adrDir) || capabilities.length === 0) return [];
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Drafting the agent-facing changelog from what actually shipped.
|
|
3
|
+
//
|
|
4
|
+
// `AGENT_CHANGELOG` in lib/commands.js is the block `upgrade --write` writes
|
|
5
|
+
// into AGENTS.md: at most five bullets telling an arriving agent what it must
|
|
6
|
+
// now DO differently. It is an object literal somebody edits by hand at every
|
|
7
|
+
// release, in parallel with a 57 KB prose CHANGELOG describing the same
|
|
8
|
+
// changes for people — and the comment above it already admits the approach
|
|
9
|
+
// "worked once and does not scale" (audit finding F22).
|
|
10
|
+
//
|
|
11
|
+
// This module does not write it. It DRAFTS candidates, from the signals the
|
|
12
|
+
// docs gate already extracts per archived change — the commands, flags and
|
|
13
|
+
// exit codes that change said it touched. Authorship stays human, because
|
|
14
|
+
// what an agent must do differently is a judgement about behaviour, and the
|
|
15
|
+
// CLI does not make those (ADR 0005). What it removes is the remembering.
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { isDir } from "./fs-ops.js";
|
|
18
|
+
import { readLedger } from "./ledger.js";
|
|
19
|
+
import { documentedSurfaceSignals } from "./docs-impact.js";
|
|
20
|
+
import { AGENT_CHANGELOG_MAX_BULLETS } from "./commands.js";
|
|
21
|
+
import { lastTagDate, windowCutoff } from "./git.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Which window a draft covers, and why that window.
|
|
25
|
+
*
|
|
26
|
+
* "Since the last tag" is the honest default: the block describes the
|
|
27
|
+
* release being cut, and the last tag is where the previous one ended. With
|
|
28
|
+
* no tags (a young project, a shallow clone) it falls back to a day window
|
|
29
|
+
* and SAYS which it used — a draft that quietly covered the wrong period
|
|
30
|
+
* would be worse than one that names its edges.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} projectRoot
|
|
33
|
+
* @param {{days?: number|null}} [options]
|
|
34
|
+
* @returns {{since: string, basis: string}}
|
|
35
|
+
*/
|
|
36
|
+
export function draftWindow(projectRoot, { days = null } = {}) {
|
|
37
|
+
if (days !== null) return { since: windowCutoff(days), basis: `the last ${days} days` };
|
|
38
|
+
const tag = lastTagDate(projectRoot);
|
|
39
|
+
if (tag) return { since: tag, basis: "changes archived since the last tag" };
|
|
40
|
+
return { since: windowCutoff(30), basis: "the last 30 days (no tag to measure from)" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Candidate bullets for the agent changelog, newest first.
|
|
45
|
+
*
|
|
46
|
+
* One candidate per archived change in the window that touched a documented
|
|
47
|
+
* surface. A change that touched none produces nothing — the block is for
|
|
48
|
+
* what an agent must do differently, and a change no agent can observe is
|
|
49
|
+
* not that.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} projectRoot
|
|
52
|
+
* @param {{days?: number|null, max?: number}} [options]
|
|
53
|
+
* @returns {{
|
|
54
|
+
* since: string, basis: string, max: number,
|
|
55
|
+
* candidates: {id: string, date: string, title: string, signals: string[], bullet: string}[],
|
|
56
|
+
* silent: {id: string, date: string, title: string}[],
|
|
57
|
+
* truncated: number
|
|
58
|
+
* }}
|
|
59
|
+
*/
|
|
60
|
+
export function draftAgentChangelog(projectRoot, { days = null, max = AGENT_CHANGELOG_MAX_BULLETS } = {}) {
|
|
61
|
+
const { since, basis } = draftWindow(projectRoot, { days });
|
|
62
|
+
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
63
|
+
|
|
64
|
+
const candidates = [];
|
|
65
|
+
const silent = [];
|
|
66
|
+
// The ledger is the record of what landed and when (change 0046); the
|
|
67
|
+
// archived folder is where the change's own artifacts still are.
|
|
68
|
+
const landed = readLedger(projectRoot).entries
|
|
69
|
+
.filter((e) => e.kind === "archived" && e.date >= since)
|
|
70
|
+
.sort((a, b) => b.date.localeCompare(a.date) || b.id.localeCompare(a.id));
|
|
71
|
+
|
|
72
|
+
for (const entry of landed) {
|
|
73
|
+
const dir = path.join(archiveDir, `${entry.date}-${entry.id}`);
|
|
74
|
+
const signals = isDir(dir) ? documentedSurfaceSignals(dir, projectRoot) : [];
|
|
75
|
+
if (signals.length === 0) {
|
|
76
|
+
silent.push({ id: entry.id, date: entry.date, title: entry.title });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
candidates.push({
|
|
80
|
+
id: entry.id,
|
|
81
|
+
date: entry.date,
|
|
82
|
+
title: entry.title,
|
|
83
|
+
signals,
|
|
84
|
+
bullet: `${entry.title} (${signals.join("; ")}).`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const truncated = Math.max(0, candidates.length - max);
|
|
89
|
+
return { since, basis, max, candidates: candidates.slice(0, max), silent, truncated };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The draft, rendered as the block a human edits into lib/commands.js.
|
|
94
|
+
*
|
|
95
|
+
* Deliberately shaped as the literal it will become, so the step is "cut and
|
|
96
|
+
* reword", not "translate". The bullets are drafts of the SHAPE — a title and
|
|
97
|
+
* the surface it touched — and every one of them is expected to be rewritten
|
|
98
|
+
* into what an agent must now do.
|
|
99
|
+
*
|
|
100
|
+
* @param {ReturnType<typeof draftAgentChangelog>} draft
|
|
101
|
+
* @param {string} version
|
|
102
|
+
* @returns {string[]} lines
|
|
103
|
+
*/
|
|
104
|
+
export function renderDraft(draft, version) {
|
|
105
|
+
const out = [];
|
|
106
|
+
out.push(`# Agent changelog draft for ${version} — ${draft.basis} (since ${draft.since})`);
|
|
107
|
+
out.push("");
|
|
108
|
+
if (draft.candidates.length === 0) {
|
|
109
|
+
out.push("No archived change in this window touched a documented surface.");
|
|
110
|
+
out.push("Nothing to tell an arriving agent — which is a valid answer, not an empty one.");
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
out.push("Proposed, newest first. REWRITE each into what an agent must now DO;");
|
|
114
|
+
out.push("this draft only knows what surface the change touched.");
|
|
115
|
+
out.push("");
|
|
116
|
+
out.push(` "${version}": [`);
|
|
117
|
+
for (const cand of draft.candidates) {
|
|
118
|
+
out.push(` // ${cand.date} ${cand.id}`);
|
|
119
|
+
out.push(` ${JSON.stringify(cand.bullet)},`);
|
|
120
|
+
}
|
|
121
|
+
out.push(" ],");
|
|
122
|
+
if (draft.truncated > 0) {
|
|
123
|
+
out.push("");
|
|
124
|
+
out.push(`${draft.truncated} further candidate${draft.truncated === 1 ? "" : "s"} did not fit the ${draft.max}-bullet cap.`);
|
|
125
|
+
out.push("The cap is AGENTS.md's line budget, not a preference: choose, do not raise it.");
|
|
126
|
+
}
|
|
127
|
+
if (draft.silent.length > 0) {
|
|
128
|
+
out.push("");
|
|
129
|
+
out.push(`${draft.silent.length} change${draft.silent.length === 1 ? "" : "s"} touched no documented surface and are not proposed:`);
|
|
130
|
+
for (const s of draft.silent) out.push(` ${s.date} ${s.id}`);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|