doctrina-cli 0.15.0 → 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 -199
- 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 +7 -75
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +121 -160
- 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 +109 -6
- 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 +210 -24
- 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 +238 -11
- 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/contract.md.template +7 -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/index.js
CHANGED
|
@@ -5,11 +5,11 @@ import { parseArgs } from "./lib/args.js";
|
|
|
5
5
|
import { c } from "./lib/colors.js";
|
|
6
6
|
import { suggest } from "./lib/suggest.js";
|
|
7
7
|
import { cliVersion } from "./lib/version.js";
|
|
8
|
-
import { surfaceHelp, OPERATIONS } from "./lib/commands.js";
|
|
8
|
+
import { surfaceHelp, OPERATIONS, deprecationFor } from "./lib/commands.js";
|
|
9
9
|
import { GLOBAL_FLAGS } from "./lib/flag-catalog.js";
|
|
10
10
|
import { EXIT, exitCodeHelp } from "./lib/exit-codes.js";
|
|
11
|
-
import { recordUsage } from "./lib/usage.js";
|
|
12
|
-
import { wantsJson, emitJson, captureOutput, stripAnsi } from "./lib/json-out.js";
|
|
11
|
+
import { recordUsage, operationOf } from "./lib/usage.js";
|
|
12
|
+
import { wantsJson, emitJson, captureOutput, stripAnsi, setDeprecation, deferJson, flushJson } from "./lib/json-out.js";
|
|
13
13
|
|
|
14
14
|
import * as init from "./commands/init.js";
|
|
15
15
|
import * as spec from "./commands/spec.js";
|
|
@@ -48,6 +48,7 @@ import * as intent from "./commands/intent.js";
|
|
|
48
48
|
import * as upgrade from "./commands/upgrade.js";
|
|
49
49
|
import * as adapter from "./commands/adapter.js";
|
|
50
50
|
import * as triage from "./commands/triage.js";
|
|
51
|
+
import * as ci from "./commands/ci.js";
|
|
51
52
|
|
|
52
53
|
const COMMANDS = {
|
|
53
54
|
init, spec, change, decision, validate, hooks, analyze, clarify,
|
|
@@ -55,7 +56,7 @@ const COMMANDS = {
|
|
|
55
56
|
intake, work, coverage, verify, contract, trace,
|
|
56
57
|
status, close, review, watch, why, constitution,
|
|
57
58
|
prime, handoff, show, doctor, report, completion,
|
|
58
|
-
intent, upgrade, adapter, triage,
|
|
59
|
+
intent, upgrade, adapter, triage, ci,
|
|
59
60
|
};
|
|
60
61
|
|
|
61
62
|
const TOP_HELP = `
|
|
@@ -127,18 +128,98 @@ async function main(argv) {
|
|
|
127
128
|
return 0;
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
// A flag the command does not declare is REFUSED, never ignored.
|
|
132
|
+
//
|
|
133
|
+
// Declaring the flags (C3) fixed the parser swallowing a positional; it
|
|
134
|
+
// did not make anything CHECK the declaration, so an unrecognised flag was
|
|
135
|
+
// simply dropped. On the same tree, `doctrina coverage --strict` exited 1
|
|
136
|
+
// and `doctrina coverage --stricts` exited 0: the gate the operator asked
|
|
137
|
+
// for never ran, and nothing said so. That is the most expensive failure a
|
|
138
|
+
// gate can have, because it is indistinguishable from success — a CI job
|
|
139
|
+
// with a typo in `--strict` stays green forever over a tree the gate would
|
|
140
|
+
// reject (change 0082).
|
|
141
|
+
//
|
|
142
|
+
// Usage error, not gate failure: exit 2 (ADR 0018). Checked after --help,
|
|
143
|
+
// so `doctrina <cmd> --typo --help` still explains the command instead of
|
|
144
|
+
// refusing to.
|
|
145
|
+
if (spec) {
|
|
146
|
+
const declared = new Set([
|
|
147
|
+
...GLOBAL_FLAGS.boolean, ...GLOBAL_FLAGS.string,
|
|
148
|
+
...(spec.boolean ?? []), ...(spec.string ?? []),
|
|
149
|
+
]);
|
|
150
|
+
const undeclared = [...flags.keys()].filter((f) => !declared.has(f));
|
|
151
|
+
if (undeclared.length > 0) {
|
|
152
|
+
const lines = [];
|
|
153
|
+
for (const f of undeclared) {
|
|
154
|
+
lines.push(`error: unknown flag "--${f}" for \`doctrina ${commandName}\``);
|
|
155
|
+
console.error(c.red("error:") + ` unknown flag "--${f}" for \`doctrina ${commandName}\``);
|
|
156
|
+
const guess = suggest(f, [...declared]);
|
|
157
|
+
if (guess) {
|
|
158
|
+
lines.push(`hint: did you mean \`--${guess}\`?`);
|
|
159
|
+
console.error(c.gray("hint: ") + `did you mean \`--${guess}\`?`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
lines.push(`hint: \`doctrina ${commandName} --help\` lists the flags it accepts`);
|
|
163
|
+
console.error(c.gray("hint: ") + `\`doctrina ${commandName} --help\` lists the flags it accepts`);
|
|
164
|
+
// A consumer that asked for JSON gets JSON, including when the answer
|
|
165
|
+
// is "I refused" (third audit, finding 7). Change 0086 made the
|
|
166
|
+
// envelope tell the truth about the exit code; the flag check runs
|
|
167
|
+
// BEFORE the envelope exists, so a rejected invocation returned exit 2
|
|
168
|
+
// with an empty stdout and the consumer got a parse error instead of
|
|
169
|
+
// `{ok: false, exit_code: 2}`.
|
|
170
|
+
if (wantsJson(flags)) {
|
|
171
|
+
emitJson(operationName(positional), { stderr: lines },
|
|
172
|
+
{ ok: false, exitCode: EXIT.USAGE, args: positional.slice(1) });
|
|
173
|
+
}
|
|
174
|
+
return EXIT.USAGE;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A deprecated name keeps working and says so, once, before it runs
|
|
179
|
+
// (change 0049). On stderr, so a piped stdout stays exactly what it was —
|
|
180
|
+
// a warning that corrupts the output it warns about is a breaking change
|
|
181
|
+
// wearing a deprecation's clothes.
|
|
182
|
+
const deprecated = deprecationFor(positional);
|
|
183
|
+
if (deprecated) {
|
|
184
|
+
console.error(c.yellow("deprecated:") + ` this command is superseded — use ${c.cyan(deprecated.use)}`);
|
|
185
|
+
console.error(c.gray(` ${deprecated.why}; the old name still works and will be removed in a later minor.`));
|
|
186
|
+
// And into the envelope, for the consumer that never sees a terminal
|
|
187
|
+
// (change 0061). The prose line above stays for the one that does.
|
|
188
|
+
setDeprecation({ use: deprecated.use, since: deprecated.since, why: deprecated.why });
|
|
189
|
+
}
|
|
190
|
+
|
|
130
191
|
try {
|
|
131
192
|
// --json on a command that builds no payload of its own still answers in
|
|
132
193
|
// JSON: its output is captured into a versioned envelope beside `ok` and
|
|
133
194
|
// `exit_code`. Branch on those; the lines are for completeness (M7).
|
|
134
|
-
|
|
195
|
+
// A module with several subcommands is native for some and not others —
|
|
196
|
+
// `contract check` builds a real payload, `contract new` has nothing but
|
|
197
|
+
// its prose — so `jsonNative` may be a predicate over the subcommand's
|
|
198
|
+
// own arguments (change 0068).
|
|
199
|
+
const jsonNative = typeof command.jsonNative === "function"
|
|
200
|
+
? command.jsonNative(positional.slice(1))
|
|
201
|
+
: command.jsonNative === true;
|
|
202
|
+
if (wantsJson(flags) && !jsonNative) {
|
|
135
203
|
const { code, stdout, stderr } = await captureOutput(
|
|
136
204
|
() => command.run(positional.slice(1), flags),
|
|
137
205
|
);
|
|
138
|
-
emitJson(
|
|
206
|
+
emitJson(operationName(positional), {
|
|
139
207
|
stdout: stdout.map(stripAnsi),
|
|
140
208
|
stderr: stderr.map(stripAnsi),
|
|
141
|
-
}, { ok: code === EXIT.OK, exitCode: code });
|
|
209
|
+
}, { ok: code === EXIT.OK, exitCode: code, args: operationArgs(positional) });
|
|
210
|
+
return code;
|
|
211
|
+
}
|
|
212
|
+
// The native path: hold the payload the command builds, run it, then
|
|
213
|
+
// write the envelope with the code it returned — so `ok` and `exit_code`
|
|
214
|
+
// say what the process says (change 0086).
|
|
215
|
+
if (wantsJson(flags)) {
|
|
216
|
+
deferJson();
|
|
217
|
+
let code = EXIT.OK;
|
|
218
|
+
try {
|
|
219
|
+
code = (await command.run(positional.slice(1), flags)) ?? EXIT.OK;
|
|
220
|
+
} finally {
|
|
221
|
+
flushJson(code);
|
|
222
|
+
}
|
|
142
223
|
return code;
|
|
143
224
|
}
|
|
144
225
|
return await command.run(positional.slice(1), flags);
|
|
@@ -157,6 +238,24 @@ async function main(argv) {
|
|
|
157
238
|
}
|
|
158
239
|
}
|
|
159
240
|
|
|
241
|
+
// The OPERATION an invocation names, and the arguments it carries — the two
|
|
242
|
+
// halves the envelope's `command` field used to run together (third audit,
|
|
243
|
+
// finding 6). `doctrina why carteira --json` answered `"command": "why
|
|
244
|
+
// carteira"`, so a consumer reading that field got a different shape for
|
|
245
|
+
// every capability, while `next --json` documents `command`/`args` as the
|
|
246
|
+
// contract to branch on. The catalog is what tells a sub-operation
|
|
247
|
+
// (`spec list`) from an argument (`why carteira`) — shape alone cannot,
|
|
248
|
+
// which is the same reason `lib/usage.js` consults it.
|
|
249
|
+
const KNOWN_OPERATIONS = new Set(OPERATIONS.map((o) => o[0]));
|
|
250
|
+
|
|
251
|
+
function operationName(positional) {
|
|
252
|
+
return operationOf(positional, KNOWN_OPERATIONS) ?? positional[0] ?? "";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function operationArgs(positional) {
|
|
256
|
+
return positional.slice(operationName(positional).split(" ").length);
|
|
257
|
+
}
|
|
258
|
+
|
|
160
259
|
main(process.argv.slice(2)).then((code) => {
|
|
161
260
|
// Record which operation ran, if and only if the operator asked for it
|
|
162
261
|
// by setting DOCTRINA_USAGE_LOG (M8). Off by default, local file only,
|
|
@@ -0,0 +1,484 @@
|
|
|
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
|
+
// Open changes drive the loop: finish what is started before opening more.
|
|
159
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
160
|
+
if (isDir(changesDir)) {
|
|
161
|
+
for (const id of readdirSync(changesDir).sort()) {
|
|
162
|
+
if (id === "archive" || id.startsWith(".")) continue;
|
|
163
|
+
if (!isDir(path.join(changesDir, id))) continue;
|
|
164
|
+
const proposalPath = path.join(changesDir, id, "proposal.md");
|
|
165
|
+
if (!isFile(proposalPath)) {
|
|
166
|
+
actions.push(action({
|
|
167
|
+
id: "change-missing-proposal",
|
|
168
|
+
why: `add a proposal.md to .doctrina/changes/${id}/ (open changes need one)`,
|
|
169
|
+
}));
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const status = listHeader(read(proposalPath), "Status") ?? "proposed";
|
|
173
|
+
if (status === "applied") {
|
|
174
|
+
actions.push(action({
|
|
175
|
+
id: "change-archive-pending",
|
|
176
|
+
command: "change archive",
|
|
177
|
+
args: [id],
|
|
178
|
+
gate: "archive",
|
|
179
|
+
runnable: true,
|
|
180
|
+
why: "applied but not archived",
|
|
181
|
+
}));
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const tasksPath = path.join(changesDir, id, "tasks.md");
|
|
185
|
+
const unchecked = isFile(tasksPath)
|
|
186
|
+
? (read(tasksPath).match(/^-\s+\[ \]/gm) ?? []).length
|
|
187
|
+
: 0;
|
|
188
|
+
if (unchecked > 0) {
|
|
189
|
+
// Deliberately NOT runnable, and deliberately without a command:
|
|
190
|
+
// the only thing that clears this is doing the work. `change tick`
|
|
191
|
+
// would tick the box, which is the dishonesty every gate here
|
|
192
|
+
// exists to prevent.
|
|
193
|
+
actions.push(action({
|
|
194
|
+
id: "change-tasks-open",
|
|
195
|
+
why: `complete ${unchecked} open task${unchecked === 1 ? "" : "s"} in .doctrina/changes/${id}/tasks.md`,
|
|
196
|
+
}));
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const deltas = walk(path.join(changesDir, id, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
200
|
+
if (deltas.length > 0) {
|
|
201
|
+
actions.push(action({
|
|
202
|
+
id: "change-apply-ready",
|
|
203
|
+
command: "analyze",
|
|
204
|
+
args: [id],
|
|
205
|
+
gate: "structure",
|
|
206
|
+
runnable: true,
|
|
207
|
+
why: `tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`,
|
|
208
|
+
text: `doctrina analyze ${id}, then doctrina change apply ${id} — ` +
|
|
209
|
+
`tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`,
|
|
210
|
+
}));
|
|
211
|
+
} else {
|
|
212
|
+
actions.push(action({
|
|
213
|
+
id: "change-deltas-missing",
|
|
214
|
+
command: "change apply",
|
|
215
|
+
args: [id],
|
|
216
|
+
why: `add spec deltas under .doctrina/changes/${id}/specs/, or apply as metadata-only`,
|
|
217
|
+
text: `add spec deltas under .doctrina/changes/${id}/specs/, ` +
|
|
218
|
+
`or apply as metadata-only: doctrina change apply ${id}`,
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ADRs stuck in proposed need a human decision; accepted-but-bare ADRs are
|
|
225
|
+
// the rot the review flagged — a decision with nothing behind it (no Evidence,
|
|
226
|
+
// no Landed) is drift waiting to be superseded. Surface the proposed ones
|
|
227
|
+
// first (a pending decision blocks more than a missing stamp).
|
|
228
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
229
|
+
/** @type {Action[]} */
|
|
230
|
+
const landNudges = [];
|
|
231
|
+
const isBareValue = (v) => {
|
|
232
|
+
const t = (v ?? "").trim();
|
|
233
|
+
return t === "" || t === "—" || t === "-";
|
|
234
|
+
};
|
|
235
|
+
for (const f of walk(adrDir)) {
|
|
236
|
+
if (!f.endsWith(".md")) continue;
|
|
237
|
+
const m = path.basename(f).match(/^(\d{4})-/);
|
|
238
|
+
if (!m) continue;
|
|
239
|
+
const text = read(f);
|
|
240
|
+
const status = listHeader(text, "Status");
|
|
241
|
+
if (status && status.toLowerCase() === "proposed") {
|
|
242
|
+
// Accepting a decision is the human's call, not a step to automate:
|
|
243
|
+
// `runnable` stays false however mechanical the header edit is.
|
|
244
|
+
actions.push(action({
|
|
245
|
+
id: "adr-proposed",
|
|
246
|
+
command: "decision accept",
|
|
247
|
+
args: [m[1]],
|
|
248
|
+
why: `ADR ${m[1]} is still proposed`,
|
|
249
|
+
text: `review ADR ${m[1]} (${relPath(projectRoot, f)}): doctrina decision accept ${m[1]}, or supersede it`,
|
|
250
|
+
}));
|
|
251
|
+
} else if (status && status.toLowerCase() === "accepted") {
|
|
252
|
+
// Bare only when BOTH anchors are empty (Evidence header present-but-bare
|
|
253
|
+
// and no Landed stamp). An ADR that opts out of Evidence entirely
|
|
254
|
+
// (header absent) is not nagged.
|
|
255
|
+
const evidence = listHeader(text, "Evidence");
|
|
256
|
+
const landed = listHeader(text, "Landed");
|
|
257
|
+
if (evidence !== null && isBareValue(evidence) && isBareValue(landed)) {
|
|
258
|
+
landNudges.push(action({
|
|
259
|
+
id: "adr-unproven",
|
|
260
|
+
command: "decision land",
|
|
261
|
+
args: [m[1]],
|
|
262
|
+
severity: "advisory",
|
|
263
|
+
why: `nothing proves ADR ${m[1]}`,
|
|
264
|
+
text: `record what proves ADR ${m[1]}: cite its **Evidence:**, or once it ships, doctrina decision land ${m[1]}`,
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const n of landNudges) actions.push(n);
|
|
270
|
+
|
|
271
|
+
// No procedural memory captured yet, but the history shows a fix-shaped
|
|
272
|
+
// change — exactly the lesson a skill exists to keep from being relearned
|
|
273
|
+
// (review §5: the change-0003 "tolerate LLM code fences" case). One gentle
|
|
274
|
+
// nudge, only when skills are empty, so it never nags a project that opted in.
|
|
275
|
+
const skillNudge = suggestSkillCapture(projectRoot);
|
|
276
|
+
if (skillNudge) actions.push(skillNudge);
|
|
277
|
+
|
|
278
|
+
// GATE SIGNALS. `next` used to answer "no open work" over a tree where
|
|
279
|
+
// `doctor` reported five findings with a named remedy each: it knew the
|
|
280
|
+
// change/ADR/index/intake/runtime/skill lifecycle and nothing about whether
|
|
281
|
+
// the gates were satisfied. The command whose entire job is to answer "what
|
|
282
|
+
// now?" was the one that said "nothing" (second audit).
|
|
283
|
+
//
|
|
284
|
+
// Change 0037 unified the views so they could not disagree; this is that
|
|
285
|
+
// unification finally reaching `next`. Same collections, same numbers — the
|
|
286
|
+
// only difference is that a view REPORTS and an action RECOMMENDS.
|
|
287
|
+
// The gates measure capabilities, so they say nothing before any exists. A
|
|
288
|
+
// freshly initialised project must be told to run `intake`, not to write
|
|
289
|
+
// acceptance criteria for capabilities it has not named yet — three tidy
|
|
290
|
+
// recommendations ahead of the one that matters is the noise this whole
|
|
291
|
+
// change exists to remove.
|
|
292
|
+
const hasSpecs = specCapabilities(projectRoot).length > 0;
|
|
293
|
+
|
|
294
|
+
const cov = hasSpecs ? (gates.coverage ?? safely(() => coverageSummary(projectRoot))) : null;
|
|
295
|
+
if (cov) {
|
|
296
|
+
if (cov.totalDangling > 0) {
|
|
297
|
+
actions.push(action({
|
|
298
|
+
id: "coverage-dangling",
|
|
299
|
+
command: "coverage",
|
|
300
|
+
gate: "coverage",
|
|
301
|
+
why: `${cov.totalDangling} acceptance criterion(s) cite evidence missing on disk`,
|
|
302
|
+
}));
|
|
303
|
+
} else if (cov.totalCriteria === 0) {
|
|
304
|
+
actions.push(action({
|
|
305
|
+
id: "coverage-none",
|
|
306
|
+
command: null,
|
|
307
|
+
gate: "coverage",
|
|
308
|
+
severity: "advisory",
|
|
309
|
+
why: "no acceptance criteria declared yet — nothing proves any capability",
|
|
310
|
+
text: "write acceptance criteria with cited evidence — no capability is proven yet " +
|
|
311
|
+
"(`doctrina coverage` reports them once they exist)",
|
|
312
|
+
}));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const tr = hasSpecs ? (gates.trace ?? safely(() => traceSummary(projectRoot))) : null;
|
|
317
|
+
if (tr) {
|
|
318
|
+
if (tr.dropped > 0) {
|
|
319
|
+
actions.push(action({
|
|
320
|
+
id: "trace-dropped",
|
|
321
|
+
command: "trace",
|
|
322
|
+
gate: "trace",
|
|
323
|
+
why: `${tr.dropped} product intent anchor(s) are realized by no spec`,
|
|
324
|
+
}));
|
|
325
|
+
} else if (tr.anchors === 0) {
|
|
326
|
+
actions.push(action({
|
|
327
|
+
id: "trace-no-anchors",
|
|
328
|
+
command: null,
|
|
329
|
+
gate: "trace",
|
|
330
|
+
severity: "advisory",
|
|
331
|
+
why: "no intent anchors declared in product.md, so nothing traces to product intent",
|
|
332
|
+
text: 'tag product.md success criteria as "- [SC1] ..." and cite them with ' +
|
|
333
|
+
"**Realizes:** in each spec — nothing traces to product intent yet",
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// An active spec still `planned` with no note is an inventory claim: the
|
|
339
|
+
// document says the capability is current and the axis says nothing is built.
|
|
340
|
+
const inventoryClaims = activeButUnbuilt(projectRoot);
|
|
341
|
+
if (inventoryClaims.length > 0) {
|
|
342
|
+
actions.push(action({
|
|
343
|
+
id: "spec-inventory-claim",
|
|
344
|
+
command: "spec set",
|
|
345
|
+
args: [inventoryClaims[0], "--implementation", "auto"],
|
|
346
|
+
gate: "validate",
|
|
347
|
+
why: `${inventoryClaims.length} active spec(s) are still "planned" with no note ` +
|
|
348
|
+
`(${inventoryClaims.slice(0, 3).join(", ")})`,
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// The build gate undeclared is the quietest failure of all: `close` skips
|
|
353
|
+
// step 7 and every change ships without the project's own tests ever running.
|
|
354
|
+
const verify = hasSpecs ? (gates.verify ?? readVerifyState(projectRoot)) : { configured: true, invalid: false };
|
|
355
|
+
if (verify.invalid) {
|
|
356
|
+
actions.push(action({
|
|
357
|
+
id: "verify-invalid",
|
|
358
|
+
command: null,
|
|
359
|
+
gate: "verify",
|
|
360
|
+
why: ".doctrina/verify.json is not valid JSON, so the build gate cannot run",
|
|
361
|
+
text: "fix .doctrina/verify.json — it is not valid JSON, so `doctrina verify` " +
|
|
362
|
+
"and the close's build gate cannot run",
|
|
363
|
+
}));
|
|
364
|
+
} else if (!verify.configured) {
|
|
365
|
+
actions.push(action({
|
|
366
|
+
id: "verify-unconfigured",
|
|
367
|
+
command: "verify",
|
|
368
|
+
args: ["--init"],
|
|
369
|
+
gate: "verify",
|
|
370
|
+
// NOT runnable, though the command is one call: `verify --init` writes a
|
|
371
|
+
// fail-closed placeholder that a person still has to replace with the
|
|
372
|
+
// project's real commands, so running it unattended is not the whole of
|
|
373
|
+
// what the action asks for. Marking it runnable in change 0064 was a
|
|
374
|
+
// mistake — `next --run` picked it, found no runner registered, and
|
|
375
|
+
// errored where it should have said the next step needs a person.
|
|
376
|
+
why: "no .doctrina/verify.json — the close skips its build gate entirely",
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Index drift is silent rot; surface it last.
|
|
381
|
+
try {
|
|
382
|
+
const current = idx.load(projectRoot);
|
|
383
|
+
if (!indexesMatch(deriveIndex(projectRoot, current), current)) {
|
|
384
|
+
actions.push(action({
|
|
385
|
+
id: "index-drift",
|
|
386
|
+
command: "index rebuild",
|
|
387
|
+
gate: "index",
|
|
388
|
+
runnable: true,
|
|
389
|
+
why: "index.json has drifted from the tree",
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
392
|
+
} catch {
|
|
393
|
+
actions.push(action({
|
|
394
|
+
id: "index-unreadable",
|
|
395
|
+
command: "index rebuild",
|
|
396
|
+
gate: "index",
|
|
397
|
+
runnable: true,
|
|
398
|
+
why: "index.json is missing or unreadable",
|
|
399
|
+
}));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return actions;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Return a single skill-capture nudge, or null. Fires only when no skill has
|
|
406
|
+
// been written yet (skills/ holds nothing but .gitkeep) AND the archive shows a
|
|
407
|
+
// fix-shaped change whose lesson is the textbook case for a skill. Deterministic
|
|
408
|
+
// pattern match on the archived folder name — a hint, never a decision (ADR 0005).
|
|
409
|
+
|
|
410
|
+
function suggestSkillCapture(projectRoot) {
|
|
411
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
412
|
+
if (isDir(skillsDir)) {
|
|
413
|
+
const hasSkill = walk(skillsDir).some((f) => f.endsWith(".md"));
|
|
414
|
+
if (hasSkill) return null; // opted in already — never nag
|
|
415
|
+
}
|
|
416
|
+
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
417
|
+
if (!isDir(archiveDir)) return null;
|
|
418
|
+
for (const name of readdirSync(archiveDir).sort()) {
|
|
419
|
+
if (!isDir(path.join(archiveDir, name))) continue;
|
|
420
|
+
const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
421
|
+
if (FIX_SHAPED.test(id)) {
|
|
422
|
+
return action({
|
|
423
|
+
id: "skill-capture",
|
|
424
|
+
command: "skill new",
|
|
425
|
+
args: ["<slug>"],
|
|
426
|
+
severity: "advisory",
|
|
427
|
+
why: "a past fix went uncaptured and no skill exists yet",
|
|
428
|
+
text: `capture a skill from past fixes (e.g. "${id}"): doctrina skill new <slug> — ` +
|
|
429
|
+
`on-demand procedural memory so the next agent does not relearn it (none exist yet)`,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// A collector that throws is a finding for `validate` to make, never a reason
|
|
437
|
+
// for `next` to fall over — the same posture the runtime block above takes.
|
|
438
|
+
function safely(fn) {
|
|
439
|
+
try {
|
|
440
|
+
return fn();
|
|
441
|
+
} catch {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Whether the project declares its build gate. Deliberately the same three
|
|
447
|
+
// states `doctor` reports — configured, absent, invalid — read the same way.
|
|
448
|
+
function readVerifyState(projectRoot) {
|
|
449
|
+
const p = path.join(projectRoot, ".doctrina", "verify.json");
|
|
450
|
+
if (!isFile(p)) return { configured: false, invalid: false };
|
|
451
|
+
try {
|
|
452
|
+
const cfg = JSON.parse(read(p));
|
|
453
|
+
return { configured: Array.isArray(cfg?.checks) && cfg.checks.length > 0, invalid: false };
|
|
454
|
+
} catch {
|
|
455
|
+
return { configured: false, invalid: true };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Active capability specs whose Implementation is still a bare "planned".
|
|
460
|
+
// A note after the value ("planned — backend deferred to Q3") is the declared
|
|
461
|
+
// gap and is left alone, exactly as `validate` leaves it alone.
|
|
462
|
+
function specCapabilities(projectRoot) {
|
|
463
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
464
|
+
if (!isDir(specsDir)) return [];
|
|
465
|
+
return readdirSync(specsDir, { withFileTypes: true })
|
|
466
|
+
.filter((e) => e.isDirectory() && isFile(path.join(specsDir, e.name, "spec.md")))
|
|
467
|
+
.map((e) => e.name)
|
|
468
|
+
.sort();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function activeButUnbuilt(projectRoot) {
|
|
472
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
473
|
+
const out = [];
|
|
474
|
+
for (const cap of specCapabilities(projectRoot)) {
|
|
475
|
+
const entry = { name: cap };
|
|
476
|
+
const file = path.join(specsDir, entry.name, "spec.md");
|
|
477
|
+
const text = read(file);
|
|
478
|
+
const status = (specHeader(text, "Status") ?? "").toLowerCase();
|
|
479
|
+
const impl = (specHeader(text, "Implementation") ?? "").trim();
|
|
480
|
+
if (status !== "active") continue;
|
|
481
|
+
if (impl.toLowerCase() === "planned") out.push(entry.name);
|
|
482
|
+
}
|
|
483
|
+
return out.sort();
|
|
484
|
+
}
|
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 [];
|