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/lib/fs-ops.js
CHANGED
|
@@ -2,8 +2,26 @@
|
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, renameSync, unlinkSync, rmdirSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
+
// Every module reads a file through here, so the byte-order mark is removed
|
|
6
|
+
// here — once, at the door (third audit, finding 5).
|
|
7
|
+
//
|
|
8
|
+
// A spec saved by a Windows editor opens `# Spec — …`, and `validate`
|
|
9
|
+
// reported "carries no title" while `show`, `spec list` and `coverage` read
|
|
10
|
+
// the same file without complaint: the surfaces disagreed about a file none
|
|
11
|
+
// of them had a problem with. The mark is an encoding artifact of how the
|
|
12
|
+
// bytes were stored, not something the Doctrina grammar has an opinion
|
|
13
|
+
// about, so no parser downstream should have to know it exists.
|
|
14
|
+
//
|
|
15
|
+
// It matters beyond Markdown: `JSON.parse` throws on a leading BOM, so an
|
|
16
|
+
// `index.json` written by the wrong editor was unreadable with an error
|
|
17
|
+
// that named neither the cause nor the file.
|
|
18
|
+
//
|
|
19
|
+
// A file that had one loses it when a command rewrites it. That is a
|
|
20
|
+
// normalisation, and the right one — nothing in this tree is served by
|
|
21
|
+
// keeping it.
|
|
5
22
|
export function read(p) {
|
|
6
|
-
|
|
23
|
+
const text = readFileSync(p, "utf8");
|
|
24
|
+
return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
|
|
7
25
|
}
|
|
8
26
|
|
|
9
27
|
export function write(p, content, { force = false } = {}) {
|
|
@@ -53,6 +71,45 @@ export function remove(p) {
|
|
|
53
71
|
}
|
|
54
72
|
}
|
|
55
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Remove `dir` and each ancestor of it, walking up, for as long as each one
|
|
76
|
+
* is EMPTY — stopping at the first directory that still holds something, and
|
|
77
|
+
* never touching `stopAt` itself or anything outside it.
|
|
78
|
+
*
|
|
79
|
+
* The asymmetry this closes: a command that creates a directory with
|
|
80
|
+
* `mkdirp` on the way in has to unmake it on the way out, or "remove" leaves
|
|
81
|
+
* a configuration root behind. An EMPTY directory is the safe case to
|
|
82
|
+
* delete — there is nothing of anyone else's in it — which is why this
|
|
83
|
+
* stops at the first non-empty one rather than recursing.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} dir The innermost directory to consider.
|
|
86
|
+
* @param {string} stopAt Boundary, never removed and never escaped.
|
|
87
|
+
* @returns {string[]} The directories removed, innermost first.
|
|
88
|
+
*/
|
|
89
|
+
export function pruneEmptyDirs(dir, stopAt) {
|
|
90
|
+
const removed = [];
|
|
91
|
+
const boundary = path.resolve(stopAt);
|
|
92
|
+
let cur = path.resolve(dir);
|
|
93
|
+
while (cur !== boundary && cur.startsWith(boundary + path.sep)) {
|
|
94
|
+
if (!isDir(cur)) break;
|
|
95
|
+
let entries;
|
|
96
|
+
try {
|
|
97
|
+
entries = readdirSync(cur);
|
|
98
|
+
} catch {
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
if (entries.length > 0) break;
|
|
102
|
+
try {
|
|
103
|
+
rmdirSync(cur);
|
|
104
|
+
} catch {
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
removed.push(cur);
|
|
108
|
+
cur = path.dirname(cur);
|
|
109
|
+
}
|
|
110
|
+
return removed;
|
|
111
|
+
}
|
|
112
|
+
|
|
56
113
|
// Recursive file walk. Returns absolute paths of every regular file.
|
|
57
114
|
export function walk(root) {
|
|
58
115
|
const out = [];
|
package/src/lib/gates.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import { getSection } from "./doc-model.js";
|
|
2
|
+
import { checklistProgress, getSection } from "./doc-model.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { appendFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { exists, isFile, mkdirp, read } from "./fs-ops.js";
|
|
6
6
|
import { today } from "./dates.js";
|
|
7
|
-
import { collectAnalysis } from "
|
|
7
|
+
import { collectAnalysis } from "./analysis.js";
|
|
8
|
+
import { appendLedgerLine, forcedLine } from "./ledger.js";
|
|
8
9
|
|
|
9
10
|
// The gate-to-transition map (audit item C6).
|
|
10
11
|
//
|
|
@@ -64,7 +65,9 @@ export const GATES = {
|
|
|
64
65
|
rerun: (id) => `doctrina change tick ${id}`,
|
|
65
66
|
blockers(projectRoot, changeDir) {
|
|
66
67
|
const out = [];
|
|
67
|
-
|
|
68
|
+
// Same counter as every other surface (change 0067).
|
|
69
|
+
const countUnchecked = (text, section = null) =>
|
|
70
|
+
checklistProgress(text, { section }).total - checklistProgress(text, { section }).done;
|
|
68
71
|
|
|
69
72
|
const tasksPath = path.join(changeDir, "tasks.md");
|
|
70
73
|
if (isFile(tasksPath)) {
|
|
@@ -73,7 +76,7 @@ export const GATES = {
|
|
|
73
76
|
}
|
|
74
77
|
const proposalPath = path.join(changeDir, "proposal.md");
|
|
75
78
|
if (isFile(proposalPath)) {
|
|
76
|
-
const n = countUnchecked(
|
|
79
|
+
const n = countUnchecked(read(proposalPath), "Verification");
|
|
77
80
|
if (n > 0) out.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
|
|
78
81
|
}
|
|
79
82
|
return out;
|
|
@@ -105,6 +108,175 @@ export const TRANSITIONS = {
|
|
|
105
108
|
archive: { label: "change archive", gates: ["integrity", "verification"] },
|
|
106
109
|
};
|
|
107
110
|
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// The gate SEQUENCES (audit finding F1)
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
//
|
|
115
|
+
// ADR 0017 promised ONE gate map. TRANSITIONS above delivered it for the two
|
|
116
|
+
// mutating transitions and stopped there: `close` carried a literal array of
|
|
117
|
+
// ten steps, `doctor` eight hand-written rows, and `action.yml` five YAML
|
|
118
|
+
// steps. Four lists, four owners, and nothing that noticed when they
|
|
119
|
+
// diverged — which is how the runtime gate could land in `close` and stay
|
|
120
|
+
// absent from CI (change 0033 had to add it to both by hand, and nothing
|
|
121
|
+
// would have caught it if the second edit had been forgotten).
|
|
122
|
+
//
|
|
123
|
+
// So a sequence is DATA. Each step declares what it is, how hard it bites,
|
|
124
|
+
// and the command that runs it standalone. The surfaces only RENDER it:
|
|
125
|
+
// `close` executes it in order, `doctor` reports each as a row, and
|
|
126
|
+
// `doctrina ci --emit github` writes it as CI YAML. None of them may invent
|
|
127
|
+
// a step, drop one, or reorder — a drift test holds each surface to this
|
|
128
|
+
// declaration, and the emitted YAML to the versioned `action.yml`.
|
|
129
|
+
//
|
|
130
|
+
// What this does NOT decide is which gates belong in a sequence; it declares
|
|
131
|
+
// the sequences that exist so that the answer lives in one file.
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One step of a gate sequence.
|
|
135
|
+
*
|
|
136
|
+
* @typedef {object} GateStep
|
|
137
|
+
* @property {string} id Stable key. The surfaces bind their renderer/runner to it.
|
|
138
|
+
* @property {string} label What the surface calls the step.
|
|
139
|
+
* @property {"blocking"|"advisory"|"forceable"} level
|
|
140
|
+
* blocking — a non-zero result stops the sequence.
|
|
141
|
+
* advisory — reported, never stops anything.
|
|
142
|
+
* forceable — blocking, unless --force, which records the gap in the ledger.
|
|
143
|
+
* @property {string[]|null} argv
|
|
144
|
+
* The standalone CLI invocation this step is, or null when the step is not
|
|
145
|
+
* one command (the ADR checkpoint, the docs gate). The token "<id>" is
|
|
146
|
+
* replaced with the change id at render time.
|
|
147
|
+
* @property {string} [rerun] Literal rerun line, for a step argv cannot describe.
|
|
148
|
+
* @property {string} [why] One line of rationale. The CI emitter writes it as a comment.
|
|
149
|
+
* @property {boolean} [strict] CI only: the command takes --strict when the action runs strict.
|
|
150
|
+
* @property {string} [script] CI only: a shell body that replaces the derived one-liner.
|
|
151
|
+
* @property {string} [flag] Only run/report this step when that flag is set.
|
|
152
|
+
* @property {string} [short] CI only: the name used in the action's own description.
|
|
153
|
+
* @property {string} [summary] CI only: the name plus what it checks, for the file header.
|
|
154
|
+
*/
|
|
155
|
+
|
|
156
|
+
/** @type {Record<string, GateStep[]>} */
|
|
157
|
+
export const SEQUENCES = {
|
|
158
|
+
// The closing sequence. `close` runs these in order, in-process where it
|
|
159
|
+
// can, stopping at the first blocking failure with the step's rerun line.
|
|
160
|
+
close: [
|
|
161
|
+
{ id: "analyze", label: "analyze", level: "blocking", argv: ["analyze", "<id>"] },
|
|
162
|
+
{ id: "adr-checkpoint", label: "ADR checkpoint (advisory)", level: "advisory", argv: ["decision", "list"] },
|
|
163
|
+
// Conformance review (audit finding F3). It is the richest analysis the
|
|
164
|
+
// project has — capabilities whose code moved while their spec did not,
|
|
165
|
+
// affected dependants, dangling coverage — and no driver invoked it, so
|
|
166
|
+
// it only ever happened when somebody typed the command.
|
|
167
|
+
//
|
|
168
|
+
// BEFORE the apply, deliberately: that is the point where its findings can
|
|
169
|
+
// still change what gets written. Advisory for now — it raises a break for
|
|
170
|
+
// every capability with touched code and a still spec, and that noise has
|
|
171
|
+
// to be measured before it is allowed to refuse. `review --strict` remains
|
|
172
|
+
// the door for a project that wants CI to block on it.
|
|
173
|
+
{ id: "review", label: "review (advisory)", level: "advisory", argv: ["review"] },
|
|
174
|
+
{ id: "apply", label: "apply", level: "blocking", argv: ["change", "apply", "<id>"] },
|
|
175
|
+
{ id: "runtime", label: "runtime", level: "blocking", argv: ["contract", "check"] },
|
|
176
|
+
{ id: "implementation", label: "implementation (advisory)", level: "advisory", argv: null, rerun: "doctrina spec set <cap> --implementation auto" },
|
|
177
|
+
{ id: "verify", label: "verify", level: "blocking", argv: ["verify"] },
|
|
178
|
+
{ id: "coverage", label: "coverage", level: "blocking", argv: ["coverage", "--strict"] },
|
|
179
|
+
{ id: "trace", label: "trace", level: "advisory", argv: ["trace"] },
|
|
180
|
+
{ id: "docs", label: "docs", level: "forceable", argv: null, rerun: "document the change, then rerun" },
|
|
181
|
+
{ id: "archive", label: "archive", level: "blocking", argv: ["change", "archive", "<id>"] },
|
|
182
|
+
{ id: "validate", label: "validate", level: "blocking", argv: ["validate"] },
|
|
183
|
+
],
|
|
184
|
+
|
|
185
|
+
// The diagnostic sequence. Every row is advisory in the sense that `doctor`
|
|
186
|
+
// never mutates anything; the level says whether the row counts as a
|
|
187
|
+
// failing area (exit 1) or an advisory one (exit 0).
|
|
188
|
+
doctor: [
|
|
189
|
+
{ id: "validate", label: "validate", level: "blocking", argv: ["validate"] },
|
|
190
|
+
{ id: "index", label: "index", level: "blocking", argv: ["index", "rebuild", "--check"] },
|
|
191
|
+
{ id: "coverage", label: "coverage", level: "advisory", argv: ["coverage"] },
|
|
192
|
+
{ id: "trace", label: "trace", level: "advisory", argv: ["trace"] },
|
|
193
|
+
{ id: "clean-checkout", label: "clean-checkout", level: "blocking", argv: ["verify", "--clean"] },
|
|
194
|
+
{ id: "templates", label: "templates", level: "advisory", argv: ["templates", "check"] },
|
|
195
|
+
{ id: "runtime", label: "runtime", level: "blocking", argv: ["contract", "check"] },
|
|
196
|
+
{ id: "local-env", label: "local .env", level: "blocking", argv: null, rerun: "doctrina doctor --env", flag: "env" },
|
|
197
|
+
{ id: "verify-config", label: "verify config", level: "blocking", argv: ["verify", "--init"] },
|
|
198
|
+
// The declared size budgets, and the headroom left. Reported BEFORE either
|
|
199
|
+
// is breached: `agents-md-lines` is OUTPUT, so `analyze` refuses the
|
|
200
|
+
// raise-the-ceiling fix, and the generated surface block lives inside
|
|
201
|
+
// AGENTS.md — one command added spends a line of both (change 0072).
|
|
202
|
+
{ id: "budgets", label: "budgets", level: "advisory", argv: null, rerun: "doctrina validate" },
|
|
203
|
+
// Not a check — a READOUT. Every other row can fail; this one exists
|
|
204
|
+
// because a project could not see what it had configured without reading
|
|
205
|
+
// the CLI's source, which is how a pt-BR project sat red under `clarify`
|
|
206
|
+
// with no clue why (change 0047). Advisory: a default is not a fault.
|
|
207
|
+
{ id: "config", label: "config", level: "advisory", argv: null, rerun: "edit .doctrina/config.json" },
|
|
208
|
+
// Also a readout, and also advisory. It appears only when the operator
|
|
209
|
+
// has switched the usage log on: an instrument that is off has nothing
|
|
210
|
+
// to say, and a row saying so every run would train the reader to skip
|
|
211
|
+
// the section (change 0050).
|
|
212
|
+
{ id: "usage", label: "usage", level: "advisory", argv: null, rerun: "doctrina metrics --commands" },
|
|
213
|
+
],
|
|
214
|
+
|
|
215
|
+
// The CI sequence, emitted as the composite action. Deliberately WITHOUT
|
|
216
|
+
// `verify`: the build gate is the adopting project's own to run, and the
|
|
217
|
+
// action exists to check the artifact tree.
|
|
218
|
+
ci: [
|
|
219
|
+
{ id: "validate", label: "doctrina validate", level: "blocking", argv: ["validate"], short: "validate", summary: "validate (schema/structure)" },
|
|
220
|
+
{ id: "index-drift", label: "doctrina index rebuild --check", level: "blocking", argv: ["index", "rebuild", "--check"], short: "index drift", summary: "index rebuild --check (index ↔ tree drift)" },
|
|
221
|
+
{
|
|
222
|
+
id: "contract-check",
|
|
223
|
+
label: "doctrina contract check",
|
|
224
|
+
level: "blocking",
|
|
225
|
+
argv: ["contract", "check"],
|
|
226
|
+
short: "contract check",
|
|
227
|
+
summary: "contract check (the declared runtime surface, RT01-RT05)",
|
|
228
|
+
why:
|
|
229
|
+
"The runtime gate (RT01-RT05). Every other step reads Markdown; this one\n" +
|
|
230
|
+
"reads what the Markdown CLAIMS about the running system and holds the\n" +
|
|
231
|
+
"implementation to it — the variable no workflow exports, the default an\n" +
|
|
232
|
+
"empty CI value never triggers, the unvalidated enum, the selector that\n" +
|
|
233
|
+
"matches nothing and still exits 0. Not gated on `strict`: a declaration\n" +
|
|
234
|
+
"that does not hold is an error at any adoption stage. A project with no\n" +
|
|
235
|
+
"contracts prints one line and exits 0.",
|
|
236
|
+
},
|
|
237
|
+
{ id: "coverage", label: "doctrina coverage", level: "blocking", argv: ["coverage"], strict: true, short: "coverage", summary: "coverage (acceptance-criteria evidence)" },
|
|
238
|
+
{ id: "trace", label: "doctrina trace", level: "blocking", argv: ["trace"], strict: true, short: "trace", summary: "trace (intent provenance)" },
|
|
239
|
+
{
|
|
240
|
+
id: "context-budget",
|
|
241
|
+
label: "doctrina context (budget)",
|
|
242
|
+
level: "blocking",
|
|
243
|
+
argv: ["context"],
|
|
244
|
+
short: "context budget",
|
|
245
|
+
summary: "context (the pack budget, ADR 0022)",
|
|
246
|
+
why:
|
|
247
|
+
"The context budget (ADR 0022). A pack that degrades to fit is fine; this\n" +
|
|
248
|
+
"fails only when a pack's irreducible core no longer fits at all, which\n" +
|
|
249
|
+
"means a spec has outgrown itself or a change has gone stale. Configure\n" +
|
|
250
|
+
"the ceiling per project with \"context_budget\": n in\n" +
|
|
251
|
+
".doctrina/config.json.",
|
|
252
|
+
script:
|
|
253
|
+
"{{PREFIX}} context >/dev/null\n" +
|
|
254
|
+
"for cap in $(ls .doctrina/specs 2>/dev/null); do\n" +
|
|
255
|
+
" {{PREFIX}} context \"$cap\" >/dev/null\n" +
|
|
256
|
+
"done",
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/** The declared steps of a sequence, or an error naming the ones that exist. */
|
|
262
|
+
export function sequence(name) {
|
|
263
|
+
const steps = SEQUENCES[name];
|
|
264
|
+
if (!steps) throw new Error(`unknown gate sequence "${name}" (declared: ${Object.keys(SEQUENCES).join(", ")})`);
|
|
265
|
+
return steps;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The command that runs one step on its own — what a surface prints when it
|
|
270
|
+
* tells the operator how to clear the gate. Derived from `argv` so the
|
|
271
|
+
* declaration cannot disagree with itself; `rerun` covers the steps that are
|
|
272
|
+
* not a single command.
|
|
273
|
+
*/
|
|
274
|
+
export function stepRerun(step, changeId = "<id>") {
|
|
275
|
+
if (step.rerun) return step.rerun;
|
|
276
|
+
if (!step.argv) return `doctrina ${step.id}`;
|
|
277
|
+
return `doctrina ${step.argv.map((a) => (a === "<id>" ? changeId : a)).join(" ")}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
108
280
|
// Evaluate a transition's gates. Returns { ok, blockers, gates } where
|
|
109
281
|
// blockers is a flat list of human-readable reasons.
|
|
110
282
|
export function checkTransition(projectRoot, changeDir, transition) {
|
|
@@ -123,25 +295,11 @@ export function checkTransition(projectRoot, changeDir, transition) {
|
|
|
123
295
|
// waved through is part of the visible history rather than nothing at all
|
|
124
296
|
// — the same posture `change archive --force` and `change abandon` take.
|
|
125
297
|
export function recordForcedGap(projectRoot, id, transition, blockers) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
// this is meant to break.
|
|
132
|
-
if (!exists(ledgerPath)) {
|
|
133
|
-
mkdirp(archiveDir);
|
|
134
|
-
writeFileSync(ledgerPath,
|
|
135
|
-
"# Change ledger\n\n" +
|
|
136
|
-
"One line per archived change, newest last. Appended by\n" +
|
|
137
|
-
"`doctrina change archive`; edit freely, the CLI only appends.\n\n");
|
|
138
|
-
}
|
|
139
|
-
const summary = blockers.map((b) => `${b.gate}: ${b.message}`).join("; ");
|
|
140
|
-
appendFileSync(
|
|
141
|
-
ledgerPath,
|
|
142
|
-
` - ${today()} — ${id} — forced ${transition} past ${blockers.length} ` +
|
|
143
|
-
`blocker${blockers.length === 1 ? "" : "s"} (${summary})\n`,
|
|
144
|
-
);
|
|
298
|
+
// The ledger owns its own format and its own creation (lib/ledger.js): a
|
|
299
|
+
// forced transition can happen before any change has ever been archived,
|
|
300
|
+
// and a gap that goes unrecorded because the file did not exist yet is
|
|
301
|
+
// exactly the silence this is meant to break.
|
|
302
|
+
appendLedgerLine(projectRoot, forcedLine(id, transition, blockers));
|
|
145
303
|
return true;
|
|
146
304
|
}
|
|
147
305
|
|
package/src/lib/git.js
CHANGED
|
@@ -69,6 +69,23 @@ export function hasCommits(cwd) {
|
|
|
69
69
|
return git(cwd, ["rev-parse", "--verify", "HEAD"]).state === GIT_STATE.OK;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Does this ref resolve to a commit in this repository?
|
|
74
|
+
*
|
|
75
|
+
* Asked directly, because the message heuristic above cannot answer it: git
|
|
76
|
+
* says "unknown revision or path not in the working tree" both for a
|
|
77
|
+
* repository with no commits and for a ref that does not exist, so a diff
|
|
78
|
+
* against a missing ref came back as GIT_STATE.EMPTY and every caller read
|
|
79
|
+
* it as "nothing changed". `doctrina review --diff main --strict` therefore
|
|
80
|
+
* passed on a shallow clone with no local `main` (change 0092).
|
|
81
|
+
*
|
|
82
|
+
* False when git is absent, this is not a repository, or the ref is unknown —
|
|
83
|
+
* callers that need to tell those apart ask `historyState` as well.
|
|
84
|
+
*/
|
|
85
|
+
export function refExists(cwd, ref) {
|
|
86
|
+
return git(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).state === GIT_STATE.OK;
|
|
87
|
+
}
|
|
88
|
+
|
|
72
89
|
// The state a command should report before trying to read history:
|
|
73
90
|
// { usable, state, reason } where `reason` is a sentence fit to print.
|
|
74
91
|
export function historyState(cwd) {
|
|
@@ -95,3 +112,118 @@ export function gitLines(cwd, args) {
|
|
|
95
112
|
function splitLines(s) {
|
|
96
113
|
return s.split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
|
|
97
114
|
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// The period window the report view describes (change 0037)
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
//
|
|
120
|
+
// `report` shelled out to git twice with its own spawnSync, which is the one
|
|
121
|
+
// pattern this module exists to end: a caller that reads a non-zero exit as
|
|
122
|
+
// "no commits" rather than "the command refused". Both probes go through
|
|
123
|
+
// `git()` here, so an absent binary, a non-repository and a real failure stay
|
|
124
|
+
// distinguishable — and the view above stays a pure formatter.
|
|
125
|
+
|
|
126
|
+
/** The ISO date `days` ago — the left edge of a report window. */
|
|
127
|
+
export function windowCutoff(days) {
|
|
128
|
+
return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Commits, fix-shaped share and top-churn files in the window.
|
|
133
|
+
* Returns null when there is no history to read — outside a repository, with
|
|
134
|
+
* git absent, or before the first commit — so the caller can say so rather
|
|
135
|
+
* than print a confident zero.
|
|
136
|
+
*/
|
|
137
|
+
export function gitWindow(cwd, days) {
|
|
138
|
+
const since = `--since=${days} days ago`;
|
|
139
|
+
const log = git(cwd, ["log", since, "--pretty=%s"]);
|
|
140
|
+
if (log.state !== GIT_STATE.OK) return null;
|
|
141
|
+
const subjects = log.lines.filter((l) => l.trim().length > 0);
|
|
142
|
+
const fixes = subjects.filter((sub) => /^(fix|bug|hotfix|patch)(\(|:|!)/i.test(sub)).length;
|
|
143
|
+
|
|
144
|
+
const files = git(cwd, ["log", since, "--name-only", "--pretty=format:"]);
|
|
145
|
+
const counts = new Map();
|
|
146
|
+
if (files.state === GIT_STATE.OK) {
|
|
147
|
+
for (const line of files.lines) {
|
|
148
|
+
const f = line.trim();
|
|
149
|
+
if (!f) continue;
|
|
150
|
+
counts.set(f, (counts.get(f) ?? 0) + 1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const churn = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
154
|
+
return { commits: subjects.length, fixes, churn };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Changed files: the one answer, four callers (audit finding F5)
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
//
|
|
161
|
+
// `work`, `review`, `context` and `docs-impact` each carried their own
|
|
162
|
+
// "which files changed" — four spawnSync helpers, four slightly different
|
|
163
|
+
// answers, and each one reading a non-zero exit as an empty list rather than
|
|
164
|
+
// a refusal. The semantics that genuinely differ between them are OPTIONS
|
|
165
|
+
// here, not separate implementations: `docs-impact` needs the merge-base so a
|
|
166
|
+
// branch's earlier commits count, `context --diff` compares against a named
|
|
167
|
+
// ref, and a review against a ref does not want untracked files mixed in.
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The files that changed, as the caller defines "changed".
|
|
171
|
+
*
|
|
172
|
+
* @param {string} cwd
|
|
173
|
+
* @param {object} [opts]
|
|
174
|
+
* @param {string|null} [opts.since] Compare against this ref instead of HEAD.
|
|
175
|
+
* @param {boolean} [opts.untracked] Include untracked, non-ignored files (default true).
|
|
176
|
+
* @param {boolean} [opts.mergeBase] Also include everything since the merge-base with the
|
|
177
|
+
* default branch, so a branch's earlier commits count.
|
|
178
|
+
* @param {string[]} [opts.bases] Which branch names to try as the merge-base target.
|
|
179
|
+
* @returns {{ ok: boolean, files: string[], state: string }}
|
|
180
|
+
* `ok` is false when git could not answer — outside a repository, with git
|
|
181
|
+
* absent, or on a real failure. A caller that treats "not ok" as "nothing
|
|
182
|
+
* changed" is making the mistake this door exists to prevent, so the state
|
|
183
|
+
* is returned rather than folded into an empty list.
|
|
184
|
+
*/
|
|
185
|
+
export function changedFiles(cwd, { since = null, untracked = true, mergeBase = false, bases = ["main", "master"] } = {}) {
|
|
186
|
+
const out = new Set();
|
|
187
|
+
const add = (r) => {
|
|
188
|
+
for (const line of r.lines) {
|
|
189
|
+
const p = line.trim();
|
|
190
|
+
if (p) out.add(p.replace(/\\/g, "/"));
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const diff = git(cwd, since ? ["diff", "--name-only", since, "--"] : ["diff", "--name-only", "HEAD"]);
|
|
195
|
+
if (diff.state !== GIT_STATE.OK) return { ok: false, files: [], state: diff.state };
|
|
196
|
+
add(diff);
|
|
197
|
+
|
|
198
|
+
if (untracked) {
|
|
199
|
+
const others = git(cwd, ["ls-files", "--others", "--exclude-standard"]);
|
|
200
|
+
if (others.state === GIT_STATE.OK) add(others);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (mergeBase) {
|
|
204
|
+
for (const base of bases) {
|
|
205
|
+
const mb = git(cwd, ["merge-base", "HEAD", base]);
|
|
206
|
+
if (mb.state === GIT_STATE.OK && mb.lines[0]) {
|
|
207
|
+
const since = git(cwd, ["diff", "--name-only", `${mb.lines[0].trim()}..HEAD`]);
|
|
208
|
+
if (since.state === GIT_STATE.OK) add(since);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { ok: true, files: [...out].sort(), state: GIT_STATE.OK };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The date of the most recent tag, as YYYY-MM-DD, or null.
|
|
219
|
+
*
|
|
220
|
+
* The left edge of "what has landed since the last release". Null when there
|
|
221
|
+
* is no tag, no history, or no repository — the caller then has to say which
|
|
222
|
+
* window it used rather than pretend there was one.
|
|
223
|
+
*/
|
|
224
|
+
export function lastTagDate(cwd) {
|
|
225
|
+
const tags = gitLines(cwd, ["for-each-ref", "--sort=-creatordate", "--count=1",
|
|
226
|
+
"--format=%(creatordate:short)", "refs/tags"]);
|
|
227
|
+
const date = tags[0] ?? "";
|
|
228
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
|
|
229
|
+
}
|
package/src/lib/index-json.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { read, write, exists } from "./fs-ops.js";
|
|
4
|
-
import { cliVersion } from "./version.js";
|
|
4
|
+
import { cliVersion, newestVersion } from "./version.js";
|
|
5
5
|
|
|
6
6
|
export const SCHEMA_VERSION = "0.1.0";
|
|
7
7
|
|
|
@@ -39,7 +39,7 @@ export function save(projectRoot, index) {
|
|
|
39
39
|
// records which CLI last wrote it, so a stale stamp (an index written by an
|
|
40
40
|
// older CLI) is detectable by `doctrina validate` and migrated by
|
|
41
41
|
// `doctrina index rebuild`.
|
|
42
|
-
index.framework_version = cliVersion();
|
|
42
|
+
index.framework_version = newestVersion(index.framework_version, cliVersion());
|
|
43
43
|
const p = indexPath(projectRoot);
|
|
44
44
|
const text = JSON.stringify(index, null, 2) + "\n";
|
|
45
45
|
write(p, text, { force: true });
|
|
@@ -80,12 +80,6 @@ export function addContract(index, entry) {
|
|
|
80
80
|
return index;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
export function removeSkill(index, id) {
|
|
84
|
-
if (!index.artifacts.skills) return index;
|
|
85
|
-
index.artifacts.skills = index.artifacts.skills.filter((s) => s.id !== id);
|
|
86
|
-
return index;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
83
|
export function touch(index, date) {
|
|
90
84
|
index.last_updated = date;
|
|
91
85
|
return index;
|
|
@@ -98,11 +92,6 @@ export function addSpec(index, entry) {
|
|
|
98
92
|
return index;
|
|
99
93
|
}
|
|
100
94
|
|
|
101
|
-
export function removeSpec(index, id) {
|
|
102
|
-
index.artifacts.specs = index.artifacts.specs.filter((s) => s.id !== id);
|
|
103
|
-
return index;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
95
|
export function addDecision(index, entry) {
|
|
107
96
|
if (!index.artifacts.decisions.some((d) => d.id === entry.id)) {
|
|
108
97
|
index.artifacts.decisions.push(entry);
|
|
@@ -117,12 +106,6 @@ export function updateDecision(index, id, mutator) {
|
|
|
117
106
|
return index;
|
|
118
107
|
}
|
|
119
108
|
|
|
120
|
-
export function addChange(index, entry) {
|
|
121
|
-
if (!index.artifacts.changes.some((c) => c.id === entry.id)) {
|
|
122
|
-
index.artifacts.changes.push(entry);
|
|
123
|
-
}
|
|
124
|
-
return index;
|
|
125
|
-
}
|
|
126
109
|
|
|
127
110
|
export function moveChangeToArchive(index, id, archiveEntry) {
|
|
128
111
|
index.artifacts.changes = index.artifacts.changes.filter((c) => c.id !== id);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Writing the intake and printing the bootstrap playbook.
|
|
3
|
+
//
|
|
4
|
+
// `init --intake` performs the same three operations `intake` does, and
|
|
5
|
+
// reached into `commands/intake.js` for them (audit finding F7). They are
|
|
6
|
+
// operations over the tree, not renderings of one command, so both
|
|
7
|
+
// entry points read them from here.
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { exists, isFile, read, relPath, write } from "./fs-ops.js";
|
|
10
|
+
import { assessBrief } from "./clarity.js";
|
|
11
|
+
import { c } from "./colors.js";
|
|
12
|
+
import { printPlaybookTemplate } from "./playbook.js";
|
|
13
|
+
// Clarification gate (review Topic A). A description too thin to spec from is
|
|
14
|
+
// the moment to ask the user, not to let the agent invent requirements. Prints
|
|
15
|
+
// the specific gaps before the bootstrap playbook; advisory, never blocking —
|
|
16
|
+
// the intake is still captured verbatim (the playbook step 6 resolves it).
|
|
17
|
+
/**
|
|
18
|
+
* Does this argument read as a FILE PATH, or as the description itself?
|
|
19
|
+
*
|
|
20
|
+
* `doctrina intake "<description>"` is the form the surface block's wording
|
|
21
|
+
* invites — "store the intent" — and it answered `description file not found:`
|
|
22
|
+
* followed by the author's whole sentence, echoed back as if it were a
|
|
23
|
+
* filename, with no mention of `--text` (change 0071). Prose and a path are
|
|
24
|
+
* distinguishable without guessing: a path has no sentence in it.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately conservative — anything that could be a path IS treated as one,
|
|
27
|
+
* so a real file is never mistaken for prose and read as a description.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} value
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
export function looksLikePath(value) {
|
|
33
|
+
const v = String(value).trim();
|
|
34
|
+
if (v === "") return false;
|
|
35
|
+
if (/[/\\]/.test(v)) return true; // a separator settles it
|
|
36
|
+
if (/^[.~]/.test(v)) return true; // ./x, ../x, ~/x
|
|
37
|
+
if (!/\s/.test(v)) return true; // one token: assume a filename
|
|
38
|
+
if (/\.(md|txt|markdown|rst|adoc|org)$/i.test(v)) return true; // "my notes.txt"
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function warnIfThinIntake(body) {
|
|
43
|
+
const assessment = assessBrief(body, { kind: "intake" });
|
|
44
|
+
if (!assessment.thin) return;
|
|
45
|
+
console.log(c.yellow("⚠ thin intake — clarify with the user before converting to specs:"));
|
|
46
|
+
for (const reason of assessment.reasons) console.log(` - ${reason}`);
|
|
47
|
+
console.log("");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Shared with `init --intake`. Writes the intake verbatim under a small
|
|
51
|
+
// status header; the agent flips Status to converted at the end of the
|
|
52
|
+
// playbook, which is how `next` knows the bootstrap is done.
|
|
53
|
+
export function writeIntakeFile(projectRoot, { body, source, projectName, date, force = false }) {
|
|
54
|
+
const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
|
|
55
|
+
const content = [
|
|
56
|
+
`# Intake — ${projectName}`,
|
|
57
|
+
"",
|
|
58
|
+
"- **Status:** pending",
|
|
59
|
+
`- **Date:** ${date}`,
|
|
60
|
+
`- **Source:** ${source}`,
|
|
61
|
+
"",
|
|
62
|
+
"<!-- Raw project intent, stored verbatim. The bootstrap playbook",
|
|
63
|
+
" (doctrina intake) converts it into product.md and capability",
|
|
64
|
+
" specs, then flips Status to converted. After conversion the",
|
|
65
|
+
" specs are the only source of truth; never edit this file to",
|
|
66
|
+
" change requirements. -->",
|
|
67
|
+
"",
|
|
68
|
+
"---",
|
|
69
|
+
"",
|
|
70
|
+
body.replace(/\r\n/g, "\n").replace(/\n*$/, "\n"),
|
|
71
|
+
].join("\n");
|
|
72
|
+
write(intakePath, content, { force });
|
|
73
|
+
return intakePath;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The bootstrap playbook, like the work and chore playbooks, is a TEMPLATE
|
|
77
|
+
// resolved project-over-bundled (audit finding F17). What stays here is the
|
|
78
|
+
// one conditional: a tree that never got its specs/ directory.
|
|
79
|
+
export function printBootstrapPlaybook(projectRoot) {
|
|
80
|
+
const hasSpecs = exists(path.join(projectRoot, ".doctrina", "specs"));
|
|
81
|
+
printPlaybookTemplate(projectRoot, "bootstrap", {
|
|
82
|
+
MISSING_SPECS_WARNING: hasSpecs
|
|
83
|
+
? ""
|
|
84
|
+
: "\n" + c.yellow("warn:") + " .doctrina/specs/ is missing — re-run `doctrina init` first.",
|
|
85
|
+
});
|
|
86
|
+
}
|