doctrina-cli 0.12.0 → 0.14.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 +3 -2
- package/src/commands/adapter.js +205 -0
- package/src/commands/analyze.js +76 -19
- package/src/commands/change.js +341 -44
- package/src/commands/clarify.js +40 -19
- package/src/commands/close.js +119 -11
- package/src/commands/completion.js +6 -0
- package/src/commands/constitution.js +8 -1
- package/src/commands/context.js +359 -53
- package/src/commands/contract.js +17 -24
- package/src/commands/coverage.js +16 -4
- package/src/commands/decision.js +172 -40
- package/src/commands/doctor.js +38 -7
- package/src/commands/handoff.js +8 -1
- package/src/commands/hooks.js +6 -0
- package/src/commands/index-rebuild.js +8 -1
- package/src/commands/init.js +152 -6
- package/src/commands/intake.js +11 -2
- package/src/commands/intent.js +8 -1
- package/src/commands/metrics.js +80 -7
- package/src/commands/next.js +14 -2
- package/src/commands/prime.js +8 -1
- package/src/commands/report.js +8 -1
- package/src/commands/review.js +8 -1
- package/src/commands/search.js +8 -1
- package/src/commands/show.js +8 -1
- package/src/commands/skill.js +16 -6
- package/src/commands/spec.js +13 -6
- package/src/commands/status.js +14 -2
- package/src/commands/templates.js +277 -40
- package/src/commands/trace.js +15 -3
- package/src/commands/upgrade.js +23 -12
- package/src/commands/validate.js +93 -3
- package/src/commands/verify.js +12 -2
- package/src/commands/watch.js +8 -1
- package/src/commands/why.js +10 -24
- package/src/commands/work.js +69 -7
- package/src/index.js +60 -6
- package/src/lib/adapters.js +140 -0
- package/src/lib/adr-guard.js +47 -0
- package/src/lib/args.js +10 -0
- package/src/lib/clarity.js +1 -0
- package/src/lib/colors.js +1 -0
- package/src/lib/commands.js +314 -3
- package/src/lib/criteria.js +1 -0
- package/src/lib/dates.js +1 -0
- package/src/lib/diff.js +1 -0
- package/src/lib/doc-model.js +287 -0
- package/src/lib/docs-impact.js +167 -0
- package/src/lib/ears.js +1 -0
- package/src/lib/exit-codes.js +103 -0
- package/src/lib/flag-catalog.js +106 -0
- package/src/lib/fs-ops.js +1 -0
- package/src/lib/gates.js +123 -0
- package/src/lib/git.js +97 -0
- package/src/lib/index-json.js +14 -6
- package/src/lib/json-out.js +83 -0
- package/src/lib/prompt.js +18 -1
- package/src/lib/scan.js +81 -33
- package/src/lib/spec-ops.js +228 -3
- package/src/lib/suggest.js +1 -0
- package/src/lib/templates.js +89 -0
- package/src/lib/usage.js +89 -0
- package/src/lib/version.js +1 -0
- package/templates/.gitkeep +0 -0
- package/templates/AGENTS.md.template +71 -75
- package/templates/change/spec-delta.md.template +13 -7
package/src/commands/init.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
4
5
|
import { locateTemplatesDir, loadTemplateTree, materialiseEntry, substitute } from "../lib/templates.js";
|
|
6
|
+
import {
|
|
7
|
+
surfaceBlock, findSurfaceBlock, agentChangelogBlock, findAgentChangelogBlock,
|
|
8
|
+
} from "../lib/commands.js";
|
|
9
|
+
import * as idx from "../lib/index-json.js";
|
|
5
10
|
import { today } from "../lib/dates.js";
|
|
6
11
|
import { cliVersion } from "../lib/version.js";
|
|
7
12
|
import { flagBool, flagString } from "../lib/args.js";
|
|
8
13
|
import { c } from "../lib/colors.js";
|
|
9
|
-
import {
|
|
14
|
+
import { EXIT } from "../lib/exit-codes.js";
|
|
15
|
+
import { ask, isInteractive } from "../lib/prompt.js";
|
|
10
16
|
import { writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake } from "./intake.js";
|
|
11
17
|
|
|
12
18
|
const SUPPORTED_AGENTS = [
|
|
@@ -24,6 +30,11 @@ const SUPPORTED_AGENTS = [
|
|
|
24
30
|
"jules",
|
|
25
31
|
];
|
|
26
32
|
|
|
33
|
+
export const flags = {
|
|
34
|
+
boolean: ["json", "force", "non-interactive", "overwrite-content"],
|
|
35
|
+
string: ["project-name", "project-description", "agent", "intake", "from", "date"],
|
|
36
|
+
};
|
|
37
|
+
|
|
27
38
|
export async function run(_positional, flags) {
|
|
28
39
|
const force = flagBool(flags, "force", false);
|
|
29
40
|
const nonInteractive = flagBool(flags, "non-interactive", false);
|
|
@@ -52,8 +63,21 @@ export async function run(_positional, flags) {
|
|
|
52
63
|
if (!description && intakeBody) {
|
|
53
64
|
description = firstLineSummary(intakeBody);
|
|
54
65
|
}
|
|
66
|
+
// The description prompt used to run unconditionally, without the
|
|
67
|
+
// isTTY check the adapter prompt already had. A non-interactive caller
|
|
68
|
+
// that forgot --non-interactive read EOF, got an empty answer, and
|
|
69
|
+
// scaffolded a project with a blank description — silently (C9).
|
|
55
70
|
if (!description && !nonInteractive) {
|
|
56
|
-
|
|
71
|
+
if (isInteractive()) {
|
|
72
|
+
description = await ask("One-sentence project description:");
|
|
73
|
+
}
|
|
74
|
+
if (!description) {
|
|
75
|
+
console.error(c.red("error:") + " no project description given and no terminal to ask on");
|
|
76
|
+
console.error(c.gray("hint: ") +
|
|
77
|
+
"pass --project-description \"<text>\" (or --intake <file>); " +
|
|
78
|
+
"--non-interactive alone still needs one of those");
|
|
79
|
+
return EXIT.USAGE;
|
|
80
|
+
}
|
|
57
81
|
}
|
|
58
82
|
|
|
59
83
|
let agentSelector = flagString(flags, "agent");
|
|
@@ -61,7 +85,7 @@ export async function run(_positional, flags) {
|
|
|
61
85
|
// terminal, offer the adapter install instead of silently skipping it —
|
|
62
86
|
// first-run users rarely know the flag exists. Never fires in pipes/CI
|
|
63
87
|
// (no TTY) or under --non-interactive, so scripted init is unchanged.
|
|
64
|
-
if (agentSelector === undefined && !nonInteractive &&
|
|
88
|
+
if (agentSelector === undefined && !nonInteractive && isInteractive()) {
|
|
65
89
|
const answer = await ask(
|
|
66
90
|
`Install an agent adapter? (${SUPPORTED_AGENTS.join("/")}/all/none)`,
|
|
67
91
|
{ defaultValue: "none" },
|
|
@@ -74,7 +98,9 @@ export async function run(_positional, flags) {
|
|
|
74
98
|
|
|
75
99
|
if (!force && (exists(agentsMdPath) || exists(doctrinaDir))) {
|
|
76
100
|
console.error(c.red("error:") + " AGENTS.md or .doctrina/ already exists at this path");
|
|
77
|
-
console.error(c.gray("hint: ") +
|
|
101
|
+
console.error(c.gray("hint: ") +
|
|
102
|
+
"to add an agent adapter use `doctrina adapter add <name>` (additive); " +
|
|
103
|
+
"to re-scaffold, pass --force");
|
|
78
104
|
return 1;
|
|
79
105
|
}
|
|
80
106
|
|
|
@@ -108,10 +134,46 @@ export async function run(_positional, flags) {
|
|
|
108
134
|
FRAMEWORK_VERSION: cliVersion(),
|
|
109
135
|
};
|
|
110
136
|
|
|
111
|
-
// Root AGENTS.md — with optional --from base content prepended.
|
|
137
|
+
// Root AGENTS.md — with optional --from base content prepended. The
|
|
138
|
+
// doctrina:surface block is regenerated from the installed catalog at init
|
|
139
|
+
// time, so a freshly scaffolded hub can never document a stale surface even
|
|
140
|
+
// if the static template lags a release (ADR 0015).
|
|
112
141
|
const agentsTemplatePath = path.join(templatesDir, "AGENTS.md.template");
|
|
113
142
|
const agentsTemplateBody = read(agentsTemplatePath);
|
|
114
|
-
const projectAgents = substitute(agentsTemplateBody, tokens);
|
|
143
|
+
const projectAgents = refreshSurfaceBlock(substitute(agentsTemplateBody, tokens));
|
|
144
|
+
|
|
145
|
+
// --force used to mean "overwrite everything", which made it a content
|
|
146
|
+
// shredder: the only way to add an adapter to an existing project was
|
|
147
|
+
// `init --agent X --force`, and that replaced AGENTS.md and product.md
|
|
148
|
+
// with blank templates — every hand-authored rule and the whole product
|
|
149
|
+
// definition, gone, exit 0, no warning (audit item C1).
|
|
150
|
+
//
|
|
151
|
+
// --force now re-scaffolds only what is still pristine. Overwriting
|
|
152
|
+
// AUTHORED content takes a second, explicit opt-in, because no flag whose
|
|
153
|
+
// documented meaning is "overwrite existing files" should be able to
|
|
154
|
+
// destroy the two documents the framework calls its sources of truth.
|
|
155
|
+
const overwriteContent = flagBool(flags, "overwrite-content", false);
|
|
156
|
+
if (force && !overwriteContent) {
|
|
157
|
+
const productTemplate = loadProductTemplate(templatesDir);
|
|
158
|
+
const authored = [];
|
|
159
|
+
if (isFile(agentsMdPath) && isAuthored(read(agentsMdPath), agentsTemplateBody)) {
|
|
160
|
+
authored.push("AGENTS.md");
|
|
161
|
+
}
|
|
162
|
+
const productPath = path.join(doctrinaDir, "product.md");
|
|
163
|
+
if (productTemplate !== null && isFile(productPath) && isAuthored(read(productPath), productTemplate)) {
|
|
164
|
+
authored.push(".doctrina/product.md");
|
|
165
|
+
}
|
|
166
|
+
if (authored.length > 0) {
|
|
167
|
+
console.error(c.red("error:") + " --force would overwrite content you wrote:");
|
|
168
|
+
for (const f of authored) console.error(` - ${f}`);
|
|
169
|
+
console.error("");
|
|
170
|
+
console.error(c.gray("hint: ") + "to add an agent adapter, use the additive command instead:");
|
|
171
|
+
console.error(` ${c.cyan("doctrina adapter add <name>")}`);
|
|
172
|
+
console.error(c.gray("If you really mean to discard the content above, re-run with ") +
|
|
173
|
+
c.cyan("--overwrite-content") + c.gray("."));
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
115
177
|
const finalAgents = fromAgentsContent
|
|
116
178
|
? fromAgentsContent.replace(/\n+$/, "") + "\n\n---\n\n## Project-specific\n\n" + projectAgents
|
|
117
179
|
: projectAgents;
|
|
@@ -136,6 +198,18 @@ export async function run(_positional, flags) {
|
|
|
136
198
|
console.log(c.green("created") + ` ${relPath(projectRoot, dest)} (with --from base)`);
|
|
137
199
|
continue;
|
|
138
200
|
}
|
|
201
|
+
// index.json is written from the ONE schema definition
|
|
202
|
+
// (`lib/index-json.js`), not from a template that could drift from it.
|
|
203
|
+
// A template copy is how a project came to be born already needing a
|
|
204
|
+
// scaffold update: `contracts` was added to what `templates check`
|
|
205
|
+
// requires and not to what `init` wrote (audit item C5).
|
|
206
|
+
if (entry.relativePath === "index.json.template") {
|
|
207
|
+
const dest = path.join(doctrinaDir, "index.json");
|
|
208
|
+
const fresh = idx.blank(projectName, date);
|
|
209
|
+
write(dest, JSON.stringify(fresh, null, 2) + "\n", { force });
|
|
210
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, dest)}`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
139
213
|
const written = materialiseEntry(entry, doctrinaDir, tokens, { force });
|
|
140
214
|
console.log(c.green("created") + ` ${relPath(projectRoot, written)}`);
|
|
141
215
|
}
|
|
@@ -178,6 +252,78 @@ export async function run(_positional, flags) {
|
|
|
178
252
|
return 0;
|
|
179
253
|
}
|
|
180
254
|
|
|
255
|
+
// The RAW product.md template (tokens unsubstituted), or null when it
|
|
256
|
+
// cannot be located — in which case the authored check errs toward
|
|
257
|
+
// refusing nothing rather than guessing.
|
|
258
|
+
function loadProductTemplate(templatesDir) {
|
|
259
|
+
const p = path.join(templatesDir, "doctrina", "product.md.template");
|
|
260
|
+
if (!isFile(p)) return null;
|
|
261
|
+
return read(p);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Does this file carry content someone wrote, as opposed to the pristine
|
|
265
|
+
// scaffold?
|
|
266
|
+
//
|
|
267
|
+
// Compared by SHAPE against the raw template, not against a rendered copy:
|
|
268
|
+
// a re-init under a different --project-name substitutes different values
|
|
269
|
+
// into the same scaffold, and comparing rendered text would read that as
|
|
270
|
+
// authorship and refuse a legitimate re-scaffold. Each `{{TOKEN}}` becomes
|
|
271
|
+
// a wildcard, so any project name matches while any added prose does not.
|
|
272
|
+
// Volatile and CLI-owned regions are dropped from both sides first: the
|
|
273
|
+
// date line changes on its own and the doctrina:surface block is
|
|
274
|
+
// regenerated by `upgrade`, so neither is evidence of authorship.
|
|
275
|
+
function isAuthored(existing, rawTemplate) {
|
|
276
|
+
const body = normaliseScaffold(existing);
|
|
277
|
+
const shape = normaliseScaffold(rawTemplate)
|
|
278
|
+
.split(/\{\{[A-Z0-9_]+\}\}/)
|
|
279
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
280
|
+
.join("[\\s\\S]*?");
|
|
281
|
+
return !new RegExp(`^${shape}$`).test(body);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function normaliseScaffold(text) {
|
|
285
|
+
return text
|
|
286
|
+
.replace(/\r\n/g, "\n")
|
|
287
|
+
// Both CLI-owned blocks are generated, not authored: the surface block
|
|
288
|
+
// is regenerated by `upgrade`, and the changed block is version-specific
|
|
289
|
+
// and written at init. Neither is evidence that a human touched the file.
|
|
290
|
+
.replace(/<!--\s*doctrina:surface:begin[\s\S]*?doctrina:surface:end\s*-->/g, "")
|
|
291
|
+
.replace(/<!--\s*doctrina:changed:begin[\s\S]*?doctrina:changed:end\s*-->/g, "")
|
|
292
|
+
.split("\n")
|
|
293
|
+
.filter((line) => !/^\*\*Last updated:\*\*|^-\s+\*\*Date:\*\*/.test(line.trim()))
|
|
294
|
+
.map((line) => line.replace(/\s+$/, ""))
|
|
295
|
+
.join("\n")
|
|
296
|
+
// Removing a generated block leaves the blank lines that surrounded it,
|
|
297
|
+
// and how many depends on where it was inserted. Collapse runs so the
|
|
298
|
+
// shape comparison is about CONTENT, not about spacing the CLI itself
|
|
299
|
+
// produced.
|
|
300
|
+
.replace(/\n{2,}/g, "\n\n")
|
|
301
|
+
.trim();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Replace the template's doctrina:surface span with the one generated from
|
|
305
|
+
// the running CLI's catalog; pass the text through untouched when the
|
|
306
|
+
// template carries no markers (older custom templates).
|
|
307
|
+
function refreshSurfaceBlock(text) {
|
|
308
|
+
const block = findSurfaceBlock(text);
|
|
309
|
+
if (!block) return text;
|
|
310
|
+
let out = text.slice(0, block.start) + surfaceBlock() + text.slice(block.end);
|
|
311
|
+
|
|
312
|
+
// The agent-facing changelog is version-specific, so it is GENERATED at
|
|
313
|
+
// init like the surface block — not carried in the template. Writing it
|
|
314
|
+
// here is what keeps a fresh project from being born already needing a
|
|
315
|
+
// `templates update`, the same invariant C5 established for index.json.
|
|
316
|
+
const changed = agentChangelogBlock(cliVersion());
|
|
317
|
+
const existing = findAgentChangelogBlock(out);
|
|
318
|
+
if (existing) {
|
|
319
|
+
out = out.slice(0, existing.start) + changed + out.slice(existing.end);
|
|
320
|
+
} else if (changed) {
|
|
321
|
+
const sb = findSurfaceBlock(out);
|
|
322
|
+
if (sb) out = out.slice(0, sb.end) + "\n\n" + changed + out.slice(sb.end);
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
181
327
|
// First non-empty line of the intake, stripped of Markdown heading marks
|
|
182
328
|
// and clipped, as the one-line description token for the templates.
|
|
183
329
|
function firstLineSummary(text) {
|
package/src/commands/intake.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
@@ -7,6 +8,7 @@ import * as idx from "../lib/index-json.js";
|
|
|
7
8
|
import { today } from "../lib/dates.js";
|
|
8
9
|
import { flagBool, flagString } from "../lib/args.js";
|
|
9
10
|
import { c } from "../lib/colors.js";
|
|
11
|
+
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
10
12
|
|
|
11
13
|
// `intake` is the first half of the no-ceremony path (ADR 0005): store the
|
|
12
14
|
// user's FULL project description verbatim, then print the bootstrap
|
|
@@ -14,10 +16,15 @@ import { c } from "../lib/colors.js";
|
|
|
14
16
|
// and capability specs. The CLI does no interpretation — it scaffolds and
|
|
15
17
|
// instructs; the agent thinks.
|
|
16
18
|
|
|
19
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
20
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
21
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
22
|
+
export const flags = { boolean: ["json", "force"], string: ["text"] };
|
|
23
|
+
|
|
17
24
|
export async function run(positional, flags) {
|
|
18
25
|
const projectRoot = process.cwd();
|
|
19
26
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
20
|
-
throw
|
|
27
|
+
throw notADoctrinaProject();
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
const force = flagBool(flags, "force", false);
|
|
@@ -30,7 +37,9 @@ export async function run(positional, flags) {
|
|
|
30
37
|
if (!isFile(intakePath)) {
|
|
31
38
|
console.error(c.red("error:") + " no intake found and no description given");
|
|
32
39
|
console.error(c.gray("hint: ") + "run `doctrina intake <file>` or `doctrina intake --text \"<description>\"`");
|
|
33
|
-
|
|
40
|
+
// PRECONDITION: nothing is wrong with the work; the project has no
|
|
41
|
+
// intake yet. The remedy is a setup command, not another attempt.
|
|
42
|
+
return EXIT.PRECONDITION;
|
|
34
43
|
}
|
|
35
44
|
const status = (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase();
|
|
36
45
|
if (status === "converted") {
|
package/src/commands/intent.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
4
5
|
import { c } from "../lib/colors.js";
|
|
5
6
|
import { suggest } from "../lib/suggest.js";
|
|
7
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
6
8
|
|
|
7
9
|
// Post-intake intent evolution (0.11.0 field review item 8). The intake is
|
|
8
10
|
// converted once; capabilities born later — brainstorms, pivots, new asks —
|
|
@@ -17,6 +19,11 @@ import { suggest } from "../lib/suggest.js";
|
|
|
17
19
|
|
|
18
20
|
const SUBCOMMANDS = ["add", "list"];
|
|
19
21
|
|
|
22
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
23
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
24
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
25
|
+
export const flags = { boolean: ["json"], string: [] };
|
|
26
|
+
|
|
20
27
|
export async function run(positional, _flags) {
|
|
21
28
|
const sub = positional[0];
|
|
22
29
|
if (!SUBCOMMANDS.includes(sub)) {
|
|
@@ -29,7 +36,7 @@ export async function run(positional, _flags) {
|
|
|
29
36
|
}
|
|
30
37
|
const projectRoot = process.cwd();
|
|
31
38
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
32
|
-
throw
|
|
39
|
+
throw notADoctrinaProject();
|
|
33
40
|
}
|
|
34
41
|
if (sub === "list") return intentList(projectRoot);
|
|
35
42
|
return intentAdd(projectRoot, positional.slice(1).join(" ").trim());
|
package/src/commands/metrics.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { spawnSync } from "node:child_process";
|
|
4
5
|
import { readdirSync } from "node:fs";
|
|
5
|
-
import { exists, isDir, read, relPath, write } from "../lib/fs-ops.js";
|
|
6
|
+
import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
6
7
|
import { today } from "../lib/dates.js";
|
|
7
8
|
import { flagBool, flagString } from "../lib/args.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
10
|
+
import { EXIT } from "../lib/exit-codes.js";
|
|
11
|
+
import { GIT_STATE, historyState } from "../lib/git.js";
|
|
12
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
|
+
import { USAGE_ENV, summarise } from "../lib/usage.js";
|
|
14
|
+
import { OPERATIONS } from "../lib/commands.js";
|
|
9
15
|
|
|
10
16
|
// Local-only adoption metrics derived from git history. No network calls,
|
|
11
17
|
// no telemetry: the numbers stay in the repository, versioned like any
|
|
@@ -13,15 +19,36 @@ import { c } from "../lib/colors.js";
|
|
|
13
19
|
|
|
14
20
|
const REEDIT_WINDOW_DAYS = 21;
|
|
15
21
|
|
|
22
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
23
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
24
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
25
|
+
export const flags = { boolean: ["json", "save", "commands"], string: ["since"] };
|
|
26
|
+
|
|
16
27
|
export async function run(_positional, flags) {
|
|
17
28
|
const projectRoot = process.cwd();
|
|
18
29
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
19
|
-
throw
|
|
30
|
+
throw notADoctrinaProject();
|
|
20
31
|
}
|
|
32
|
+
// --commands reads the usage log rather than git history: which parts of
|
|
33
|
+
// the command surface are actually reached for (M8). Separate report,
|
|
34
|
+
// separate source, so it never dilutes the adoption metrics.
|
|
35
|
+
if (flagBool(flags, "commands", false)) return commandUsage();
|
|
21
36
|
const probe = git(projectRoot, ["rev-parse", "--is-inside-work-tree"]);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
37
|
+
// "No history yet" is a VALID state, not a failure — a brand-new project
|
|
38
|
+
// is the most common state in which someone explores this command, and it
|
|
39
|
+
// used to answer with a raw git plumbing error (C8). git missing entirely
|
|
40
|
+
// is the one genuine ENVIRONMENT condition here.
|
|
41
|
+
const history = historyState(projectRoot);
|
|
42
|
+
if (!history.usable) {
|
|
43
|
+
if (history.state === GIT_STATE.ABSENT) {
|
|
44
|
+
console.error(c.red("error:") + ` ${history.reason} — metrics are derived from local git history`);
|
|
45
|
+
return EXIT.ENVIRONMENT;
|
|
46
|
+
}
|
|
47
|
+
console.log(c.bold("Doctrina metrics") + c.gray(" (local git history)"));
|
|
48
|
+
console.log("");
|
|
49
|
+
console.log(c.gray(`no history to measure yet — ${history.reason}.`));
|
|
50
|
+
console.log(c.gray("Commit some work and run this again."));
|
|
51
|
+
return EXIT.OK;
|
|
25
52
|
}
|
|
26
53
|
|
|
27
54
|
const sinceRaw = flagString(flags, "since", "90");
|
|
@@ -34,7 +61,7 @@ export async function run(_positional, flags) {
|
|
|
34
61
|
]);
|
|
35
62
|
if (log.status !== 0) {
|
|
36
63
|
console.error(c.red("error:") + ` git log failed: ${log.stderr.trim()}`);
|
|
37
|
-
return
|
|
64
|
+
return EXIT.ENVIRONMENT;
|
|
38
65
|
}
|
|
39
66
|
|
|
40
67
|
const commits = parseLog(log.stdout);
|
|
@@ -171,14 +198,60 @@ function pct(x) {
|
|
|
171
198
|
return `${(x * 100).toFixed(1)}%`;
|
|
172
199
|
}
|
|
173
200
|
|
|
201
|
+
// Which operations were actually invoked, from the opt-in usage log. The
|
|
202
|
+
// surface is 36 commands and 59 operations; the audit's charge is that some
|
|
203
|
+
// exist because they were easy to add rather than because anyone reaches for
|
|
204
|
+
// them. This is the evidence for that argument — or against it. Zero samples
|
|
205
|
+
// is a CANDIDATE for cutting, never a verdict: a command used once a quarter
|
|
206
|
+
// and a command nobody wants look identical over one week.
|
|
207
|
+
function commandUsage() {
|
|
208
|
+
const target = process.env[USAGE_ENV];
|
|
209
|
+
if (!target) {
|
|
210
|
+
console.log(c.bold("Command usage") + c.gray(" — not recording"));
|
|
211
|
+
console.log("");
|
|
212
|
+
console.log(c.gray("Instrumentation is off unless you ask for it. To start a sample:"));
|
|
213
|
+
console.log(" " + c.cyan(`export ${USAGE_ENV}=$PWD/.doctrina/usage.jsonl`));
|
|
214
|
+
console.log("");
|
|
215
|
+
console.log(c.gray("One local append-only file. The operation only — never arguments,"));
|
|
216
|
+
console.log(c.gray("paths, ids or prompts. No network calls, here or anywhere in the CLI."));
|
|
217
|
+
console.log(c.gray("Run three real changes through it, then come back."));
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
if (!isFile(target)) {
|
|
221
|
+
console.log(c.bold("Command usage") + c.gray(` — ${USAGE_ENV} set, no samples yet`));
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
const catalog = OPERATIONS.map((o) => o[0]);
|
|
225
|
+
const { samples, used, unused } = summarise(read(target), catalog);
|
|
226
|
+
console.log(c.bold("Command usage") + c.gray(` — ${samples} invocation${samples === 1 ? "" : "s"} recorded`));
|
|
227
|
+
console.log("");
|
|
228
|
+
for (const [op, n] of used) {
|
|
229
|
+
console.log(` ${String(n).padStart(5)} ${c.cyan(op)}`);
|
|
230
|
+
}
|
|
231
|
+
console.log("");
|
|
232
|
+
console.log(c.gray(`${unused.length} of ${catalog.length} operations never invoked in this sample:`));
|
|
233
|
+
for (let i = 0; i < unused.length; i += 4) {
|
|
234
|
+
console.log(" " + c.gray(unused.slice(i, i + 4).map((o) => o.padEnd(22)).join("")));
|
|
235
|
+
}
|
|
236
|
+
console.log("");
|
|
237
|
+
console.log(c.gray("Never-invoked is a question, not an answer. Before cutting one, ask"));
|
|
238
|
+
console.log(c.gray("whether it went unused because nobody wants it or because nobody"));
|
|
239
|
+
console.log(c.gray("could find it — the second is what AGENTS.md discovery fixed in 0.13."));
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
174
243
|
export const help = `
|
|
175
|
-
Usage: doctrina metrics [--since <days|date>] [--save]
|
|
244
|
+
Usage: doctrina metrics [--since <days|date>] [--save] [--commands]
|
|
176
245
|
|
|
177
246
|
Derive adoption metrics from LOCAL git history: commit count, revert
|
|
178
247
|
count and rate, Conventional-Commit fix share, top-churn files, and a
|
|
179
248
|
${REEDIT_WINDOW_DAYS}-day re-edit proxy rate. No network calls; nothing leaves the repo.
|
|
180
249
|
|
|
181
250
|
Flags:
|
|
251
|
+
--commands Report which operations were actually invoked, from the
|
|
252
|
+
opt-in usage log. Recording is OFF unless DOCTRINA_USAGE_LOG
|
|
253
|
+
names a file; only the operation is recorded, never
|
|
254
|
+
arguments, and nothing leaves the machine.
|
|
182
255
|
--since <n|date> Window: a day count (default 90) or any git-parseable
|
|
183
256
|
date ("2026-01-01", "3 months ago").
|
|
184
257
|
--save Write .doctrina/metrics/YYYY-MM-DD.json and print the
|
package/src/commands/next.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
@@ -6,20 +7,31 @@ import * as idx from "../lib/index-json.js";
|
|
|
6
7
|
import { deriveIndex, indexesMatch, listHeader } from "../lib/scan.js";
|
|
7
8
|
import { flagBool } from "../lib/args.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
10
|
+
import { emitJson } from "../lib/json-out.js";
|
|
11
|
+
|
|
12
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
13
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
14
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
15
|
+
// This command builds its own JSON payload; the entrypoint must not
|
|
16
|
+
// wrap it in the generic envelope.
|
|
17
|
+
export const jsonNative = true;
|
|
18
|
+
|
|
19
|
+
export const flags = { boolean: ["json"], string: [] };
|
|
9
20
|
|
|
10
21
|
export async function run(_positional, flags) {
|
|
11
22
|
const projectRoot = process.cwd();
|
|
12
23
|
const json = flagBool(flags, "json", false);
|
|
13
24
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
14
25
|
const first = "doctrina init — this directory is not a Doctrina project yet";
|
|
15
|
-
|
|
26
|
+
if (json) emitJson("next", { actions: [first] });
|
|
27
|
+
else console.log(`1. ${first}`);
|
|
16
28
|
return 0;
|
|
17
29
|
}
|
|
18
30
|
|
|
19
31
|
const actions = computeActions(projectRoot);
|
|
20
32
|
|
|
21
33
|
if (json) {
|
|
22
|
-
|
|
34
|
+
emitJson("next", { actions });
|
|
23
35
|
return 0;
|
|
24
36
|
}
|
|
25
37
|
|
package/src/commands/prime.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
@@ -7,6 +8,7 @@ import { collectStatus } from "./status.js";
|
|
|
7
8
|
import { computeActions } from "./next.js";
|
|
8
9
|
import { acceptedDecisions, productSection } from "./constitution.js";
|
|
9
10
|
import { c } from "../lib/colors.js";
|
|
11
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
10
12
|
|
|
11
13
|
// Session primer: the ~40-line read that orients an agent at the start of a
|
|
12
14
|
// session — where things stand, what the standing rules are, what work is
|
|
@@ -15,10 +17,15 @@ import { c } from "../lib/colors.js";
|
|
|
15
17
|
// enough to act, cheap enough to run every session. Strictly read-only; every
|
|
16
18
|
// line is assembled from artifacts that already own the fact (no new home).
|
|
17
19
|
|
|
20
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
21
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
22
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
23
|
+
export const flags = { boolean: ["json"], string: [] };
|
|
24
|
+
|
|
18
25
|
export async function run(_positional, _flags) {
|
|
19
26
|
const projectRoot = process.cwd();
|
|
20
27
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
21
|
-
throw
|
|
28
|
+
throw notADoctrinaProject();
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
const s = collectStatus(projectRoot);
|
package/src/commands/report.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { spawnSync } from "node:child_process";
|
|
@@ -8,15 +9,21 @@ import { openChanges } from "./prime.js";
|
|
|
8
9
|
import { flagString } from "../lib/args.js";
|
|
9
10
|
import { today } from "../lib/dates.js";
|
|
10
11
|
import { c } from "../lib/colors.js";
|
|
12
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
11
13
|
|
|
12
14
|
// Periodic digest in Markdown — the standup/PR-description view: what was
|
|
13
15
|
// archived, where the gates stand, and what local git says about the period.
|
|
14
16
|
// Read-only, no network; the git section degrades silently outside a repo.
|
|
15
17
|
|
|
18
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
19
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
20
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
21
|
+
export const flags = { boolean: ["json"], string: ["since"] };
|
|
22
|
+
|
|
16
23
|
export async function run(_positional, flags) {
|
|
17
24
|
const projectRoot = process.cwd();
|
|
18
25
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
19
|
-
throw
|
|
26
|
+
throw notADoctrinaProject();
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
const sinceRaw = flagString(flags, "since") ?? "7";
|
package/src/commands/review.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { spawnSync } from "node:child_process";
|
|
@@ -7,6 +8,7 @@ import { flagBool, flagString } from "../lib/args.js";
|
|
|
7
8
|
import { c } from "../lib/colors.js";
|
|
8
9
|
import { rankCapabilitiesByDiff } from "./work.js";
|
|
9
10
|
import { parseDependsOn } from "../lib/scan.js";
|
|
11
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
10
12
|
import { summarize as coverageSummary } from "./coverage.js";
|
|
11
13
|
import { summarize as traceSummary } from "./trace.js";
|
|
12
14
|
|
|
@@ -20,10 +22,15 @@ import { summarize as traceSummary } from "./trace.js";
|
|
|
20
22
|
// ceiling as `trace`/`clarify` (ADR 0005). Read-only; exits 0 as a report, 1
|
|
21
23
|
// under --strict when any break exists (CI gate).
|
|
22
24
|
|
|
25
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
26
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
27
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
28
|
+
export const flags = { boolean: ["json", "strict"], string: ["diff"] };
|
|
29
|
+
|
|
23
30
|
export async function run(_positional, flags) {
|
|
24
31
|
const projectRoot = process.cwd();
|
|
25
32
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
26
|
-
throw
|
|
33
|
+
throw notADoctrinaProject();
|
|
27
34
|
}
|
|
28
35
|
const strict = flagBool(flags, "strict", false);
|
|
29
36
|
const against = flagString(flags, "diff"); // optional git ref to diff against
|
package/src/commands/search.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { exists, isDir, read, relPath, walk } from "../lib/fs-ops.js";
|
|
4
5
|
import { flagBool } from "../lib/args.js";
|
|
5
6
|
import { c } from "../lib/colors.js";
|
|
7
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
6
8
|
|
|
7
9
|
// Category-aware full-text search across the artifact tree. Answers
|
|
8
10
|
// "where is X specified / decided / proposed?" without knowing the
|
|
@@ -17,6 +19,11 @@ import { c } from "../lib/colors.js";
|
|
|
17
19
|
|
|
18
20
|
const MAX_MATCHES_PER_FILE = 5;
|
|
19
21
|
|
|
22
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
23
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
24
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
25
|
+
export const flags = { boolean: ["json", "archive"], string: [] };
|
|
26
|
+
|
|
20
27
|
export async function run(positional, flags) {
|
|
21
28
|
const terms = positional.map((t) => t.toLowerCase()).filter(Boolean);
|
|
22
29
|
if (terms.length === 0) {
|
|
@@ -27,7 +34,7 @@ export async function run(positional, flags) {
|
|
|
27
34
|
const includeArchive = flagBool(flags, "archive", false);
|
|
28
35
|
const projectRoot = process.cwd();
|
|
29
36
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
30
|
-
throw
|
|
37
|
+
throw notADoctrinaProject();
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
const categories = [
|
package/src/commands/show.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
@@ -5,6 +6,7 @@ import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
|
|
|
5
6
|
import { parseAcceptanceCriteria } from "../lib/criteria.js";
|
|
6
7
|
import { suggest } from "../lib/suggest.js";
|
|
7
8
|
import { c } from "../lib/colors.js";
|
|
9
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
8
10
|
|
|
9
11
|
// Point reads. An agent that needs ONE requirement re-reads a whole spec —
|
|
10
12
|
// hundreds of lines for a two-line fact. `show` resolves a compact reference
|
|
@@ -20,11 +22,16 @@ import { c } from "../lib/colors.js";
|
|
|
20
22
|
// conversation, not as immutable identifiers. C-refs use the criteria's own
|
|
21
23
|
// explicit numbers and are as stable as the spec keeps them.
|
|
22
24
|
|
|
25
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
26
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
27
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
28
|
+
export const flags = { boolean: ["json"], string: [] };
|
|
29
|
+
|
|
23
30
|
export async function run(positional, _flags) {
|
|
24
31
|
const ref = positional[0];
|
|
25
32
|
const projectRoot = process.cwd();
|
|
26
33
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
27
|
-
throw
|
|
34
|
+
throw notADoctrinaProject();
|
|
28
35
|
}
|
|
29
36
|
if (!ref) {
|
|
30
37
|
console.error(c.red("error:") + " show requires a reference");
|