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/commands/close.js
CHANGED
|
@@ -2,24 +2,32 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { appendFileSync } from "node:fs";
|
|
5
|
-
import { exists, isFile, read, walk } from "../lib/fs-ops.js";
|
|
5
|
+
import { exists, isDir, isFile, read, walk } from "../lib/fs-ops.js";
|
|
6
6
|
import { flagBool } from "../lib/args.js";
|
|
7
7
|
import { c } from "../lib/colors.js";
|
|
8
|
-
import { parseCapabilityFromDelta } from "
|
|
9
|
-
import { printAdrCheckpoint } from "../lib/adr-guard.js";
|
|
10
|
-
import { checkDocsImpact } from "../lib/docs-impact.js";
|
|
11
|
-
import {
|
|
8
|
+
import { parseCapabilityFromDelta } from "../lib/doc-model.js";
|
|
9
|
+
import { printAdrCheckpoint, acceptedDecisionCount } from "../lib/adr-guard.js";
|
|
10
|
+
import { docsRemedy, checkDocsImpact, checkChangelogImpact } from "../lib/docs-impact.js";
|
|
11
|
+
import { collectRuntimeFindings } from "../lib/runtime.js";
|
|
12
|
+
import { derivedImplementations, implementationMismatch, summarize } from "../lib/coverage-model.js";
|
|
13
|
+
import { specHeader, dependentsOf } from "../lib/scan.js";
|
|
14
|
+
import { sequence, sequenceLabels, stepRerun } from "../lib/gates.js";
|
|
15
|
+
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
16
|
+
import { refuseChangeRef } from "../lib/project.js";
|
|
12
17
|
import * as analyze from "./analyze.js";
|
|
13
18
|
import * as change from "./change.js";
|
|
14
19
|
import * as verify from "./verify.js";
|
|
15
20
|
import * as coverage from "./coverage.js";
|
|
16
21
|
import * as trace from "./trace.js";
|
|
17
22
|
import * as validate from "./validate.js";
|
|
23
|
+
import * as indexRebuild from "./index-rebuild.js";
|
|
24
|
+
import { appendLedgerLine, docsGapLine, ledgerPath as ledgerFile } from "../lib/ledger.js";
|
|
25
|
+
import * as review from "./review.js";
|
|
18
26
|
import * as skill from "./skill.js";
|
|
19
27
|
|
|
20
28
|
// One-command close (review 2026-06-27 passive-user feature #2). The work
|
|
21
|
-
// playbook lists the closing sequence — analyze → apply →
|
|
22
|
-
// trace → archive → validate — and the agent runs it step by step, which is
|
|
29
|
+
// playbook lists the closing sequence — analyze → apply → runtime → verify →
|
|
30
|
+
// coverage → trace → archive → validate — and the agent runs it step by step, which is
|
|
23
31
|
// exactly where a gate gets skipped. `close` runs the whole sequence in one
|
|
24
32
|
// pass, in-process, stopping at the first failure with the exact command to
|
|
25
33
|
// rerun, so the agent makes one call and the human approves once. It is a
|
|
@@ -61,6 +69,16 @@ export async function run(positional, flags) {
|
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
async function closeOne(projectRoot, id, flags) {
|
|
72
|
+
const refused = refuseChangeRef(id);
|
|
73
|
+
if (refused !== null) return refused;
|
|
74
|
+
// A change id that does not resolve is the USAGE class, before any step
|
|
75
|
+
// is sequenced (change 0114) — every sibling that takes a change id
|
|
76
|
+
// answers 2; `close` answered 1 through the analyze step it ran first.
|
|
77
|
+
if (!isDir(path.join(projectRoot, ".doctrina", "changes", id))) {
|
|
78
|
+
console.error(c.red("error:") + ` change "${id}" not found at .doctrina/changes/${id}`);
|
|
79
|
+
console.error(c.gray("hint: ") + "open changes: `doctrina next`");
|
|
80
|
+
return EXIT.USAGE;
|
|
81
|
+
}
|
|
64
82
|
const force = flagBool(flags, "force", false);
|
|
65
83
|
const archiveFlags = force ? new Map([["force", true]]) : new Map();
|
|
66
84
|
// Set by the docs gate when it fails, so a --force close can record the
|
|
@@ -82,67 +100,256 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
82
100
|
coverageRerun = `doctrina coverage --strict --only ${touched.join(",")}`;
|
|
83
101
|
}
|
|
84
102
|
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
103
|
+
// How each declared step actually runs, keyed by the step id in
|
|
104
|
+
// SEQUENCES.close. The DECLARATION owns which steps exist, in what order,
|
|
105
|
+
// and how hard each bites (lib/gates.js, audit finding F1); this table owns
|
|
106
|
+
// only how to execute one in-process. A step declared with no runner here
|
|
107
|
+
// falls back to spawning its declared `argv`, so a gate added to the
|
|
108
|
+
// sequence appears in `close` — and in `doctor` and the CI action —
|
|
109
|
+
// without a second edit; a drift test pins the two together.
|
|
110
|
+
//
|
|
111
|
+
// Each runner returns an exit code and may replace the step's label or
|
|
112
|
+
// rerun line with a context-aware one (the coverage scope, the --force
|
|
113
|
+
// archive), which is rendering, not sequencing.
|
|
88
114
|
const verifyConfigured = isFile(path.join(projectRoot, ".doctrina", "verify.json"));
|
|
89
|
-
const
|
|
90
|
-
|
|
115
|
+
const runners = {
|
|
116
|
+
analyze: { run: () => analyze.run([id], new Map()) },
|
|
117
|
+
|
|
91
118
|
// ADR checkpoint (operator review §4.6): the playbook's "record an ADR"
|
|
92
119
|
// step was skippable in silence. Advisory — it names the accepted ADRs
|
|
93
120
|
// whose text cites the touched capabilities and the amend commands, but
|
|
94
121
|
// an ADR merely mentioning a capability is normal, so it never blocks.
|
|
95
|
-
{
|
|
96
|
-
label: "ADR checkpoint (advisory)",
|
|
97
|
-
rerun: "doctrina decision list",
|
|
122
|
+
"adr-checkpoint": {
|
|
98
123
|
run: async () => {
|
|
99
124
|
if (printAdrCheckpoint(projectRoot, touched, { c }) === 0) {
|
|
100
|
-
|
|
125
|
+
// "No ADR cites these capabilities" is conformance only when there
|
|
126
|
+
// ARE accepted ADRs to cite them. With none on disk the sentence is
|
|
127
|
+
// vacuously true, and printing it as `ok` reads as a decision
|
|
128
|
+
// checked rather than a checkbox with nothing behind it (0089).
|
|
129
|
+
const accepted = acceptedDecisionCount(projectRoot);
|
|
130
|
+
if (accepted === 0) {
|
|
131
|
+
console.log(c.gray("· no accepted ADR in the tree — nothing to check the change against"));
|
|
132
|
+
} else {
|
|
133
|
+
console.log(c.green("ok") + ` no accepted ADR cites the touched capabilities (${accepted} checked)`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
// The review runs against the working tree, which for a close IS the
|
|
141
|
+
// change's diff: the work is done and not yet archived. Its exit code is
|
|
142
|
+
// discarded on purpose — the step is declared advisory, and an advisory
|
|
143
|
+
// step that could still move the close's result would be advisory in name
|
|
144
|
+
// only. A crash is caught by the loop and reported the same way.
|
|
145
|
+
review: {
|
|
146
|
+
run: async () => {
|
|
147
|
+
await review.run([], new Map());
|
|
148
|
+
return 0;
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
apply: { run: () => change.run(["apply", id], new Map()) },
|
|
153
|
+
|
|
154
|
+
// The RUNTIME gate (audit finding F2). RT01–RT05 live in lib/runtime.js
|
|
155
|
+
// and no default driver ran them: `close` did not, `validate` only under
|
|
156
|
+
// --runtime, and the published action not at all — so the one class of
|
|
157
|
+
// break every structural gate is blind to (the declaration that no
|
|
158
|
+
// longer matches the running system) reached production green. It sits
|
|
159
|
+
// after `apply`, because the deltas the apply just merged are what may
|
|
160
|
+
// have moved the surface the contract describes. A driver over
|
|
161
|
+
// lib/runtime.js: the same findings `contract check`, `triage` and
|
|
162
|
+
// `doctor` render, so the five can never disagree.
|
|
163
|
+
//
|
|
164
|
+
// Severity decides the level: an `error` blocks the close, a `warn` is
|
|
165
|
+
// reported and the close continues. A project with no contracts — or
|
|
166
|
+
// with contracts that declare no Wiring/Selectors rows — is not passing,
|
|
167
|
+
// it is UNCHECKED, and saying so is what stops silence from reading as
|
|
168
|
+
// proof.
|
|
169
|
+
runtime: {
|
|
170
|
+
run: async () => {
|
|
171
|
+
const { findings, contracts, declared } = collectRuntimeFindings(projectRoot);
|
|
172
|
+
if (contracts === 0) {
|
|
173
|
+
console.log(c.gray("· no contracts — nothing declares a runtime surface"));
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
for (const f of findings) {
|
|
177
|
+
const mark = f.level === "error" ? c.red(" ✗ ") : c.yellow(" ! ");
|
|
178
|
+
console.log(mark + `${f.contract}: ${f.message}` + c.gray(` [${f.code}]`));
|
|
179
|
+
console.log(` ${c.gray(`fix: ${f.remedy}`)}`);
|
|
180
|
+
}
|
|
181
|
+
const errs = findings.filter((f) => f.level === "error").length;
|
|
182
|
+
if (errs > 0) {
|
|
183
|
+
console.error(c.red("error:") + ` ${errs} contract finding${errs === 1 ? "" : "s"} block${errs === 1 ? "s" : ""} the close — the same ${errs === 1 ? "one" : "ones"} \`doctrina contract check\` reports`);
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
186
|
+
if (declared === 0) {
|
|
187
|
+
console.log(c.yellow("warn: ") +
|
|
188
|
+
`${contracts} contract${contracts === 1 ? "" : "s"}, 0 Wiring/Selectors rows — the runtime surface is unchecked`);
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
const warns = findings.length;
|
|
192
|
+
console.log(c.green("ok") + ` ${declared} declared row${declared === 1 ? " holds" : "s hold"}` +
|
|
193
|
+
(warns > 0 ? c.gray(`; ${warns} advisory finding${warns === 1 ? "" : "s"} above`) : ""));
|
|
194
|
+
return 0;
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
// The Implementation header, PROPOSED rather than remembered (audit
|
|
199
|
+
// finding F10). The `work` playbook asked the agent twice to advance a
|
|
200
|
+
// field whose correct value coverage had already computed one file over.
|
|
201
|
+
// Advisory and non-mutating by design: the close prints the `set-header`
|
|
202
|
+
// op for the capabilities this change touched, and a human — or
|
|
203
|
+
// `spec set --implementation auto` — applies it. A gate that rewrote the
|
|
204
|
+
// claim it checks would be marking its own homework.
|
|
205
|
+
implementation: {
|
|
206
|
+
run: async () => {
|
|
207
|
+
const scope = touched.length > 0 ? new Set(touched) : null;
|
|
208
|
+
const derived = derivedImplementations(projectRoot, { only: scope });
|
|
209
|
+
const proposals = [];
|
|
210
|
+
for (const [cap, row] of derived) {
|
|
211
|
+
const specPath = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
|
|
212
|
+
if (!isFile(specPath)) continue;
|
|
213
|
+
const mismatch = implementationMismatch(specHeader(read(specPath), "Implementation"), row.derived);
|
|
214
|
+
if (mismatch) proposals.push({ cap, row, mismatch });
|
|
215
|
+
}
|
|
216
|
+
if (derived.size === 0) {
|
|
217
|
+
// "Every touched spec matches" over ZERO specs is vacuously true.
|
|
218
|
+
// A chore in a project with no capability spec reached this and was
|
|
219
|
+
// told its implementation headers were in order (change 0089).
|
|
220
|
+
console.log(c.gray("· no spec to check — the change touches no capability"));
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
if (proposals.length === 0) {
|
|
224
|
+
console.log(c.green("ok") + ` every touched spec's Implementation header matches its coverage (${derived.size} checked)`);
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
for (const { cap, row, mismatch } of proposals) {
|
|
228
|
+
console.log(c.yellow(" ! ") + `${cap}: Implementation is "${mismatch.written}" but ` +
|
|
229
|
+
`${row.covered}/${row.total} criteria have resolving proof`);
|
|
230
|
+
console.log(` ${c.gray("delta op: ")}${c.cyan(mismatch.op)}`);
|
|
231
|
+
console.log(` ${c.gray("or: ")}${c.cyan(`doctrina spec set ${cap} --implementation auto`)}`);
|
|
101
232
|
}
|
|
102
233
|
return 0;
|
|
103
234
|
},
|
|
104
235
|
},
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
236
|
+
|
|
237
|
+
// verify is conditional: skipped with a loud note when no verify.json is
|
|
238
|
+
// declared, because a project may not have wired the real gate yet.
|
|
239
|
+
verify: verifyConfigured
|
|
240
|
+
? { run: () => verify.run([], new Map()) }
|
|
241
|
+
: { skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
|
|
242
|
+
|
|
243
|
+
coverage: {
|
|
110
244
|
label: touched.length > 0 ? `coverage (scoped: ${touched.join(", ")})` : "coverage",
|
|
111
245
|
rerun: coverageRerun,
|
|
112
|
-
run: () =>
|
|
246
|
+
run: async () => {
|
|
247
|
+
const code = await coverage.run([], coverageFlags);
|
|
248
|
+
reportDependentCoverage(projectRoot, touched);
|
|
249
|
+
return code;
|
|
250
|
+
},
|
|
113
251
|
},
|
|
252
|
+
|
|
114
253
|
// trace is advisory (provenance is a warning, not a hard gate): report it,
|
|
115
254
|
// never let it block the close.
|
|
116
|
-
|
|
255
|
+
trace: { run: async () => { await trace.run([], new Map()); return 0; } },
|
|
256
|
+
|
|
117
257
|
// Docs ship inside the change (D2): a change that alters a documented
|
|
118
258
|
// surface — a command, a flag, an exit code — closes only with the
|
|
119
259
|
// documentation that describes it. A blocking gate, because a docs
|
|
120
260
|
// phase scheduled after the work never happens; --force is the same
|
|
121
261
|
// escape hatch archive offers, and records the gap in the ledger.
|
|
122
|
-
{
|
|
123
|
-
label: "docs",
|
|
124
|
-
forceable: true,
|
|
125
|
-
rerun: "edit docs/ (EN + PT), then rerun",
|
|
262
|
+
docs: {
|
|
126
263
|
run: async () => {
|
|
127
|
-
const
|
|
264
|
+
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
265
|
+
const r = checkDocsImpact(projectRoot, changeDir);
|
|
128
266
|
if (r.ok) {
|
|
129
267
|
console.log(c.green("ok") + ` ${r.reason}`);
|
|
130
|
-
|
|
268
|
+
// Documentation says how it works; the changelog says that it
|
|
269
|
+
// changed. Asking only the first is how thirteen changes closed in
|
|
270
|
+
// one session with an empty `## [Unreleased]` behind them.
|
|
271
|
+
const log = checkChangelogImpact(projectRoot, changeDir);
|
|
272
|
+
if (log.ok) {
|
|
273
|
+
console.log(c.green("ok") + ` ${log.reason}`);
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
console.error(c.red("error:") + ` this change ${log.reason}:`);
|
|
277
|
+
for (const s of log.signals) console.error(` - ${s}`);
|
|
278
|
+
console.error(c.gray("hint: ") + "add an entry under the changelog's unreleased heading" +
|
|
279
|
+
", or pass --force to close anyway (records the gap)");
|
|
280
|
+
docsGap = log;
|
|
281
|
+
return 1;
|
|
131
282
|
}
|
|
132
283
|
console.error(c.red("error:") + ` this change ${r.reason}:`);
|
|
133
284
|
for (const s of r.signals) console.error(` - ${s}`);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
285
|
+
// The remedy comes out of the project being checked, never out of
|
|
286
|
+
// Doctrina's own repository (change 0058): an adopting project with
|
|
287
|
+
// no `docs/` was being told to write English AND Portuguese and to
|
|
288
|
+
// read a skill it does not have.
|
|
289
|
+
console.error(c.gray("hint: ") + docsRemedy(projectRoot) + ".");
|
|
290
|
+
// THE SECOND ANSWER, NAMED WHERE IT IS NEEDED.
|
|
291
|
+
//
|
|
292
|
+
// The gate reads names, and a name is all it can see: a change that
|
|
293
|
+
// explains an effect, or a Scope boundaries line saying what it does
|
|
294
|
+
// NOT touch, reads exactly like one that alters the command. When the
|
|
295
|
+
// only remedy on offer was `--force`, an author looking at a false
|
|
296
|
+
// positive took the worst of the two exits — the ledger fills with
|
|
297
|
+
// gaps that were never gaps, and a real one stops standing out.
|
|
298
|
+
//
|
|
299
|
+
// The declaration exists for precisely this (change 0139) and was
|
|
300
|
+
// named nowhere the author would be looking.
|
|
301
|
+
console.error(c.gray(" ") + "if the names above are only MENTIONED, say so on the record: " +
|
|
302
|
+
"add `- **Documented surface:** n/a — <why>` to the proposal.");
|
|
303
|
+
console.error(c.gray(" ") + "or pass --force to close anyway (records the gap).");
|
|
137
304
|
docsGap = r;
|
|
138
305
|
return 1;
|
|
139
306
|
},
|
|
140
307
|
},
|
|
141
|
-
{ label: "archive", rerun: `doctrina change archive ${id}${force ? " --force" : ""}`, run: () => change.run(["archive", id], archiveFlags) },
|
|
142
|
-
{ label: "validate", rerun: "doctrina validate", run: () => validate.run([], new Map()) },
|
|
143
|
-
];
|
|
144
308
|
|
|
145
|
-
|
|
309
|
+
archive: {
|
|
310
|
+
rerun: `doctrina change archive ${id}${force ? " --force" : ""}`,
|
|
311
|
+
run: () => change.run(["archive", id], archiveFlags),
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
// In-process, like every other runner here: the close starts no subprocess
|
|
315
|
+
// of its own binary, and a criterion holds it to that.
|
|
316
|
+
"index-drift": { run: () => indexRebuild.run(["rebuild"], new Map([["check", true]])) },
|
|
317
|
+
|
|
318
|
+
validate: { run: () => validate.run([], new Map()) },
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const steps = sequence("close").map((step) => {
|
|
322
|
+
const runner = runners[step.id] ?? {};
|
|
323
|
+
return {
|
|
324
|
+
id: step.id,
|
|
325
|
+
label: runner.label ?? step.label,
|
|
326
|
+
rerun: runner.rerun ?? stepRerun(step, id),
|
|
327
|
+
forceable: step.level === "forceable",
|
|
328
|
+
advisory: step.level === "advisory",
|
|
329
|
+
skip: runner.skip,
|
|
330
|
+
// A step declared with no in-process runner is a gap in THIS file, and
|
|
331
|
+
// it is reported as one — the same answer `doctor` gives (change 0060).
|
|
332
|
+
// The close used to shell out to its own binary here instead, which was
|
|
333
|
+
// the second integration style change 0045 removed from `doctor`; every
|
|
334
|
+
// declared step has a runner and a test holds it that way, so the
|
|
335
|
+
// fallback was a second answer to a question nobody could reach.
|
|
336
|
+
run: runner.run ?? (() => missingRunner(step)),
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// The banner is the declaration read aloud, so it cannot fall behind the
|
|
341
|
+
// sequence it announces the way a hand-maintained string did.
|
|
342
|
+
console.log(c.bold(`Closing change ${id}`) +
|
|
343
|
+
c.gray(` — ${sequence("close").map((s) => s.label.replace(" (advisory)", "")).join(" → ")}`));
|
|
344
|
+
|
|
345
|
+
// What the closing line is allowed to claim. A step that was SKIPPED was
|
|
346
|
+
// not performed, and the conclusion used to say "verified, archived, and
|
|
347
|
+
// validated" as a fixed string — including on a close whose own step 7 had
|
|
348
|
+
// just printed `skip no .doctrina/verify.json`. The final line is the one
|
|
349
|
+
// sentence a human reads before approving; a word it did not earn is the
|
|
350
|
+
// most expensive claim in the tree (change 0089).
|
|
351
|
+
const ran = new Set();
|
|
352
|
+
const skippedSteps = [];
|
|
146
353
|
|
|
147
354
|
for (let i = 0; i < steps.length; i++) {
|
|
148
355
|
const step = steps[i];
|
|
@@ -150,6 +357,7 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
150
357
|
console.log(c.gray(`──── ${i + 1}/${steps.length} ${step.label}`));
|
|
151
358
|
if (step.skip) {
|
|
152
359
|
console.log(c.yellow("skip ") + step.skip);
|
|
360
|
+
skippedSteps.push(step);
|
|
153
361
|
continue;
|
|
154
362
|
}
|
|
155
363
|
let code;
|
|
@@ -160,6 +368,13 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
160
368
|
console.error(c.red("error:") + ` ${err.message}`);
|
|
161
369
|
}
|
|
162
370
|
if (code !== 0) {
|
|
371
|
+
// The declared level decides, not the runner: an advisory step reports
|
|
372
|
+
// and the close carries on, which is what "advisory" means in
|
|
373
|
+
// lib/gates.js and what the surfaces must agree on.
|
|
374
|
+
if (step.advisory) {
|
|
375
|
+
console.log(c.yellow("warn:") + ` "${step.label}" reported findings (advisory — the close continues)`);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
163
378
|
// A forceable gate under --force warns and continues, matching how
|
|
164
379
|
// `change archive --force` handles incomplete verification: the gap
|
|
165
380
|
// is recorded, not hidden.
|
|
@@ -174,14 +389,17 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
174
389
|
console.log(` ${c.cyan(`doctrina close ${id}`)}`);
|
|
175
390
|
return 1;
|
|
176
391
|
}
|
|
392
|
+
ran.add(step.id);
|
|
177
393
|
}
|
|
178
394
|
|
|
179
395
|
// Record a forced docs gap in the ledger, so history shows the change
|
|
180
396
|
// shipped without its documentation rather than showing nothing.
|
|
181
397
|
if (docsGap) {
|
|
182
|
-
const ledgerPath =
|
|
398
|
+
const ledgerPath = ledgerFile(projectRoot);
|
|
183
399
|
if (isFile(ledgerPath)) {
|
|
184
|
-
|
|
400
|
+
// Written in the ledger's own entry grammar (change 0046): a waived
|
|
401
|
+
// gate no reader can find is the same as an unrecorded one.
|
|
402
|
+
appendLedgerLine(projectRoot, docsGapLine(id, docsGap.signals));
|
|
185
403
|
console.log(c.yellow("ledger") + " recorded the docs gap");
|
|
186
404
|
}
|
|
187
405
|
}
|
|
@@ -197,13 +415,74 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
197
415
|
} catch { /* advisory only */ }
|
|
198
416
|
|
|
199
417
|
console.log("");
|
|
200
|
-
console.log(c.green(`✓ change ${id} closed`) + c.gray(
|
|
418
|
+
console.log(c.green(`✓ change ${id} closed`) + c.gray(closingClaim(ran, skippedSteps)));
|
|
201
419
|
console.log(c.gray("Next: ") + c.cyan("doctrina next"));
|
|
202
420
|
return 0;
|
|
203
421
|
}
|
|
204
422
|
|
|
423
|
+
// A step the sequence declares and this file does not implement. The gate map
|
|
424
|
+
// is the single source of truth for what a close runs (ADR 0017), so a step
|
|
425
|
+
// with no runner here is a defect in this file — not a reason to start a
|
|
426
|
+
// second process and not a reason to pass silently. `doctor` reaches the same
|
|
427
|
+
// conclusion for its own reporters; the two drivers now give one answer.
|
|
428
|
+
// The closing sentence, built from the steps that actually ran.
|
|
429
|
+
//
|
|
430
|
+
// Each word names one step: `verify` -> verified, `archive` -> archived,
|
|
431
|
+
// `validate` -> validated. A step that was skipped loses its word and is
|
|
432
|
+
// named instead, so the line can never claim more than the run performed.
|
|
433
|
+
function closingClaim(ran, skippedSteps) {
|
|
434
|
+
const words = [
|
|
435
|
+
["verify", "verified"],
|
|
436
|
+
["archive", "archived"],
|
|
437
|
+
["validate", "validated"],
|
|
438
|
+
].filter(([stepId]) => ran.has(stepId)).map(([, word]) => word);
|
|
439
|
+
|
|
440
|
+
const claimed = words.length === 0
|
|
441
|
+
? ""
|
|
442
|
+
: words.length === 1
|
|
443
|
+
? ` — ${words[0]}.`
|
|
444
|
+
: ` — ${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}.`;
|
|
445
|
+
|
|
446
|
+
const skipped = skippedSteps.map((s) => s.label.replace(" (advisory)", ""));
|
|
447
|
+
if (skipped.length === 0) return claimed || " — no gate ran.";
|
|
448
|
+
return `${claimed || " —"} ${skipped.length === 1 ? "Skipped" : "Skipped"}: ${skipped.join(", ")}.`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function missingRunner(step) {
|
|
452
|
+
console.error(c.red("error:") +
|
|
453
|
+
` "${step.label}" is declared in the close sequence but has no runner here`);
|
|
454
|
+
if (step.argv) {
|
|
455
|
+
console.error(c.gray("hint: ") + `run it directly: doctrina ${step.argv.join(" ")}`);
|
|
456
|
+
}
|
|
457
|
+
return 1;
|
|
458
|
+
}
|
|
459
|
+
|
|
205
460
|
// The capabilities this change's deltas target — the honest scope for its
|
|
206
461
|
// coverage gate. Read from the change folder's specs/**/delta.md files.
|
|
462
|
+
// The capabilities that DECLARE a dependency on what this change touched,
|
|
463
|
+
// with their coverage — advisory, never part of the verdict (change 0046).
|
|
464
|
+
//
|
|
465
|
+
// The scope exists because one deliberately deferred spec elsewhere in the
|
|
466
|
+
// tree must not block a change that never went near it, and widening the gate
|
|
467
|
+
// to dependents would give that problem straight back. What a closing agent
|
|
468
|
+
// actually needs is the pointer: this change moved ground something else
|
|
469
|
+
// stands on, and here is how well that something is proven today.
|
|
470
|
+
function reportDependentCoverage(projectRoot, touched) {
|
|
471
|
+
if (touched.length === 0) return;
|
|
472
|
+
const dependents = dependentsOf(projectRoot, touched);
|
|
473
|
+
if (dependents.length === 0) return;
|
|
474
|
+
const byCap = new Map(summarize(projectRoot).perCap.map((r) => [r.cap, r]));
|
|
475
|
+
console.log(c.gray(" dependents of the touched capabilities (advisory — not part of this gate):"));
|
|
476
|
+
for (const dep of dependents) {
|
|
477
|
+
const row = byCap.get(dep.capability);
|
|
478
|
+
const proof = row && row.total > 0
|
|
479
|
+
? `${row.covered}/${row.total} criteria proven` + (row.dangling ? `, ${row.dangling} dangling` : "")
|
|
480
|
+
: "no acceptance criteria";
|
|
481
|
+
console.log(` ${c.cyan(dep.capability)} depends on ${dep.dependsOn.join(", ")} — ${proof}` +
|
|
482
|
+
c.gray(` (doctrina why ${dep.capability})`));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
207
486
|
function touchedCapabilities(projectRoot, id) {
|
|
208
487
|
const specsDir = path.join(projectRoot, ".doctrina", "changes", id, "specs");
|
|
209
488
|
const caps = new Set();
|
|
@@ -215,24 +494,49 @@ function touchedCapabilities(projectRoot, id) {
|
|
|
215
494
|
return [...caps].sort();
|
|
216
495
|
}
|
|
217
496
|
|
|
497
|
+
// The sequence is DECLARED once, in lib/gates.js, and rendered here. It used
|
|
498
|
+
// to be typed out in this string, which is how the help came to name ten
|
|
499
|
+
// steps of thirteen — review, implementation, docs and the index-drift check
|
|
500
|
+
// were added over time and the paragraph was not.
|
|
501
|
+
const SEQUENCE = wrapArrows(sequenceLabels("close"), 70, " ");
|
|
502
|
+
|
|
503
|
+
function wrapArrows(labels, width, indent) {
|
|
504
|
+
const lines = [];
|
|
505
|
+
let line = "";
|
|
506
|
+
for (const label of labels) {
|
|
507
|
+
const piece = line === "" ? label : `${line} → ${label}`;
|
|
508
|
+
if (piece.length + indent.length > width && line !== "") {
|
|
509
|
+
lines.push(indent + line + " →");
|
|
510
|
+
line = label;
|
|
511
|
+
} else line = piece;
|
|
512
|
+
}
|
|
513
|
+
if (line !== "") lines.push(indent + line);
|
|
514
|
+
return lines.join("\n");
|
|
515
|
+
}
|
|
516
|
+
|
|
218
517
|
export const help = `
|
|
219
518
|
Usage: doctrina close <id...> [--force]
|
|
220
519
|
|
|
221
520
|
Run the whole closing sequence for a change in one pass, stopping at the
|
|
222
521
|
first failure with the exact command to rerun:
|
|
223
522
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
523
|
+
${SEQUENCE}
|
|
524
|
+
|
|
525
|
+
then a closing skill-suggest listing (advisory).
|
|
227
526
|
|
|
228
527
|
The coverage gate is scoped to the capabilities the change's deltas touch,
|
|
229
528
|
so a deliberately deferred spec elsewhere cannot block an unrelated close;
|
|
230
|
-
a change with no deltas gates on the whole tree.
|
|
231
|
-
|
|
529
|
+
a change with no deltas gates on the whole tree. The runtime gate holds the
|
|
530
|
+
contracts' declared wiring, enums and selectors to the implementation (the
|
|
531
|
+
RT01-RT05 checks \`contract check\` renders): an error blocks, a warning is
|
|
532
|
+
reported and the close continues, and a project with no contracts — or with
|
|
533
|
+
contracts declaring no rows — is reported UNCHECKED rather than passing.
|
|
534
|
+
verify is skipped (with a note) when no .doctrina/verify.json is declared;
|
|
535
|
+
trace, the ADR checkpoint
|
|
232
536
|
(accepted ADRs citing the touched capabilities — amend via \`decision
|
|
233
537
|
supersede\`, not silence), and the closing skill-suggest listing are
|
|
234
538
|
advisory (reports, never blockers). This is a driver over the existing
|
|
235
|
-
commands so the agent makes one call instead of
|
|
539
|
+
commands so the agent makes one call instead of thirteen and the human
|
|
236
540
|
approves once.
|
|
237
541
|
|
|
238
542
|
Multiple ids close in sequence, each independently; the exit code is the
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import {
|
|
2
|
+
import { DEPRECATED, OPERATIONS } from "../lib/commands.js";
|
|
3
3
|
import { c } from "../lib/colors.js";
|
|
4
4
|
|
|
5
5
|
// Shell completions, generated from the same OPERATIONS catalog that feeds
|
|
6
6
|
// `--help` and the docs drift tests — so the completion script can never
|
|
7
7
|
// know a different surface than the CLI ships. Static output by design:
|
|
8
8
|
// paste it into your shell config once; regenerate after upgrading.
|
|
9
|
+
//
|
|
10
|
+
// Deprecated operations are left out (change 0188): they still run, with a
|
|
11
|
+
// notice, for a script that already types them — but tab completion is how
|
|
12
|
+
// a person discovers the surface, and it should offer the replacement only.
|
|
13
|
+
const LIVE = OPERATIONS.filter(([op]) => !DEPRECATED[op]);
|
|
14
|
+
const COMMAND_NAMES = [...new Set(LIVE.map(([op]) => op.split(" ")[0]))];
|
|
9
15
|
|
|
10
16
|
function subcommandMap() {
|
|
11
17
|
const map = new Map();
|
|
12
|
-
for (const [op] of
|
|
18
|
+
for (const [op] of LIVE) {
|
|
13
19
|
const [cmd, sub] = op.split(" ");
|
|
14
20
|
if (!sub) continue;
|
|
15
21
|
if (!map.has(cmd)) map.set(cmd, []);
|