doctrina-cli 0.15.1 → 0.16.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 +1 -1
- package/package.json +1 -1
- package/src/commands/adapter.js +24 -1
- package/src/commands/analyze.js +8 -212
- package/src/commands/change.js +143 -217
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +58 -21
- package/src/commands/close.js +291 -42
- package/src/commands/constitution.js +14 -91
- package/src/commands/context.js +278 -107
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +54 -7
- package/src/commands/doctor.js +292 -129
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +11 -2
- package/src/commands/index-rebuild.js +9 -48
- package/src/commands/init.js +70 -13
- package/src/commands/intake.js +30 -92
- package/src/commands/metrics.js +53 -84
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +24 -91
- package/src/commands/report.js +50 -108
- 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 +16 -25
- package/src/commands/spec.js +26 -5
- package/src/commands/status.js +51 -172
- package/src/commands/templates.js +31 -138
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +234 -263
- package/src/index.js +106 -7
- package/src/lib/actions.js +484 -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/change-ops.js +131 -0
- package/src/lib/commands.js +56 -4
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +392 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +326 -0
- package/src/lib/docs-impact.js +196 -27
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +181 -23
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +2 -19
- package/src/lib/intake-model.js +86 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +191 -0
- package/src/lib/metrics-model.js +159 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +57 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +204 -13
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +209 -0
- package/src/lib/spec-ops.js +50 -5
- package/src/lib/templates-model.js +262 -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 +1299 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +410 -0
- package/src/lib/work-model.js +77 -0
- package/templates/AGENTS.md.template +20 -26
- package/templates/README.md +35 -0
- package/templates/change/proposal.md.template +1 -0
- package/templates/hooks/pre-commit.sample +6 -2
- package/templates/playbooks/bootstrap.md.template +43 -0
- package/templates/playbooks/chore.md.template +19 -0
- package/templates/playbooks/work.md.template +76 -0
package/src/commands/close.js
CHANGED
|
@@ -2,24 +2,30 @@
|
|
|
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 } 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, stepRerun } from "../lib/gates.js";
|
|
15
|
+
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
16
|
import * as analyze from "./analyze.js";
|
|
13
17
|
import * as change from "./change.js";
|
|
14
18
|
import * as verify from "./verify.js";
|
|
15
19
|
import * as coverage from "./coverage.js";
|
|
16
20
|
import * as trace from "./trace.js";
|
|
17
21
|
import * as validate from "./validate.js";
|
|
22
|
+
import { appendLedgerLine, docsGapLine, ledgerPath as ledgerFile } from "../lib/ledger.js";
|
|
23
|
+
import * as review from "./review.js";
|
|
18
24
|
import * as skill from "./skill.js";
|
|
19
25
|
|
|
20
26
|
// 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
|
|
27
|
+
// playbook lists the closing sequence — analyze → apply → runtime → verify →
|
|
28
|
+
// coverage → trace → archive → validate — and the agent runs it step by step, which is
|
|
23
29
|
// exactly where a gate gets skipped. `close` runs the whole sequence in one
|
|
24
30
|
// pass, in-process, stopping at the first failure with the exact command to
|
|
25
31
|
// rerun, so the agent makes one call and the human approves once. It is a
|
|
@@ -61,6 +67,14 @@ export async function run(positional, flags) {
|
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
async function closeOne(projectRoot, id, flags) {
|
|
70
|
+
// A change id that does not resolve is the USAGE class, before any step
|
|
71
|
+
// is sequenced (change 0114) — every sibling that takes a change id
|
|
72
|
+
// answers 2; `close` answered 1 through the analyze step it ran first.
|
|
73
|
+
if (!isDir(path.join(projectRoot, ".doctrina", "changes", id))) {
|
|
74
|
+
console.error(c.red("error:") + ` change "${id}" not found at .doctrina/changes/${id}`);
|
|
75
|
+
console.error(c.gray("hint: ") + "open changes: `doctrina next`");
|
|
76
|
+
return EXIT.USAGE;
|
|
77
|
+
}
|
|
64
78
|
const force = flagBool(flags, "force", false);
|
|
65
79
|
const archiveFlags = force ? new Map([["force", true]]) : new Map();
|
|
66
80
|
// Set by the docs gate when it fails, so a --force close can record the
|
|
@@ -82,47 +96,166 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
82
96
|
coverageRerun = `doctrina coverage --strict --only ${touched.join(",")}`;
|
|
83
97
|
}
|
|
84
98
|
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
99
|
+
// How each declared step actually runs, keyed by the step id in
|
|
100
|
+
// SEQUENCES.close. The DECLARATION owns which steps exist, in what order,
|
|
101
|
+
// and how hard each bites (lib/gates.js, audit finding F1); this table owns
|
|
102
|
+
// only how to execute one in-process. A step declared with no runner here
|
|
103
|
+
// falls back to spawning its declared `argv`, so a gate added to the
|
|
104
|
+
// sequence appears in `close` — and in `doctor` and the CI action —
|
|
105
|
+
// without a second edit; a drift test pins the two together.
|
|
106
|
+
//
|
|
107
|
+
// Each runner returns an exit code and may replace the step's label or
|
|
108
|
+
// rerun line with a context-aware one (the coverage scope, the --force
|
|
109
|
+
// archive), which is rendering, not sequencing.
|
|
88
110
|
const verifyConfigured = isFile(path.join(projectRoot, ".doctrina", "verify.json"));
|
|
89
|
-
const
|
|
90
|
-
|
|
111
|
+
const runners = {
|
|
112
|
+
analyze: { run: () => analyze.run([id], new Map()) },
|
|
113
|
+
|
|
91
114
|
// ADR checkpoint (operator review §4.6): the playbook's "record an ADR"
|
|
92
115
|
// step was skippable in silence. Advisory — it names the accepted ADRs
|
|
93
116
|
// whose text cites the touched capabilities and the amend commands, but
|
|
94
117
|
// an ADR merely mentioning a capability is normal, so it never blocks.
|
|
95
|
-
{
|
|
96
|
-
label: "ADR checkpoint (advisory)",
|
|
97
|
-
rerun: "doctrina decision list",
|
|
118
|
+
"adr-checkpoint": {
|
|
98
119
|
run: async () => {
|
|
99
120
|
if (printAdrCheckpoint(projectRoot, touched, { c }) === 0) {
|
|
100
|
-
|
|
121
|
+
// "No ADR cites these capabilities" is conformance only when there
|
|
122
|
+
// ARE accepted ADRs to cite them. With none on disk the sentence is
|
|
123
|
+
// vacuously true, and printing it as `ok` reads as a decision
|
|
124
|
+
// checked rather than a checkbox with nothing behind it (0089).
|
|
125
|
+
const accepted = acceptedDecisionCount(projectRoot);
|
|
126
|
+
if (accepted === 0) {
|
|
127
|
+
console.log(c.gray("· no accepted ADR in the tree — nothing to check the change against"));
|
|
128
|
+
} else {
|
|
129
|
+
console.log(c.green("ok") + ` no accepted ADR cites the touched capabilities (${accepted} checked)`);
|
|
130
|
+
}
|
|
101
131
|
}
|
|
102
132
|
return 0;
|
|
103
133
|
},
|
|
104
134
|
},
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
135
|
+
|
|
136
|
+
// The review runs against the working tree, which for a close IS the
|
|
137
|
+
// change's diff: the work is done and not yet archived. Its exit code is
|
|
138
|
+
// discarded on purpose — the step is declared advisory, and an advisory
|
|
139
|
+
// step that could still move the close's result would be advisory in name
|
|
140
|
+
// only. A crash is caught by the loop and reported the same way.
|
|
141
|
+
review: {
|
|
142
|
+
run: async () => {
|
|
143
|
+
await review.run([], new Map());
|
|
144
|
+
return 0;
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
apply: { run: () => change.run(["apply", id], new Map()) },
|
|
149
|
+
|
|
150
|
+
// The RUNTIME gate (audit finding F2). RT01–RT05 live in lib/runtime.js
|
|
151
|
+
// and no default driver ran them: `close` did not, `validate` only under
|
|
152
|
+
// --runtime, and the published action not at all — so the one class of
|
|
153
|
+
// break every structural gate is blind to (the declaration that no
|
|
154
|
+
// longer matches the running system) reached production green. It sits
|
|
155
|
+
// after `apply`, because the deltas the apply just merged are what may
|
|
156
|
+
// have moved the surface the contract describes. A driver over
|
|
157
|
+
// lib/runtime.js: the same findings `contract check`, `triage` and
|
|
158
|
+
// `doctor` render, so the five can never disagree.
|
|
159
|
+
//
|
|
160
|
+
// Severity decides the level: an `error` blocks the close, a `warn` is
|
|
161
|
+
// reported and the close continues. A project with no contracts — or
|
|
162
|
+
// with contracts that declare no Wiring/Selectors rows — is not passing,
|
|
163
|
+
// it is UNCHECKED, and saying so is what stops silence from reading as
|
|
164
|
+
// proof.
|
|
165
|
+
runtime: {
|
|
166
|
+
run: async () => {
|
|
167
|
+
const { findings, contracts, declared } = collectRuntimeFindings(projectRoot);
|
|
168
|
+
if (contracts === 0) {
|
|
169
|
+
console.log(c.gray("· no contracts — nothing declares a runtime surface"));
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
for (const f of findings) {
|
|
173
|
+
const mark = f.level === "error" ? c.red(" ✗ ") : c.yellow(" ! ");
|
|
174
|
+
console.log(mark + `${f.contract}: ${f.message}` + c.gray(` [${f.code}]`));
|
|
175
|
+
console.log(` ${c.gray(`fix: ${f.remedy}`)}`);
|
|
176
|
+
}
|
|
177
|
+
const errs = findings.filter((f) => f.level === "error").length;
|
|
178
|
+
if (errs > 0) {
|
|
179
|
+
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`);
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
if (declared === 0) {
|
|
183
|
+
console.log(c.yellow("warn: ") +
|
|
184
|
+
`${contracts} contract${contracts === 1 ? "" : "s"}, 0 Wiring/Selectors rows — the runtime surface is unchecked`);
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
const warns = findings.length;
|
|
188
|
+
console.log(c.green("ok") + ` ${declared} declared row${declared === 1 ? " holds" : "s hold"}` +
|
|
189
|
+
(warns > 0 ? c.gray(`; ${warns} advisory finding${warns === 1 ? "" : "s"} above`) : ""));
|
|
190
|
+
return 0;
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
// The Implementation header, PROPOSED rather than remembered (audit
|
|
195
|
+
// finding F10). The `work` playbook asked the agent twice to advance a
|
|
196
|
+
// field whose correct value coverage had already computed one file over.
|
|
197
|
+
// Advisory and non-mutating by design: the close prints the `set-header`
|
|
198
|
+
// op for the capabilities this change touched, and a human — or
|
|
199
|
+
// `spec set --implementation auto` — applies it. A gate that rewrote the
|
|
200
|
+
// claim it checks would be marking its own homework.
|
|
201
|
+
implementation: {
|
|
202
|
+
run: async () => {
|
|
203
|
+
const scope = touched.length > 0 ? new Set(touched) : null;
|
|
204
|
+
const derived = derivedImplementations(projectRoot, { only: scope });
|
|
205
|
+
const proposals = [];
|
|
206
|
+
for (const [cap, row] of derived) {
|
|
207
|
+
const specPath = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
|
|
208
|
+
if (!isFile(specPath)) continue;
|
|
209
|
+
const mismatch = implementationMismatch(specHeader(read(specPath), "Implementation"), row.derived);
|
|
210
|
+
if (mismatch) proposals.push({ cap, row, mismatch });
|
|
211
|
+
}
|
|
212
|
+
if (derived.size === 0) {
|
|
213
|
+
// "Every touched spec matches" over ZERO specs is vacuously true.
|
|
214
|
+
// A chore in a project with no capability spec reached this and was
|
|
215
|
+
// told its implementation headers were in order (change 0089).
|
|
216
|
+
console.log(c.gray("· no spec to check — the change touches no capability"));
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
if (proposals.length === 0) {
|
|
220
|
+
console.log(c.green("ok") + ` every touched spec's Implementation header matches its coverage (${derived.size} checked)`);
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
for (const { cap, row, mismatch } of proposals) {
|
|
224
|
+
console.log(c.yellow(" ! ") + `${cap}: Implementation is "${mismatch.written}" but ` +
|
|
225
|
+
`${row.covered}/${row.total} criteria have resolving proof`);
|
|
226
|
+
console.log(` ${c.gray("delta op: ")}${c.cyan(mismatch.op)}`);
|
|
227
|
+
console.log(` ${c.gray("or: ")}${c.cyan(`doctrina spec set ${cap} --implementation auto`)}`);
|
|
228
|
+
}
|
|
229
|
+
return 0;
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
// verify is conditional: skipped with a loud note when no verify.json is
|
|
234
|
+
// declared, because a project may not have wired the real gate yet.
|
|
235
|
+
verify: verifyConfigured
|
|
236
|
+
? { run: () => verify.run([], new Map()) }
|
|
237
|
+
: { skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
|
|
238
|
+
|
|
239
|
+
coverage: {
|
|
110
240
|
label: touched.length > 0 ? `coverage (scoped: ${touched.join(", ")})` : "coverage",
|
|
111
241
|
rerun: coverageRerun,
|
|
112
|
-
run: () =>
|
|
242
|
+
run: async () => {
|
|
243
|
+
const code = await coverage.run([], coverageFlags);
|
|
244
|
+
reportDependentCoverage(projectRoot, touched);
|
|
245
|
+
return code;
|
|
246
|
+
},
|
|
113
247
|
},
|
|
248
|
+
|
|
114
249
|
// trace is advisory (provenance is a warning, not a hard gate): report it,
|
|
115
250
|
// never let it block the close.
|
|
116
|
-
|
|
251
|
+
trace: { run: async () => { await trace.run([], new Map()); return 0; } },
|
|
252
|
+
|
|
117
253
|
// Docs ship inside the change (D2): a change that alters a documented
|
|
118
254
|
// surface — a command, a flag, an exit code — closes only with the
|
|
119
255
|
// documentation that describes it. A blocking gate, because a docs
|
|
120
256
|
// phase scheduled after the work never happens; --force is the same
|
|
121
257
|
// 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",
|
|
258
|
+
docs: {
|
|
126
259
|
run: async () => {
|
|
127
260
|
const r = checkDocsImpact(projectRoot, path.join(projectRoot, ".doctrina", "changes", id));
|
|
128
261
|
if (r.ok) {
|
|
@@ -131,18 +264,57 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
131
264
|
}
|
|
132
265
|
console.error(c.red("error:") + ` this change ${r.reason}:`);
|
|
133
266
|
for (const s of r.signals) console.error(` - ${s}`);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
267
|
+
// The remedy comes out of the project being checked, never out of
|
|
268
|
+
// Doctrina's own repository (change 0058): an adopting project with
|
|
269
|
+
// no `docs/` was being told to write English AND Portuguese and to
|
|
270
|
+
// read a skill it does not have.
|
|
271
|
+
console.error(c.gray("hint: ") + docsRemedy(projectRoot) +
|
|
272
|
+
", or pass --force to close anyway (records the gap)");
|
|
137
273
|
docsGap = r;
|
|
138
274
|
return 1;
|
|
139
275
|
},
|
|
140
276
|
},
|
|
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
277
|
|
|
145
|
-
|
|
278
|
+
archive: {
|
|
279
|
+
rerun: `doctrina change archive ${id}${force ? " --force" : ""}`,
|
|
280
|
+
run: () => change.run(["archive", id], archiveFlags),
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
validate: { run: () => validate.run([], new Map()) },
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const steps = sequence("close").map((step) => {
|
|
287
|
+
const runner = runners[step.id] ?? {};
|
|
288
|
+
return {
|
|
289
|
+
id: step.id,
|
|
290
|
+
label: runner.label ?? step.label,
|
|
291
|
+
rerun: runner.rerun ?? stepRerun(step, id),
|
|
292
|
+
forceable: step.level === "forceable",
|
|
293
|
+
advisory: step.level === "advisory",
|
|
294
|
+
skip: runner.skip,
|
|
295
|
+
// A step declared with no in-process runner is a gap in THIS file, and
|
|
296
|
+
// it is reported as one — the same answer `doctor` gives (change 0060).
|
|
297
|
+
// The close used to shell out to its own binary here instead, which was
|
|
298
|
+
// the second integration style change 0045 removed from `doctor`; every
|
|
299
|
+
// declared step has a runner and a test holds it that way, so the
|
|
300
|
+
// fallback was a second answer to a question nobody could reach.
|
|
301
|
+
run: runner.run ?? (() => missingRunner(step)),
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// The banner is the declaration read aloud, so it cannot fall behind the
|
|
306
|
+
// sequence it announces the way a hand-maintained string did.
|
|
307
|
+
console.log(c.bold(`Closing change ${id}`) +
|
|
308
|
+
c.gray(` — ${sequence("close").map((s) => s.label.replace(" (advisory)", "")).join(" → ")}`));
|
|
309
|
+
|
|
310
|
+
// What the closing line is allowed to claim. A step that was SKIPPED was
|
|
311
|
+
// not performed, and the conclusion used to say "verified, archived, and
|
|
312
|
+
// validated" as a fixed string — including on a close whose own step 7 had
|
|
313
|
+
// just printed `skip no .doctrina/verify.json`. The final line is the one
|
|
314
|
+
// sentence a human reads before approving; a word it did not earn is the
|
|
315
|
+
// most expensive claim in the tree (change 0089).
|
|
316
|
+
const ran = new Set();
|
|
317
|
+
const skippedSteps = [];
|
|
146
318
|
|
|
147
319
|
for (let i = 0; i < steps.length; i++) {
|
|
148
320
|
const step = steps[i];
|
|
@@ -150,6 +322,7 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
150
322
|
console.log(c.gray(`──── ${i + 1}/${steps.length} ${step.label}`));
|
|
151
323
|
if (step.skip) {
|
|
152
324
|
console.log(c.yellow("skip ") + step.skip);
|
|
325
|
+
skippedSteps.push(step);
|
|
153
326
|
continue;
|
|
154
327
|
}
|
|
155
328
|
let code;
|
|
@@ -160,6 +333,13 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
160
333
|
console.error(c.red("error:") + ` ${err.message}`);
|
|
161
334
|
}
|
|
162
335
|
if (code !== 0) {
|
|
336
|
+
// The declared level decides, not the runner: an advisory step reports
|
|
337
|
+
// and the close carries on, which is what "advisory" means in
|
|
338
|
+
// lib/gates.js and what the surfaces must agree on.
|
|
339
|
+
if (step.advisory) {
|
|
340
|
+
console.log(c.yellow("warn:") + ` "${step.label}" reported findings (advisory — the close continues)`);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
163
343
|
// A forceable gate under --force warns and continues, matching how
|
|
164
344
|
// `change archive --force` handles incomplete verification: the gap
|
|
165
345
|
// is recorded, not hidden.
|
|
@@ -174,14 +354,17 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
174
354
|
console.log(` ${c.cyan(`doctrina close ${id}`)}`);
|
|
175
355
|
return 1;
|
|
176
356
|
}
|
|
357
|
+
ran.add(step.id);
|
|
177
358
|
}
|
|
178
359
|
|
|
179
360
|
// Record a forced docs gap in the ledger, so history shows the change
|
|
180
361
|
// shipped without its documentation rather than showing nothing.
|
|
181
362
|
if (docsGap) {
|
|
182
|
-
const ledgerPath =
|
|
363
|
+
const ledgerPath = ledgerFile(projectRoot);
|
|
183
364
|
if (isFile(ledgerPath)) {
|
|
184
|
-
|
|
365
|
+
// Written in the ledger's own entry grammar (change 0046): a waived
|
|
366
|
+
// gate no reader can find is the same as an unrecorded one.
|
|
367
|
+
appendLedgerLine(projectRoot, docsGapLine(id, docsGap.signals));
|
|
185
368
|
console.log(c.yellow("ledger") + " recorded the docs gap");
|
|
186
369
|
}
|
|
187
370
|
}
|
|
@@ -197,13 +380,74 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
197
380
|
} catch { /* advisory only */ }
|
|
198
381
|
|
|
199
382
|
console.log("");
|
|
200
|
-
console.log(c.green(`✓ change ${id} closed`) + c.gray(
|
|
383
|
+
console.log(c.green(`✓ change ${id} closed`) + c.gray(closingClaim(ran, skippedSteps)));
|
|
201
384
|
console.log(c.gray("Next: ") + c.cyan("doctrina next"));
|
|
202
385
|
return 0;
|
|
203
386
|
}
|
|
204
387
|
|
|
388
|
+
// A step the sequence declares and this file does not implement. The gate map
|
|
389
|
+
// is the single source of truth for what a close runs (ADR 0017), so a step
|
|
390
|
+
// with no runner here is a defect in this file — not a reason to start a
|
|
391
|
+
// second process and not a reason to pass silently. `doctor` reaches the same
|
|
392
|
+
// conclusion for its own reporters; the two drivers now give one answer.
|
|
393
|
+
// The closing sentence, built from the steps that actually ran.
|
|
394
|
+
//
|
|
395
|
+
// Each word names one step: `verify` -> verified, `archive` -> archived,
|
|
396
|
+
// `validate` -> validated. A step that was skipped loses its word and is
|
|
397
|
+
// named instead, so the line can never claim more than the run performed.
|
|
398
|
+
function closingClaim(ran, skippedSteps) {
|
|
399
|
+
const words = [
|
|
400
|
+
["verify", "verified"],
|
|
401
|
+
["archive", "archived"],
|
|
402
|
+
["validate", "validated"],
|
|
403
|
+
].filter(([stepId]) => ran.has(stepId)).map(([, word]) => word);
|
|
404
|
+
|
|
405
|
+
const claimed = words.length === 0
|
|
406
|
+
? ""
|
|
407
|
+
: words.length === 1
|
|
408
|
+
? ` — ${words[0]}.`
|
|
409
|
+
: ` — ${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}.`;
|
|
410
|
+
|
|
411
|
+
const skipped = skippedSteps.map((s) => s.label.replace(" (advisory)", ""));
|
|
412
|
+
if (skipped.length === 0) return claimed || " — no gate ran.";
|
|
413
|
+
return `${claimed || " —"} ${skipped.length === 1 ? "Skipped" : "Skipped"}: ${skipped.join(", ")}.`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function missingRunner(step) {
|
|
417
|
+
console.error(c.red("error:") +
|
|
418
|
+
` "${step.label}" is declared in the close sequence but has no runner here`);
|
|
419
|
+
if (step.argv) {
|
|
420
|
+
console.error(c.gray("hint: ") + `run it directly: doctrina ${step.argv.join(" ")}`);
|
|
421
|
+
}
|
|
422
|
+
return 1;
|
|
423
|
+
}
|
|
424
|
+
|
|
205
425
|
// The capabilities this change's deltas target — the honest scope for its
|
|
206
426
|
// coverage gate. Read from the change folder's specs/**/delta.md files.
|
|
427
|
+
// The capabilities that DECLARE a dependency on what this change touched,
|
|
428
|
+
// with their coverage — advisory, never part of the verdict (change 0046).
|
|
429
|
+
//
|
|
430
|
+
// The scope exists because one deliberately deferred spec elsewhere in the
|
|
431
|
+
// tree must not block a change that never went near it, and widening the gate
|
|
432
|
+
// to dependents would give that problem straight back. What a closing agent
|
|
433
|
+
// actually needs is the pointer: this change moved ground something else
|
|
434
|
+
// stands on, and here is how well that something is proven today.
|
|
435
|
+
function reportDependentCoverage(projectRoot, touched) {
|
|
436
|
+
if (touched.length === 0) return;
|
|
437
|
+
const dependents = dependentsOf(projectRoot, touched);
|
|
438
|
+
if (dependents.length === 0) return;
|
|
439
|
+
const byCap = new Map(summarize(projectRoot).perCap.map((r) => [r.cap, r]));
|
|
440
|
+
console.log(c.gray(" dependents of the touched capabilities (advisory — not part of this gate):"));
|
|
441
|
+
for (const dep of dependents) {
|
|
442
|
+
const row = byCap.get(dep.capability);
|
|
443
|
+
const proof = row && row.total > 0
|
|
444
|
+
? `${row.covered}/${row.total} criteria proven` + (row.dangling ? `, ${row.dangling} dangling` : "")
|
|
445
|
+
: "no acceptance criteria";
|
|
446
|
+
console.log(` ${c.cyan(dep.capability)} depends on ${dep.dependsOn.join(", ")} — ${proof}` +
|
|
447
|
+
c.gray(` (doctrina why ${dep.capability})`));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
207
451
|
function touchedCapabilities(projectRoot, id) {
|
|
208
452
|
const specsDir = path.join(projectRoot, ".doctrina", "changes", id, "specs");
|
|
209
453
|
const caps = new Set();
|
|
@@ -221,14 +465,19 @@ Usage: doctrina close <id...> [--force]
|
|
|
221
465
|
Run the whole closing sequence for a change in one pass, stopping at the
|
|
222
466
|
first failure with the exact command to rerun:
|
|
223
467
|
|
|
224
|
-
analyze → ADR checkpoint (advisory) → change apply → verify →
|
|
225
|
-
--strict (scoped to the change's touched capabilities) → trace →
|
|
226
|
-
archive → validate → skill suggest (advisory)
|
|
468
|
+
analyze → ADR checkpoint (advisory) → change apply → runtime → verify →
|
|
469
|
+
coverage --strict (scoped to the change's touched capabilities) → trace →
|
|
470
|
+
change archive → validate → skill suggest (advisory)
|
|
227
471
|
|
|
228
472
|
The coverage gate is scoped to the capabilities the change's deltas touch,
|
|
229
473
|
so a deliberately deferred spec elsewhere cannot block an unrelated close;
|
|
230
|
-
a change with no deltas gates on the whole tree.
|
|
231
|
-
|
|
474
|
+
a change with no deltas gates on the whole tree. The runtime gate holds the
|
|
475
|
+
contracts' declared wiring, enums and selectors to the implementation (the
|
|
476
|
+
RT01-RT05 checks \`contract check\` renders): an error blocks, a warning is
|
|
477
|
+
reported and the close continues, and a project with no contracts — or with
|
|
478
|
+
contracts declaring no rows — is reported UNCHECKED rather than passing.
|
|
479
|
+
verify is skipped (with a note) when no .doctrina/verify.json is declared;
|
|
480
|
+
trace, the ADR checkpoint
|
|
232
481
|
(accepted ADRs citing the touched capabilities — amend via \`decision
|
|
233
482
|
supersede\`, not silence), and the closing skill-suggest listing are
|
|
234
483
|
advisory (reports, never blockers). This is a driver over the existing
|
|
@@ -6,6 +6,12 @@ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
|
|
|
6
6
|
import { listHeader } from "../lib/scan.js";
|
|
7
7
|
import { c } from "../lib/colors.js";
|
|
8
8
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
9
|
+
import { acceptedDecisions, productSection } from "../lib/constitution-model.js";
|
|
10
|
+
import { collectSnapshot } from "../lib/snapshot.js";
|
|
11
|
+
import { renderView } from "../lib/views.js";
|
|
12
|
+
|
|
13
|
+
// Read by the project snapshot as well; see lib/constitution-model.js.
|
|
14
|
+
export { acceptedDecisions, productSection } from "../lib/constitution-model.js";
|
|
9
15
|
|
|
10
16
|
// The project's standing rules in one read — Spec Kit parity for its
|
|
11
17
|
// `constitution.md`, but ASSEMBLED, not a new home for facts. Doctrina's
|
|
@@ -28,42 +34,11 @@ export async function run(_positional, _flags) {
|
|
|
28
34
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
29
35
|
throw notADoctrinaProject();
|
|
30
36
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
// Principles — the accepted ADRs, by number. Every accepted decision is a
|
|
39
|
-
// binding rule; superseded/withdrawn/proposed ADRs are not yet (or no longer)
|
|
40
|
-
// in force and stay out.
|
|
41
|
-
console.log("");
|
|
42
|
-
console.log(c.bold(" Principles") + c.gray(" (immutable — supersede an ADR to change one)"));
|
|
43
|
-
const adrs = acceptedDecisions(projectRoot);
|
|
44
|
-
if (adrs.length === 0) {
|
|
45
|
-
console.log(` ${c.gray("no accepted ADRs yet — record decisions with `doctrina decision new`")}`);
|
|
46
|
-
} else {
|
|
47
|
-
for (const a of adrs) console.log(` ${c.cyan("ADR " + a.id)} ${a.title}`);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Non-goals — the explicit "what this project will not be", from product.md.
|
|
51
|
-
console.log("");
|
|
52
|
-
console.log(c.bold(" Non-goals") + c.gray(" (.doctrina/product.md)"));
|
|
53
|
-
const nonGoals = productSection(projectRoot, "Non-goals");
|
|
54
|
-
if (nonGoals.length === 0) {
|
|
55
|
-
console.log(` ${c.gray("none declared — add a `## Non-goals` section to product.md")}`);
|
|
56
|
-
} else {
|
|
57
|
-
for (const g of nonGoals) console.log(` ${c.gray("•")} ${g}`);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
console.log("");
|
|
61
|
-
console.log(
|
|
62
|
-
c.gray(
|
|
63
|
-
` ${adrs.length} accepted decision${adrs.length === 1 ? "" : "s"} · ` +
|
|
64
|
-
`${nonGoals.length} non-goal${nonGoals.length === 1 ? "" : "s"} · read-only`,
|
|
65
|
-
),
|
|
66
|
-
);
|
|
37
|
+
// Deprecated (change 0049): the standing rules are a VIEW of the shared
|
|
38
|
+
// collection, and `prime --rules` renders it. This command prints the same
|
|
39
|
+
// lines — not a similar set, the same ones — so the deprecation is a
|
|
40
|
+
// rename with a warning rather than a loss.
|
|
41
|
+
for (const line of renderView("rules", collectSnapshot(projectRoot))) console.log(line);
|
|
67
42
|
return 0;
|
|
68
43
|
}
|
|
69
44
|
|
|
@@ -76,61 +51,6 @@ function projectName(projectRoot) {
|
|
|
76
51
|
}
|
|
77
52
|
}
|
|
78
53
|
|
|
79
|
-
// Accepted ADRs (NNNN-slug.md with Status: accepted), oldest first, with the
|
|
80
|
-
// title read from the `# ADR NNNN — <title>` heading. Exported for `prime`,
|
|
81
|
-
// which digests the same standing rules into the session primer.
|
|
82
|
-
export function acceptedDecisions(projectRoot) {
|
|
83
|
-
const dir = path.join(projectRoot, ".doctrina", "decisions");
|
|
84
|
-
const out = [];
|
|
85
|
-
if (!isDir(dir)) return out;
|
|
86
|
-
for (const f of readdirSync(dir).sort()) {
|
|
87
|
-
const m = f.match(/^(\d{4})-.*\.md$/);
|
|
88
|
-
if (!m) continue;
|
|
89
|
-
const text = read(path.join(dir, f));
|
|
90
|
-
if ((listHeader(text, "Status") ?? "").toLowerCase() !== "accepted") continue;
|
|
91
|
-
const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
|
|
92
|
-
out.push({ id: m[1], title: titleMatch ? titleMatch[1].trim() : f });
|
|
93
|
-
}
|
|
94
|
-
return out;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Bullets under a named `## <section>` of product.md, each accumulated across
|
|
98
|
-
// its continuation lines so a wrapped bullet reads as one rule. Exported for
|
|
99
|
-
// `prime`.
|
|
100
|
-
export function productSection(projectRoot, name) {
|
|
101
|
-
const p = path.join(projectRoot, ".doctrina", "product.md");
|
|
102
|
-
if (!isFile(p)) return [];
|
|
103
|
-
const lines = read(p).split(/\r?\n/);
|
|
104
|
-
let inSection = false;
|
|
105
|
-
const out = [];
|
|
106
|
-
let cur = null;
|
|
107
|
-
const flush = () => {
|
|
108
|
-
if (cur) out.push(cur.replace(/\s+/g, " ").trim());
|
|
109
|
-
cur = null;
|
|
110
|
-
};
|
|
111
|
-
for (const line of lines) {
|
|
112
|
-
if (/^##\s+/.test(line)) {
|
|
113
|
-
if (inSection) {
|
|
114
|
-
flush();
|
|
115
|
-
break;
|
|
116
|
-
}
|
|
117
|
-
if (new RegExp(`^##\\s+${name}\\b`, "i").test(line)) inSection = true;
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
if (!inSection) continue;
|
|
121
|
-
const m = line.match(/^\s*[-*]\s+(.+)$/);
|
|
122
|
-
if (m) {
|
|
123
|
-
flush();
|
|
124
|
-
cur = m[1];
|
|
125
|
-
} else if (cur && line.trim() !== "") {
|
|
126
|
-
cur += " " + line.trim();
|
|
127
|
-
} else if (line.trim() === "") {
|
|
128
|
-
flush();
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
flush();
|
|
132
|
-
return out;
|
|
133
|
-
}
|
|
134
54
|
|
|
135
55
|
export const help = `
|
|
136
56
|
Usage: doctrina constitution
|
|
@@ -142,4 +62,7 @@ already own; it never writes.
|
|
|
142
62
|
|
|
143
63
|
This is the Spec Kit \`constitution.md\` analogue: a single place to see the
|
|
144
64
|
non-negotiables. To change one, supersede the ADR or edit product.md.
|
|
65
|
+
|
|
66
|
+
DEPRECATED: use \`doctrina prime --rules\`, which prints exactly these lines.
|
|
67
|
+
This name keeps working and will be removed in a future minor.
|
|
145
68
|
`;
|