doctrina-cli 0.15.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -72
- package/package.json +3 -3
- package/src/commands/adapter.js +27 -3
- package/src/commands/analyze.js +16 -212
- package/src/commands/change.js +144 -267
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +65 -21
- package/src/commands/close.js +349 -45
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +284 -111
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +66 -7
- package/src/commands/doctor.js +293 -130
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +17 -3
- package/src/commands/index-rebuild.js +55 -44
- package/src/commands/init.js +88 -16
- package/src/commands/intake.js +85 -99
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +65 -88
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +23 -91
- package/src/commands/report.js +40 -109
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +23 -39
- package/src/commands/spec.js +38 -11
- package/src/commands/status.js +69 -171
- package/src/commands/templates.js +35 -148
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +44 -877
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +243 -267
- package/src/index.js +139 -12
- package/src/lib/actions.js +490 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +133 -0
- package/src/lib/commands.js +135 -10
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +431 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +370 -3
- package/src/lib/docs-impact.js +270 -27
- package/src/lib/ears.js +24 -1
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +231 -29
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +42 -24
- package/src/lib/intake-model.js +155 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +228 -0
- package/src/lib/metrics-model.js +189 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +105 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +328 -25
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +231 -0
- package/src/lib/spec-ops.js +72 -8
- package/src/lib/templates-model.js +289 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1381 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +413 -0
- package/src/lib/work-model.js +102 -0
- package/templates/AGENTS.md.template +21 -28
- package/templates/README.md +35 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +16 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +16 -2
- package/templates/playbooks/bootstrap.md.template +47 -0
- package/templates/playbooks/chore.md.template +24 -0
- package/templates/playbooks/work.md.template +83 -0
- package/src/commands/constitution.js +0 -145
package/src/commands/intake.js
CHANGED
|
@@ -2,12 +2,19 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
5
|
+
import { ensureDoctrinaProject, projectName } from "../lib/project.js";
|
|
5
6
|
import { listHeader } from "../lib/scan.js";
|
|
6
7
|
import { assessBrief } from "../lib/clarity.js";
|
|
7
8
|
import * as idx from "../lib/index-json.js";
|
|
8
9
|
import { today } from "../lib/dates.js";
|
|
9
10
|
import { flagBool, flagString } from "../lib/args.js";
|
|
10
11
|
import { c } from "../lib/colors.js";
|
|
12
|
+
import { looksLikePath, markIntakeConverted, printBootstrapPlaybook, warnIfThinIntake, writeIntakeFile } from "../lib/intake-model.js";
|
|
13
|
+
|
|
14
|
+
// `init --intake` performs the same operations; they live in
|
|
15
|
+
// lib/intake-model.js so neither command imports out of the other (F7).
|
|
16
|
+
export { warnIfThinIntake, writeIntakeFile, printBootstrapPlaybook } from "../lib/intake-model.js";
|
|
17
|
+
import { collectValidation } from "../lib/validation-model.js";
|
|
11
18
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
19
|
|
|
13
20
|
// `intake` is the first half of the no-ceremony path (ADR 0005): store the
|
|
@@ -19,10 +26,47 @@ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
|
19
26
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
20
27
|
// adding a command never requires editing the entrypoint — the gap that
|
|
21
28
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
22
|
-
export const flags = { boolean: ["json", "force"], string: ["text"] };
|
|
29
|
+
export const flags = { boolean: ["json", "force", "converted"], string: ["text"] };
|
|
23
30
|
|
|
24
31
|
export async function run(positional, flags) {
|
|
25
32
|
const projectRoot = process.cwd();
|
|
33
|
+
|
|
34
|
+
// Close the bootstrap through the CLI, not by hand.
|
|
35
|
+
//
|
|
36
|
+
// Step 7 of the playbook used to read "flip Status to converted in
|
|
37
|
+
// .doctrina/intake.md" — the one place this framework told an agent to
|
|
38
|
+
// hand-author a metadata header, against its own standing rule, and the one
|
|
39
|
+
// header nothing read back. A mistyped word left `next` asking forever for
|
|
40
|
+
// a bootstrap that was already done.
|
|
41
|
+
if (flagBool(flags, "converted", false)) {
|
|
42
|
+
ensureDoctrinaProject(projectRoot);
|
|
43
|
+
// Converting is the claim that the bootstrap is done — step 6 of the
|
|
44
|
+
// playbook, "fix everything the gates report", made checkable (change
|
|
45
|
+
// 0194). It used to convert over a tree `validate` failed, so the specs
|
|
46
|
+
// became "the source of truth" while an error still said they were not
|
|
47
|
+
// well-formed. --force converts anyway, for a tree whose errors are not
|
|
48
|
+
// the bootstrap's to fix.
|
|
49
|
+
if (isFile(path.join(projectRoot, ".doctrina", "intake.md")) && !flagBool(flags, "force", false)) {
|
|
50
|
+
const { errors } = collectValidation(projectRoot);
|
|
51
|
+
if (errors.length > 0) {
|
|
52
|
+
console.error(c.red("error:") + ` the bootstrap is not done — \`doctrina validate\` reports ${errors.length} error${errors.length === 1 ? "" : "s"}:`);
|
|
53
|
+
for (const e of errors.slice(0, 5)) console.error(" - " + e);
|
|
54
|
+
if (errors.length > 5) console.error(` … and ${errors.length - 5} more`);
|
|
55
|
+
console.error(c.gray("hint: ") + "fix them (`doctrina validate --fix` heals index drift), then rerun; `--force` converts anyway");
|
|
56
|
+
return EXIT.GATE;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const written = markIntakeConverted(projectRoot);
|
|
60
|
+
if (!written) {
|
|
61
|
+
console.error(c.red("error:") + " no intake to mark converted at .doctrina/intake.md");
|
|
62
|
+
console.error(c.gray("hint: ") + 'store one first: `doctrina intake --text "<description>"`');
|
|
63
|
+
return EXIT.PRECONDITION;
|
|
64
|
+
}
|
|
65
|
+
console.log(c.green("converted") + ` ${relPath(projectRoot, written)} — specs are the source of truth now`);
|
|
66
|
+
console.log(c.gray("next: ") + 'doctrina next');
|
|
67
|
+
return EXIT.OK;
|
|
68
|
+
}
|
|
69
|
+
|
|
26
70
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
27
71
|
throw notADoctrinaProject();
|
|
28
72
|
}
|
|
@@ -58,12 +102,24 @@ export async function run(positional, flags) {
|
|
|
58
102
|
source = "inline (--text)";
|
|
59
103
|
} else {
|
|
60
104
|
const abs = path.resolve(projectRoot, sourceFile);
|
|
61
|
-
if (
|
|
105
|
+
if (isFile(abs)) {
|
|
106
|
+
body = read(abs);
|
|
107
|
+
source = relPath(projectRoot, abs);
|
|
108
|
+
} else if (looksLikePath(sourceFile)) {
|
|
62
109
|
console.error(c.red("error:") + ` description file not found: ${sourceFile}`);
|
|
110
|
+
console.error(c.gray("hint: ") +
|
|
111
|
+
'to pass the description itself, use `doctrina intake --text "<description>"`');
|
|
63
112
|
return 1;
|
|
113
|
+
} else {
|
|
114
|
+
// Prose, not a path (change 0071). The surface block says "store the
|
|
115
|
+
// intent", so passing the intent is what its wording invites; answering
|
|
116
|
+
// "description file not found:" and echoing the author's whole sentence
|
|
117
|
+
// back as a filename was the CLI blaming the reader for reading it.
|
|
118
|
+
body = sourceFile;
|
|
119
|
+
source = "inline";
|
|
120
|
+
console.error(c.gray("note: ") +
|
|
121
|
+
'read as the description itself, not as a file — `--text "<description>"` says so explicitly');
|
|
64
122
|
}
|
|
65
|
-
body = read(abs);
|
|
66
|
-
source = relPath(projectRoot, abs);
|
|
67
123
|
}
|
|
68
124
|
if (body.trim().length === 0) {
|
|
69
125
|
console.error(c.red("error:") + " the description is empty");
|
|
@@ -74,9 +130,19 @@ export async function run(positional, flags) {
|
|
|
74
130
|
console.error(c.red("error:") + ` ${relPath(projectRoot, intakePath)} already exists (pass --force to overwrite)`);
|
|
75
131
|
return 1;
|
|
76
132
|
}
|
|
133
|
+
// After conversion the specs are the only source of truth, and the intake
|
|
134
|
+
// is never edited to change requirements (change 0112). `--force` was the
|
|
135
|
+
// CLI-sanctioned way to do exactly that: it rewrote the file to `pending`
|
|
136
|
+
// and printed the bootstrap playbook as if the tree were empty. The door
|
|
137
|
+
// for new intent is `intent add`; for a change of behaviour, `work`.
|
|
138
|
+
if (exists(intakePath) && (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase() === "converted") {
|
|
139
|
+
console.error(c.red("error:") + ` ${relPath(projectRoot, intakePath)} is already converted — the specs are the source of truth now, and --force does not reopen it`);
|
|
140
|
+
console.error(c.gray("hint: ") + "new product intent: `doctrina intent add \"<text>\"` · a change of behaviour: `doctrina work \"<prompt>\"`");
|
|
141
|
+
return EXIT.PRECONDITION;
|
|
142
|
+
}
|
|
77
143
|
|
|
78
|
-
const
|
|
79
|
-
writeIntakeFile(projectRoot, { body, source, projectName, date: today(), force });
|
|
144
|
+
const name = projectName(projectRoot);
|
|
145
|
+
writeIntakeFile(projectRoot, { body, source, projectName: name, date: today(), force });
|
|
80
146
|
console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)}`);
|
|
81
147
|
console.log("");
|
|
82
148
|
warnIfThinIntake(body);
|
|
@@ -84,95 +150,6 @@ export async function run(positional, flags) {
|
|
|
84
150
|
return 0;
|
|
85
151
|
}
|
|
86
152
|
|
|
87
|
-
// Clarification gate (review Topic A). A description too thin to spec from is
|
|
88
|
-
// the moment to ask the user, not to let the agent invent requirements. Prints
|
|
89
|
-
// the specific gaps before the bootstrap playbook; advisory, never blocking —
|
|
90
|
-
// the intake is still captured verbatim (the playbook step 6 resolves it).
|
|
91
|
-
export function warnIfThinIntake(body) {
|
|
92
|
-
const assessment = assessBrief(body, { kind: "intake" });
|
|
93
|
-
if (!assessment.thin) return;
|
|
94
|
-
console.log(c.yellow("⚠ thin intake — clarify with the user before converting to specs:"));
|
|
95
|
-
for (const reason of assessment.reasons) console.log(` - ${reason}`);
|
|
96
|
-
console.log("");
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Shared with `init --intake`. Writes the intake verbatim under a small
|
|
100
|
-
// status header; the agent flips Status to converted at the end of the
|
|
101
|
-
// playbook, which is how `next` knows the bootstrap is done.
|
|
102
|
-
export function writeIntakeFile(projectRoot, { body, source, projectName, date, force = false }) {
|
|
103
|
-
const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
|
|
104
|
-
const content = [
|
|
105
|
-
`# Intake — ${projectName}`,
|
|
106
|
-
"",
|
|
107
|
-
"- **Status:** pending",
|
|
108
|
-
`- **Date:** ${date}`,
|
|
109
|
-
`- **Source:** ${source}`,
|
|
110
|
-
"",
|
|
111
|
-
"<!-- Raw project intent, stored verbatim. The bootstrap playbook",
|
|
112
|
-
" (doctrina intake) converts it into product.md and capability",
|
|
113
|
-
" specs, then flips Status to converted. After conversion the",
|
|
114
|
-
" specs are the only source of truth; never edit this file to",
|
|
115
|
-
" change requirements. -->",
|
|
116
|
-
"",
|
|
117
|
-
"---",
|
|
118
|
-
"",
|
|
119
|
-
body.replace(/\r\n/g, "\n").replace(/\n*$/, "\n"),
|
|
120
|
-
].join("\n");
|
|
121
|
-
write(intakePath, content, { force });
|
|
122
|
-
return intakePath;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Exported so `init --intake` can print the same playbook inline instead
|
|
126
|
-
// of bouncing the user to a second command.
|
|
127
|
-
export function printBootstrapPlaybook(projectRoot) {
|
|
128
|
-
const hasSpecs = exists(path.join(projectRoot, ".doctrina", "specs"));
|
|
129
|
-
console.log(c.bold("Bootstrap playbook") + c.gray(" — agent-executed (ADR 0005); the CLI does no interpretation."));
|
|
130
|
-
console.log("");
|
|
131
|
-
console.log("Execute in order, in a single linear pass:");
|
|
132
|
-
console.log("");
|
|
133
|
-
console.log(`1. Read ${c.cyan(".doctrina/intake.md")} (raw intent) and ${c.cyan(".doctrina/product.md")}.`);
|
|
134
|
-
console.log("");
|
|
135
|
-
console.log("2. Fill every product.md section from the intake: Vision, Problem,");
|
|
136
|
-
console.log(" Target users, Scope (in/out), Non-goals, Success criteria, and the");
|
|
137
|
-
console.log(" Delivery order — name the ONE end-to-end walking skeleton to build");
|
|
138
|
-
console.log(" and verify before fanning out (depth before breadth).");
|
|
139
|
-
console.log(" One fact, one home — product.md holds vision, never requirements.");
|
|
140
|
-
console.log("");
|
|
141
|
-
console.log("3. Derive the capability list: kebab-case, one per area of behaviour");
|
|
142
|
-
console.log(" (e.g. auth, billing, reports). For each capability:");
|
|
143
|
-
console.log(` ${c.cyan("doctrina spec new <capability>")}`);
|
|
144
|
-
console.log(" then replace the template placeholders in");
|
|
145
|
-
console.log(" .doctrina/specs/<capability>/spec.md with EARS requirements derived");
|
|
146
|
-
console.log(" from the intake (Ubiquitous / Event-driven / State-driven /");
|
|
147
|
-
console.log(" Unwanted-behavior / Optional). Keep the two axes honest — leave");
|
|
148
|
-
console.log(" Implementation: planned for what is not built yet; keep aspiration");
|
|
149
|
-
console.log(" under ## Maturity → Future, not in EARS; write concrete Acceptance");
|
|
150
|
-
console.log(" criteria, each [unverified] until a cited test proves it.");
|
|
151
|
-
console.log("");
|
|
152
|
-
console.log("4. If the project spans services/front-ends, own the seams between");
|
|
153
|
-
console.log(" them (port map, env, API/WS/event shapes) as a contract:");
|
|
154
|
-
console.log(` ${c.cyan("doctrina contract new <name>")} → fill it → ${c.cyan("doctrina contract check")}`);
|
|
155
|
-
console.log("");
|
|
156
|
-
console.log("5. Record any architectural decision the intake forces (cite Evidence):");
|
|
157
|
-
console.log(` ${c.cyan("doctrina decision new \"<title>\"")} → edit → ${c.cyan("doctrina decision accept <num>")}`);
|
|
158
|
-
console.log("");
|
|
159
|
-
console.log("6. Quality gates — fix everything they report before moving on:");
|
|
160
|
-
console.log(` ${c.cyan("doctrina clarify --all")}`);
|
|
161
|
-
console.log(` ${c.cyan("doctrina validate")}`);
|
|
162
|
-
console.log(` ${c.cyan("doctrina coverage")} (and ${c.cyan("doctrina verify --init")} to declare the build gate)`);
|
|
163
|
-
console.log(" If something in the intake is genuinely ambiguous, ask the user");
|
|
164
|
-
console.log(" before assuming.");
|
|
165
|
-
console.log("");
|
|
166
|
-
console.log("7. Mark the intake consumed: in .doctrina/intake.md flip");
|
|
167
|
-
console.log(" \"- **Status:** pending\" to \"- **Status:** converted\".");
|
|
168
|
-
console.log("");
|
|
169
|
-
console.log(`8. ${c.cyan("doctrina next")} — follow the recommendation. Implementation then`);
|
|
170
|
-
console.log(` flows through ${c.cyan("doctrina work \"<brief prompt>\"")}, one change at a time.`);
|
|
171
|
-
if (!hasSpecs) {
|
|
172
|
-
console.log("");
|
|
173
|
-
console.log(c.yellow("warn:") + " .doctrina/specs/ is missing — re-run `doctrina init` first.");
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
153
|
|
|
177
154
|
export const help = `
|
|
178
155
|
Usage: doctrina intake [<file>] [--text "<description>"] [--force]
|
|
@@ -189,11 +166,20 @@ Forms:
|
|
|
189
166
|
doctrina intake --text "<text>" Ingest an inline description
|
|
190
167
|
doctrina intake Reprint the playbook for the
|
|
191
168
|
existing pending intake
|
|
169
|
+
doctrina intake --converted Close the bootstrap: flip the stored
|
|
170
|
+
intake's Status to converted
|
|
192
171
|
|
|
193
172
|
Options:
|
|
194
173
|
--text "<description>" Inline description instead of a file
|
|
195
|
-
--force Overwrite an existing .doctrina/intake.md
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
174
|
+
--force Overwrite an existing .doctrina/intake.md; with
|
|
175
|
+
--converted, convert although validate fails
|
|
176
|
+
--converted Mark the stored intake converted (ends the
|
|
177
|
+
bootstrap; nothing else writes that header).
|
|
178
|
+
Refuses (exit 1) while validate reports an error
|
|
179
|
+
|
|
180
|
+
Close the bootstrap with \`doctrina intake --converted\` rather than editing
|
|
181
|
+
the header by hand: \`next\` branches on that value, and \`validate\` reports a
|
|
182
|
+
Status that is neither pending nor converted, because every other word read
|
|
183
|
+
as pending in silence. After conversion the specs are the only source of
|
|
184
|
+
truth.
|
|
199
185
|
`;
|
package/src/commands/intent.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
5
|
+
import { intentKey } from "../lib/validation-model.js";
|
|
5
6
|
import { c } from "../lib/colors.js";
|
|
6
7
|
import { suggest } from "../lib/suggest.js";
|
|
7
8
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
@@ -102,9 +103,21 @@ function intentAdd(projectRoot, textArg) {
|
|
|
102
103
|
let id = null;
|
|
103
104
|
let text = textArg;
|
|
104
105
|
const pinned = textArg.match(/^([A-Z]+)(\d+)\s*:\s*(.+)$/);
|
|
106
|
+
if (pinned) text = pinned[3].trim();
|
|
107
|
+
|
|
108
|
+
// The same intent twice is two anchors for one goal: realize either and the
|
|
109
|
+
// other stays "dropped", so `trace --strict` fails on a gap nobody can
|
|
110
|
+
// close. An agent re-running a playbook step is enough to do it. Compared
|
|
111
|
+
// folded, so case, accents and spacing do not slip a twin past.
|
|
112
|
+
const twin = anchors.find((a) => intentKey(a.text) === intentKey(text));
|
|
113
|
+
if (twin) {
|
|
114
|
+
console.error(c.red("error:") + ` [${twin.id}] already states this intent in product.md`);
|
|
115
|
+
console.error(c.gray("hint: ") + `realize [${twin.id}] with a spec's \`Realizes:\` header, or reword this one if it is a different goal`);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
105
119
|
if (pinned) {
|
|
106
120
|
id = pinned[1] + pinned[2];
|
|
107
|
-
text = pinned[3].trim();
|
|
108
121
|
if (anchors.some((a) => a.id === id)) {
|
|
109
122
|
console.error(c.red("error:") + ` anchor [${id}] already exists in product.md`);
|
|
110
123
|
return 1;
|
package/src/commands/metrics.js
CHANGED
|
@@ -1,28 +1,26 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
5
4
|
import { readdirSync } from "node:fs";
|
|
6
5
|
import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
7
6
|
import { today } from "../lib/dates.js";
|
|
8
7
|
import { flagBool, flagString } from "../lib/args.js";
|
|
9
8
|
import { c } from "../lib/colors.js";
|
|
10
9
|
import { EXIT } from "../lib/exit-codes.js";
|
|
11
|
-
import { GIT_STATE, historyState } from "../lib/git.js";
|
|
10
|
+
import { GIT_STATE, historyState, git } from "../lib/git.js";
|
|
12
11
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
12
|
import { USAGE_ENV, summarise } from "../lib/usage.js";
|
|
14
13
|
import { OPERATIONS } from "../lib/commands.js";
|
|
14
|
+
import { REEDIT_WINDOW_DAYS, sinceWindow, computeSnapshot, parseLog, readSeries, latestSnapshot, trend, round } from "../lib/metrics-model.js";
|
|
15
15
|
|
|
16
16
|
// Local-only adoption metrics derived from git history. No network calls,
|
|
17
17
|
// no telemetry: the numbers stay in the repository, versioned like any
|
|
18
18
|
// other artifact. This is the tooling half of docs/en/validation.md.
|
|
19
19
|
|
|
20
|
-
const REEDIT_WINDOW_DAYS = 21;
|
|
21
|
-
|
|
22
20
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
23
21
|
// adding a command never requires editing the entrypoint — the gap that
|
|
24
22
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
25
|
-
export const flags = { boolean: ["json", "save", "commands"], string: ["since"] };
|
|
23
|
+
export const flags = { boolean: ["json", "save", "commands", "trend"], string: ["since"] };
|
|
26
24
|
|
|
27
25
|
export async function run(_positional, flags) {
|
|
28
26
|
const projectRoot = process.cwd();
|
|
@@ -33,6 +31,20 @@ export async function run(_positional, flags) {
|
|
|
33
31
|
// the command surface are actually reached for (M8). Separate report,
|
|
34
32
|
// separate source, so it never dilutes the adoption metrics.
|
|
35
33
|
if (flagBool(flags, "commands", false)) return commandUsage();
|
|
34
|
+
// --trend reads the SERIES of saved snapshots rather than git. The
|
|
35
|
+
// snapshots are a time series versioned in the repository, and for two
|
|
36
|
+
// releases the only thing that read them was a delta against the most
|
|
37
|
+
// recent one (audit finding F15) — a year of measurement answering one
|
|
38
|
+
// question. Reading the series is what makes the saving worth doing.
|
|
39
|
+
if (flagBool(flags, "trend", false)) return printTrend(projectRoot);
|
|
40
|
+
// The window is the invocation's: refused the same with or without history.
|
|
41
|
+
const sinceRaw = flagString(flags, "since", "90");
|
|
42
|
+
const since = sinceWindow(sinceRaw);
|
|
43
|
+
if (since === null) {
|
|
44
|
+
console.error(c.red("error:") + ` --since expects a day count, a date (YYYY-MM-DD) or "<n> <unit>s ago", got "${sinceRaw}"`);
|
|
45
|
+
console.error(c.gray("hint: ") + "examples: --since 30 · --since 2026-01-01 · --since \"3 months ago\"");
|
|
46
|
+
return EXIT.USAGE;
|
|
47
|
+
}
|
|
36
48
|
const probe = git(projectRoot, ["rev-parse", "--is-inside-work-tree"]);
|
|
37
49
|
// "No history yet" is a VALID state, not a failure — a brand-new project
|
|
38
50
|
// is the most common state in which someone explores this command, and it
|
|
@@ -51,15 +63,13 @@ export async function run(_positional, flags) {
|
|
|
51
63
|
return EXIT.OK;
|
|
52
64
|
}
|
|
53
65
|
|
|
54
|
-
const sinceRaw = flagString(flags, "since", "90");
|
|
55
|
-
const since = /^\d+$/.test(sinceRaw) ? `${sinceRaw} days ago` : sinceRaw;
|
|
56
66
|
const save = flagBool(flags, "save", false);
|
|
57
67
|
|
|
58
68
|
const log = git(projectRoot, [
|
|
59
69
|
"log", `--since=${since}`, "--date=unix",
|
|
60
70
|
"--pretty=format:@@%H|%ct|%s", "--name-only",
|
|
61
71
|
]);
|
|
62
|
-
if (log.
|
|
72
|
+
if (log.state !== GIT_STATE.OK) {
|
|
63
73
|
console.error(c.red("error:") + ` git log failed: ${log.stderr.trim()}`);
|
|
64
74
|
return EXIT.ENVIRONMENT;
|
|
65
75
|
}
|
|
@@ -78,7 +88,7 @@ export async function run(_positional, flags) {
|
|
|
78
88
|
if (save) {
|
|
79
89
|
const dir = path.join(projectRoot, ".doctrina", "metrics");
|
|
80
90
|
const file = path.join(dir, `${today()}.json`);
|
|
81
|
-
const previous = latestSnapshot(
|
|
91
|
+
const previous = latestSnapshot(projectRoot, path.basename(file));
|
|
82
92
|
write(file, JSON.stringify(snapshot, null, 2) + "\n", { force: true });
|
|
83
93
|
console.log("");
|
|
84
94
|
console.log(c.green("saved") + ` ${relPath(projectRoot, file)}`);
|
|
@@ -93,49 +103,45 @@ export async function run(_positional, flags) {
|
|
|
93
103
|
return 0;
|
|
94
104
|
}
|
|
95
105
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
// The saved series, oldest first, with the direction each rate moved across
|
|
107
|
+
// the whole span. No verdict attached: a revert rate moves with team size,
|
|
108
|
+
// release cadence and how the window was drawn, and the CLI cannot tell an
|
|
109
|
+
// improvement from a quieter quarter.
|
|
110
|
+
function printTrend(projectRoot) {
|
|
111
|
+
const series = readSeries(projectRoot);
|
|
112
|
+
console.log(c.bold("Doctrina metrics") + c.gray(" — saved snapshots (.doctrina/metrics/)"));
|
|
113
|
+
console.log("");
|
|
114
|
+
if (series.length === 0) {
|
|
115
|
+
console.log(c.gray("no saved snapshots yet — `doctrina metrics --save` writes the first."));
|
|
116
|
+
return 0;
|
|
105
117
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
// already touched in the previous REEDIT_WINDOW_DAYS. This is a proxy
|
|
113
|
-
// for rework, not a verdict — iterative work also trips it.
|
|
114
|
-
const windowSec = REEDIT_WINDOW_DAYS * 86400;
|
|
115
|
-
const lastTouch = new Map();
|
|
116
|
-
let reedits = 0;
|
|
117
|
-
for (const cm of [...commits].sort((a, b) => a.ts - b.ts)) {
|
|
118
|
-
const isReedit = cm.files.some((f) => {
|
|
119
|
-
const prev = lastTouch.get(f);
|
|
120
|
-
return prev !== undefined && cm.ts - prev <= windowSec;
|
|
121
|
-
});
|
|
122
|
-
if (isReedit) reedits += 1;
|
|
123
|
-
for (const f of cm.files) lastTouch.set(f, cm.ts);
|
|
118
|
+
console.log(c.gray("date commits reverts fix share re-edit"));
|
|
119
|
+
for (const snap of series) {
|
|
120
|
+
console.log(
|
|
121
|
+
`${String(snap.generated).padEnd(12)} ${String(snap.commits ?? "—").padStart(7)} ` +
|
|
122
|
+
`${pct(snap.revert_rate).padStart(7)} ${pct(snap.fix_share).padStart(9)} ${pct(snap.reedit_rate).padStart(7)}`,
|
|
123
|
+
);
|
|
124
124
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
125
|
+
const t = trend(series);
|
|
126
|
+
if (t.rows.length === 0) {
|
|
127
|
+
console.log("");
|
|
128
|
+
console.log(c.gray(`one snapshot — a trend needs two. Save another with \`doctrina metrics --save\`.`));
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
console.log("");
|
|
132
|
+
console.log(c.bold(`Across ${t.span} snapshots (${t.from} → ${t.to}):`));
|
|
133
|
+
for (const row of t.rows) printDelta(LABELS[row.rate] ?? row.rate, row.first, row.last);
|
|
134
|
+
console.log("");
|
|
135
|
+
console.log(c.gray("A direction, not a verdict: these rates move with team size and cadence."));
|
|
136
|
+
return 0;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
const LABELS = {
|
|
140
|
+
revert_rate: "revert rate",
|
|
141
|
+
fix_share: "fix share",
|
|
142
|
+
reedit_rate: `${REEDIT_WINDOW_DAYS}-day re-edit rate`,
|
|
143
|
+
};
|
|
144
|
+
|
|
139
145
|
function printReport(s) {
|
|
140
146
|
console.log(`commits ${s.commits}`);
|
|
141
147
|
console.log(`reverts ${s.reverts} (${pct(s.revert_rate)})`);
|
|
@@ -158,50 +164,16 @@ function printDelta(label, before, after) {
|
|
|
158
164
|
console.log(` ${label}: ${pct(before)} → ${pct(after)} (${arrow})`);
|
|
159
165
|
}
|
|
160
166
|
|
|
161
|
-
function latestSnapshot(dir, excludeBase) {
|
|
162
|
-
if (!isDir(dir)) return null;
|
|
163
|
-
const files = readdirSync(dir)
|
|
164
|
-
.filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f) && f !== excludeBase)
|
|
165
|
-
.sort();
|
|
166
|
-
if (files.length === 0) return null;
|
|
167
|
-
try {
|
|
168
|
-
return JSON.parse(read(path.join(dir, files[files.length - 1])));
|
|
169
|
-
} catch {
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function parseLog(stdout) {
|
|
175
|
-
const commits = [];
|
|
176
|
-
let current = null;
|
|
177
|
-
for (const line of stdout.split("\n")) {
|
|
178
|
-
if (line.startsWith("@@")) {
|
|
179
|
-
const [hash, ts, ...rest] = line.slice(2).split("|");
|
|
180
|
-
current = { hash, ts: Number(ts), subject: rest.join("|"), files: [] };
|
|
181
|
-
commits.push(current);
|
|
182
|
-
} else if (line.trim() !== "" && current) {
|
|
183
|
-
current.files.push(line.trim());
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
return commits;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function git(cwd, args) {
|
|
190
|
-
return spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function round(x) {
|
|
194
|
-
return Math.round(x * 1000) / 1000;
|
|
195
|
-
}
|
|
196
167
|
|
|
197
168
|
function pct(x) {
|
|
169
|
+
if (typeof x !== "number") return "—";
|
|
198
170
|
return `${(x * 100).toFixed(1)}%`;
|
|
199
171
|
}
|
|
200
172
|
|
|
201
173
|
// Which operations were actually invoked, from the opt-in usage log. The
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
174
|
+
// audit's charge is that some of the surface exists because it was easy to
|
|
175
|
+
// add rather than because anyone reaches for it (how big that surface is,
|
|
176
|
+
// lib/commands.js owns — a count copied into a comment only rots). This is the evidence for that argument — or against it. Zero samples
|
|
205
177
|
// is a CANDIDATE for cutting, never a verdict: a command used once a quarter
|
|
206
178
|
// and a command nobody wants look identical over one week.
|
|
207
179
|
function commandUsage() {
|
|
@@ -252,10 +224,15 @@ Flags:
|
|
|
252
224
|
opt-in usage log. Recording is OFF unless DOCTRINA_USAGE_LOG
|
|
253
225
|
names a file; only the operation is recorded, never
|
|
254
226
|
arguments, and nothing leaves the machine.
|
|
255
|
-
--since <n|date> Window: a day count (default 90)
|
|
256
|
-
|
|
227
|
+
--since <n|date> Window: a day count (default 90), a calendar date
|
|
228
|
+
("2026-01-01") or "<n> <unit>s ago" ("3 months ago").
|
|
229
|
+
Anything else is refused: git reads any text as a
|
|
230
|
+
date, so a typo would measure the wrong window.
|
|
257
231
|
--save Write .doctrina/metrics/YYYY-MM-DD.json and print the
|
|
258
232
|
deltas against the most recent prior snapshot.
|
|
233
|
+
--trend Read the saved snapshots instead of git: every snapshot
|
|
234
|
+
in date order, and the direction each rate moved across
|
|
235
|
+
the whole span (first to last, not last to previous).
|
|
259
236
|
|
|
260
237
|
This is the tooling half of the empirical A/B protocol in
|
|
261
238
|
docs/en/validation.md: snapshot before adopting Doctrina, snapshot each
|