doctrina-cli 0.16.0 → 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 +3 -2
- package/src/commands/analyze.js +8 -0
- package/src/commands/change.js +28 -77
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +65 -10
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +6 -4
- package/src/commands/decision.js +12 -0
- package/src/commands/doctor.js +1 -1
- package/src/commands/hooks.js +6 -1
- package/src/commands/index-rebuild.js +52 -2
- package/src/commands/init.js +19 -4
- package/src/commands/intake.js +55 -7
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +13 -5
- package/src/commands/next.js +1 -1
- package/src/commands/prime.js +4 -5
- package/src/commands/report.js +9 -20
- package/src/commands/skill.js +8 -15
- package/src/commands/spec.js +13 -7
- package/src/commands/status.js +39 -20
- package/src/commands/templates.js +4 -10
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +32 -8
- package/src/commands/work.js +10 -5
- package/src/index.js +34 -6
- package/src/lib/actions.js +30 -24
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +3 -1
- package/src/lib/commands.js +88 -15
- package/src/lib/coverage-model.js +40 -1
- package/src/lib/doc-model.js +47 -6
- package/src/lib/docs-impact.js +74 -0
- package/src/lib/ears.js +24 -1
- package/src/lib/gates.js +53 -9
- package/src/lib/index-json.js +40 -5
- package/src/lib/intake-model.js +71 -2
- package/src/lib/lexicon.js +38 -1
- package/src/lib/metrics-model.js +30 -0
- package/src/lib/project.js +49 -1
- package/src/lib/scan.js +126 -14
- package/src/lib/snapshot.js +23 -1
- package/src/lib/spec-ops.js +24 -5
- package/src/lib/templates-model.js +35 -8
- package/src/lib/validation-model.js +116 -34
- package/src/lib/views.js +4 -1
- package/src/lib/work-model.js +25 -0
- package/templates/AGENTS.md.template +3 -4
- 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 +15 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +10 -0
- package/templates/playbooks/bootstrap.md.template +6 -2
- package/templates/playbooks/chore.md.template +9 -4
- package/templates/playbooks/work.md.template +10 -3
- package/src/commands/constitution.js +0 -68
package/src/commands/decision.js
CHANGED
|
@@ -152,8 +152,20 @@ function decisionSupersede(args) {
|
|
|
152
152
|
});
|
|
153
153
|
// Inject Supersedes link
|
|
154
154
|
body = body.replace(/(\*\*Supersedes:\*\*)\s+—/, `$1 ${padded}`);
|
|
155
|
+
// Carry the predecessor's Scope. A successor built from the bare template
|
|
156
|
+
// had none, so it was GLOBAL: refining a decision that governed one
|
|
157
|
+
// capability, then accepting the refinement, loaded it into every
|
|
158
|
+
// capability's pack — ones the original never governed — and nothing said
|
|
159
|
+
// so. A replacement decides the same subject; its scope starts where the
|
|
160
|
+
// predecessor's was, written into the file where the author can widen it
|
|
161
|
+
// on purpose (ADR 0022: a pack is retrieval, not a dump).
|
|
162
|
+
const inheritedScope = parseAdrScope(oldText);
|
|
163
|
+
if (inheritedScope.length > 0) body = insertScopeHeader(body, inheritedScope) ?? body;
|
|
155
164
|
write(newPath, body, { force: false });
|
|
156
165
|
console.log(c.green("created") + ` ${relPath(projectRoot, newPath)}`);
|
|
166
|
+
if (inheritedScope.length > 0) {
|
|
167
|
+
console.log(c.gray("scope: ") + `inherited from ${padded} — ${inheritedScope.join(", ")} (edit the Scope: header to change it)`);
|
|
168
|
+
}
|
|
157
169
|
|
|
158
170
|
// Mutate ONLY the Status: and Superseded by: headers of the old ADR.
|
|
159
171
|
// Both go through the document model, so an ADR written with a slightly
|
package/src/commands/doctor.js
CHANGED
|
@@ -188,7 +188,7 @@ export async function run(_positional, _flags) {
|
|
|
188
188
|
? tmplFindings[0].message
|
|
189
189
|
: `${tmplFindings.length} template findings`;
|
|
190
190
|
const remedy = [...byRemedy.keys()].filter((k) => k !== "(manual repair)");
|
|
191
|
-
row("warn", "templates", summary, remedy.length === 1 ? remedy[0] : "doctrina
|
|
191
|
+
row("warn", "templates", summary, remedy.length === 1 ? remedy[0] : "doctrina upgrade (each finding names its own fix)");
|
|
192
192
|
warningsTotal += 1;
|
|
193
193
|
},
|
|
194
194
|
|
package/src/commands/hooks.js
CHANGED
|
@@ -10,6 +10,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|
|
10
10
|
import { flagBool } from "../lib/args.js";
|
|
11
11
|
import { c } from "../lib/colors.js";
|
|
12
12
|
import { suggest } from "../lib/suggest.js";
|
|
13
|
+
import { EXIT } from "../lib/exit-codes.js";
|
|
13
14
|
|
|
14
15
|
const SUBCOMMANDS = ["install"];
|
|
15
16
|
|
|
@@ -33,8 +34,12 @@ export async function run(positional, flags) {
|
|
|
33
34
|
const projectRoot = process.cwd();
|
|
34
35
|
|
|
35
36
|
if (!exists(path.join(projectRoot, ".git"))) {
|
|
37
|
+
// A PRECONDITION, not a gate: nothing was measured and found wanting,
|
|
38
|
+
// the place to put the hook simply does not exist yet. Class 1 told an
|
|
39
|
+
// agent to fix its work and retry the same command, which never clears.
|
|
36
40
|
console.error(c.red("error:") + " not a git repository (no .git/ in cwd)");
|
|
37
|
-
|
|
41
|
+
console.error(c.gray("hint: ") + "run `git init` here, then `doctrina hooks install`");
|
|
42
|
+
return EXIT.PRECONDITION;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
const hooksDir = path.join(projectRoot, ".git", "hooks");
|
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { exists } from "../lib/fs-ops.js";
|
|
5
5
|
import * as idx from "../lib/index-json.js";
|
|
6
|
-
import { collectIndexDrift } from "../lib/scan.js";
|
|
6
|
+
import { collectIndexDrift, collectStagedIndexDrift } from "../lib/scan.js";
|
|
7
7
|
import { today } from "../lib/dates.js";
|
|
8
8
|
import { flagBool } from "../lib/args.js";
|
|
9
9
|
import { c } from "../lib/colors.js";
|
|
@@ -15,7 +15,7 @@ const SUBCOMMANDS = ["rebuild"];
|
|
|
15
15
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
16
16
|
// adding a command never requires editing the entrypoint — the gap that
|
|
17
17
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
18
|
-
export const flags = { boolean: ["json", "check"], string: [] };
|
|
18
|
+
export const flags = { boolean: ["json", "check", "staged"], string: [] };
|
|
19
19
|
|
|
20
20
|
export async function run(positional, flags) {
|
|
21
21
|
const sub = positional[0];
|
|
@@ -32,11 +32,21 @@ export async function run(positional, flags) {
|
|
|
32
32
|
|
|
33
33
|
function rebuild(flags) {
|
|
34
34
|
const check = flagBool(flags, "check", false);
|
|
35
|
+
const staged = flagBool(flags, "staged", false);
|
|
35
36
|
const projectRoot = process.cwd();
|
|
36
37
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
37
38
|
throw notADoctrinaProject();
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
if (staged) {
|
|
42
|
+
if (!check) {
|
|
43
|
+
console.error(c.red("error:") + " --staged reads the commit, so it can only check; it never writes");
|
|
44
|
+
console.error(c.gray("hint: ") + "run `doctrina index rebuild --check --staged`");
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
return checkStaged(projectRoot);
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
const state = collectIndexDrift(projectRoot);
|
|
41
51
|
if (state.unreadable) {
|
|
42
52
|
if (check) {
|
|
@@ -70,6 +80,39 @@ function rebuild(flags) {
|
|
|
70
80
|
return 0;
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
// The drift check, asked of what git will record. `index.json` is derived
|
|
84
|
+
// from the WHOLE tree, so it cannot describe a subset of it: stage part of
|
|
85
|
+
// `.doctrina/` and the index riding along names artifacts the commit does
|
|
86
|
+
// not carry. Every other gate reads the working tree, where those artifacts
|
|
87
|
+
// are still sitting, so none of them can see it.
|
|
88
|
+
function checkStaged(projectRoot) {
|
|
89
|
+
const state = collectStagedIndexDrift(projectRoot);
|
|
90
|
+
if (!state.applicable) {
|
|
91
|
+
console.log(c.green("ok") + ` nothing to check — ${state.reason}`);
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
if (state.ok) {
|
|
95
|
+
console.log(c.green("ok") + " the staged index matches the staged tree");
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
if (state.reason) {
|
|
99
|
+
console.error(c.red("error:") + ` ${state.reason}`);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
for (const p of state.missing) {
|
|
103
|
+
console.log(c.yellow("staged: ") + `${p} — named by the staged index, absent from the commit`);
|
|
104
|
+
}
|
|
105
|
+
for (const p of state.unindexed) {
|
|
106
|
+
console.log(c.yellow("staged: ") + `${p} — in the commit, absent from the staged index`);
|
|
107
|
+
}
|
|
108
|
+
const n = state.missing.length + state.unindexed.length;
|
|
109
|
+
console.log("");
|
|
110
|
+
console.log(c.red("fail") + ` the staged index does not describe this commit (${n} difference${n === 1 ? "" : "s"})`);
|
|
111
|
+
console.log(c.gray("hint: ") + "stage the artifacts above with the index, or unstage the index — "
|
|
112
|
+
+ "index.json is derived from the whole tree, so a partial commit of .doctrina/ cannot carry a matching one");
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
|
|
73
116
|
export const help = `
|
|
74
117
|
Usage: doctrina index <subcommand>
|
|
75
118
|
|
|
@@ -80,6 +123,13 @@ Subcommands:
|
|
|
80
123
|
source of truth; the index is derived.
|
|
81
124
|
rebuild --check Write nothing; exit 1 with a drift summary when the
|
|
82
125
|
index no longer matches the tree. CI-friendly.
|
|
126
|
+
rebuild --check --staged
|
|
127
|
+
Ask the same question of the COMMIT rather than the
|
|
128
|
+
disk: every artifact the staged index names must be in
|
|
129
|
+
the commit, and every staged artifact must be in the
|
|
130
|
+
index. A partial commit of .doctrina/ cannot satisfy
|
|
131
|
+
both, because the index is derived from the whole tree.
|
|
132
|
+
Exits 0 when nothing relevant is staged.
|
|
83
133
|
|
|
84
134
|
Fields with no on-disk source (project name, framework_version, product
|
|
85
135
|
metadata) are carried over from the existing index.
|
package/src/commands/init.js
CHANGED
|
@@ -14,7 +14,7 @@ import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
|
|
|
14
14
|
import { c } from "../lib/colors.js";
|
|
15
15
|
import { EXIT } from "../lib/exit-codes.js";
|
|
16
16
|
import { ask, isInteractive } from "../lib/prompt.js";
|
|
17
|
-
import { looksLikePath, writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake } from "../lib/intake-model.js";
|
|
17
|
+
import { looksLikePath, writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake, nextStepAfterInit } from "../lib/intake-model.js";
|
|
18
18
|
|
|
19
19
|
const SUPPORTED_AGENTS = [
|
|
20
20
|
"claude",
|
|
@@ -107,9 +107,20 @@ export async function run(_positional, flags) {
|
|
|
107
107
|
// isTTY check the adapter prompt already had. A non-interactive caller
|
|
108
108
|
// that forgot --non-interactive read EOF, got an empty answer, and
|
|
109
109
|
// scaffolded a project with a blank description — silently (C9).
|
|
110
|
+
// At a terminal, ONE question, and its answer is the intake (change 0173):
|
|
111
|
+
// it was asked as a one-line description, written into product.md, and
|
|
112
|
+
// then `next` asked for the same thing again as an intake. A person
|
|
113
|
+
// describes the project once.
|
|
114
|
+
let intakeFromTty = false;
|
|
110
115
|
if (!description && !nonInteractive) {
|
|
111
116
|
if (isInteractive()) {
|
|
112
|
-
|
|
117
|
+
const answer = await ask("Describe the project — what it is, who it is for, what it must do:");
|
|
118
|
+
if (answer && answer.trim() !== "") {
|
|
119
|
+
intakeBody = answer.trim();
|
|
120
|
+
intakeSource = "typed at `doctrina init`";
|
|
121
|
+
intakeFromTty = true;
|
|
122
|
+
description = firstLineSummary(intakeBody);
|
|
123
|
+
}
|
|
113
124
|
}
|
|
114
125
|
if (!description) {
|
|
115
126
|
console.error(c.red("error:") + " no project description given and no terminal to ask on");
|
|
@@ -293,7 +304,11 @@ export async function run(_positional, flags) {
|
|
|
293
304
|
|
|
294
305
|
console.log("");
|
|
295
306
|
console.log(c.bold("Doctrina initialised."));
|
|
296
|
-
if (
|
|
307
|
+
if (intakeFromTty) {
|
|
308
|
+
// A person at a terminal gets the one instruction, not the agent's
|
|
309
|
+
// playbook: the agent reprints it with `doctrina intake`.
|
|
310
|
+
for (const line of nextStepAfterInit("tty")) console.log(line);
|
|
311
|
+
} else if (intakeBody) {
|
|
297
312
|
console.log(`Next: execute the bootstrap playbook below — it converts the intake into`);
|
|
298
313
|
console.log(`${c.cyan(".doctrina/product.md")} and capability specs. Any AGENTS.md-aware agent`);
|
|
299
314
|
console.log(`runs it on its own; reprint anytime with ${c.cyan("doctrina intake")}.`);
|
|
@@ -301,7 +316,7 @@ export async function run(_positional, flags) {
|
|
|
301
316
|
warnIfThinIntake(intakeBody);
|
|
302
317
|
printBootstrapPlaybook(projectRoot);
|
|
303
318
|
} else {
|
|
304
|
-
|
|
319
|
+
for (const line of nextStepAfterInit(null)) console.log(line);
|
|
305
320
|
}
|
|
306
321
|
return 0;
|
|
307
322
|
}
|
package/src/commands/intake.js
CHANGED
|
@@ -2,17 +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";
|
|
11
|
-
import { looksLikePath, warnIfThinIntake, writeIntakeFile
|
|
12
|
+
import { looksLikePath, markIntakeConverted, printBootstrapPlaybook, warnIfThinIntake, writeIntakeFile } from "../lib/intake-model.js";
|
|
12
13
|
|
|
13
14
|
// `init --intake` performs the same operations; they live in
|
|
14
15
|
// lib/intake-model.js so neither command imports out of the other (F7).
|
|
15
16
|
export { warnIfThinIntake, writeIntakeFile, printBootstrapPlaybook } from "../lib/intake-model.js";
|
|
17
|
+
import { collectValidation } from "../lib/validation-model.js";
|
|
16
18
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
17
19
|
|
|
18
20
|
// `intake` is the first half of the no-ceremony path (ADR 0005): store the
|
|
@@ -24,10 +26,47 @@ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
|
24
26
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
25
27
|
// adding a command never requires editing the entrypoint — the gap that
|
|
26
28
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
27
|
-
export const flags = { boolean: ["json", "force"], string: ["text"] };
|
|
29
|
+
export const flags = { boolean: ["json", "force", "converted"], string: ["text"] };
|
|
28
30
|
|
|
29
31
|
export async function run(positional, flags) {
|
|
30
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
|
+
|
|
31
70
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
32
71
|
throw notADoctrinaProject();
|
|
33
72
|
}
|
|
@@ -102,8 +141,8 @@ export async function run(positional, flags) {
|
|
|
102
141
|
return EXIT.PRECONDITION;
|
|
103
142
|
}
|
|
104
143
|
|
|
105
|
-
const
|
|
106
|
-
writeIntakeFile(projectRoot, { body, source, projectName, date: today(), force });
|
|
144
|
+
const name = projectName(projectRoot);
|
|
145
|
+
writeIntakeFile(projectRoot, { body, source, projectName: name, date: today(), force });
|
|
107
146
|
console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)}`);
|
|
108
147
|
console.log("");
|
|
109
148
|
warnIfThinIntake(body);
|
|
@@ -127,11 +166,20 @@ Forms:
|
|
|
127
166
|
doctrina intake --text "<text>" Ingest an inline description
|
|
128
167
|
doctrina intake Reprint the playbook for the
|
|
129
168
|
existing pending intake
|
|
169
|
+
doctrina intake --converted Close the bootstrap: flip the stored
|
|
170
|
+
intake's Status to converted
|
|
130
171
|
|
|
131
172
|
Options:
|
|
132
173
|
--text "<description>" Inline description instead of a file
|
|
133
|
-
--force Overwrite an existing .doctrina/intake.md
|
|
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
|
|
134
179
|
|
|
135
|
-
|
|
136
|
-
|
|
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.
|
|
137
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
|
@@ -11,7 +11,7 @@ import { GIT_STATE, historyState, git } from "../lib/git.js";
|
|
|
11
11
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
12
|
import { USAGE_ENV, summarise } from "../lib/usage.js";
|
|
13
13
|
import { OPERATIONS } from "../lib/commands.js";
|
|
14
|
-
import { REEDIT_WINDOW_DAYS, computeSnapshot, parseLog, readSeries, latestSnapshot, trend, round } from "../lib/metrics-model.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
|
|
@@ -37,6 +37,14 @@ export async function run(_positional, flags) {
|
|
|
37
37
|
// recent one (audit finding F15) — a year of measurement answering one
|
|
38
38
|
// question. Reading the series is what makes the saving worth doing.
|
|
39
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
|
+
}
|
|
40
48
|
const probe = git(projectRoot, ["rev-parse", "--is-inside-work-tree"]);
|
|
41
49
|
// "No history yet" is a VALID state, not a failure — a brand-new project
|
|
42
50
|
// is the most common state in which someone explores this command, and it
|
|
@@ -55,8 +63,6 @@ export async function run(_positional, flags) {
|
|
|
55
63
|
return EXIT.OK;
|
|
56
64
|
}
|
|
57
65
|
|
|
58
|
-
const sinceRaw = flagString(flags, "since", "90");
|
|
59
|
-
const since = /^\d+$/.test(sinceRaw) ? `${sinceRaw} days ago` : sinceRaw;
|
|
60
66
|
const save = flagBool(flags, "save", false);
|
|
61
67
|
|
|
62
68
|
const log = git(projectRoot, [
|
|
@@ -218,8 +224,10 @@ Flags:
|
|
|
218
224
|
opt-in usage log. Recording is OFF unless DOCTRINA_USAGE_LOG
|
|
219
225
|
names a file; only the operation is recorded, never
|
|
220
226
|
arguments, and nothing leaves the machine.
|
|
221
|
-
--since <n|date> Window: a day count (default 90)
|
|
222
|
-
|
|
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.
|
|
223
231
|
--save Write .doctrina/metrics/YYYY-MM-DD.json and print the
|
|
224
232
|
deltas against the most recent prior snapshot.
|
|
225
233
|
--trend Read the saved snapshots instead of git: every snapshot
|
package/src/commands/next.js
CHANGED
|
@@ -79,7 +79,7 @@ export async function run(_positional, flags) {
|
|
|
79
79
|
export const RUNNERS = {
|
|
80
80
|
triage: () => import("./triage.js"),
|
|
81
81
|
intake: () => import("./intake.js"),
|
|
82
|
-
|
|
82
|
+
"change check": () => import("./change.js"),
|
|
83
83
|
"change apply": () => import("./change.js"),
|
|
84
84
|
"change archive": () => import("./change.js"),
|
|
85
85
|
"index rebuild": () => import("./index-rebuild.js"),
|
package/src/commands/prime.js
CHANGED
|
@@ -29,8 +29,8 @@ export async function run(_positional, flags) {
|
|
|
29
29
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
30
30
|
throw notADoctrinaProject();
|
|
31
31
|
}
|
|
32
|
-
// --rules: the standing rules in full, which is what
|
|
33
|
-
// (
|
|
32
|
+
// --rules: the standing rules in full, which is what the removed
|
|
33
|
+
// `constitution` printed (changes 0049, 0175). The primer names the ADRs and counts the non-goals; this
|
|
34
34
|
// prints both lists, from the same collection, so the two cannot disagree.
|
|
35
35
|
const view = flagBool(flags, "rules", false) ? "rules" : "prime";
|
|
36
36
|
for (const line of renderView(view, collectSnapshot(projectRoot))) console.log(line);
|
|
@@ -49,9 +49,8 @@ The same view as \`doctrina status --view prime\`; both render one
|
|
|
49
49
|
collection of the tree, so they cannot disagree.
|
|
50
50
|
|
|
51
51
|
--rules Print the standing rules in full instead of the primer: every
|
|
52
|
-
accepted ADR and every declared non-goal
|
|
53
|
-
\`doctrina constitution\` printed, from the same collection
|
|
54
|
-
that command is deprecated and delegates here.
|
|
52
|
+
accepted ADR and every declared non-goal — what the removed
|
|
53
|
+
\`doctrina constitution\` printed, from the same collection.
|
|
55
54
|
|
|
56
55
|
Read-only; always exits 0. Deeper reads: \`doctrina context\`,
|
|
57
56
|
\`doctrina why <cap>\`, \`doctrina show <ref>\`.
|
package/src/commands/report.js
CHANGED
|
@@ -2,14 +2,12 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { exists } from "../lib/fs-ops.js";
|
|
5
|
-
import { flagBool, flagString } from "../lib/args.js";
|
|
5
|
+
import { flagBool, flagString, parsePositiveInt } from "../lib/args.js";
|
|
6
6
|
import { c } from "../lib/colors.js";
|
|
7
7
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
8
|
-
import { collectSnapshot } from "../lib/snapshot.js";
|
|
8
|
+
import { collectSnapshot, collectReportWindow } from "../lib/snapshot.js";
|
|
9
9
|
import { renderView } from "../lib/views.js";
|
|
10
|
-
import { gitWindow, historyState, windowCutoff } from "../lib/git.js";
|
|
11
10
|
import { draftAgentChangelog, renderDraft } from "../lib/agent-changelog.js";
|
|
12
|
-
import { collectMetrics } from "../lib/metrics-model.js";
|
|
13
11
|
import { cliVersion } from "../lib/version.js";
|
|
14
12
|
|
|
15
13
|
// Periodic digest in Markdown — the standup/PR-description view: what was
|
|
@@ -33,8 +31,8 @@ export async function run(_positional, flags) {
|
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
const sinceRaw = flagString(flags, "since");
|
|
36
|
-
const days =
|
|
37
|
-
if (
|
|
34
|
+
const days = parsePositiveInt(sinceRaw ?? "7");
|
|
35
|
+
if (days === null) {
|
|
38
36
|
console.error(c.red("error:") + ` --since expects a positive day count, got "${sinceRaw}"`);
|
|
39
37
|
return 2;
|
|
40
38
|
}
|
|
@@ -51,19 +49,9 @@ export async function run(_positional, flags) {
|
|
|
51
49
|
return 0;
|
|
52
50
|
}
|
|
53
51
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
git: gitWindow(projectRoot, days),
|
|
58
|
-
// Why there is no window, when there is none — the same door `metrics`
|
|
59
|
-
// and `context` already ask, so the three cannot disagree about the
|
|
60
|
-
// state of one repository (change 0080).
|
|
61
|
-
gitState: historyState(projectRoot),
|
|
62
|
-
// The same snapshot `metrics` renders, for the same window (change
|
|
63
|
-
// 0050). One definition of "revert rate", so the digest and the metrics
|
|
64
|
-
// command cannot report two different numbers for one period.
|
|
65
|
-
metrics: collectMetrics(projectRoot, `${days} days ago`),
|
|
66
|
-
};
|
|
52
|
+
// One collector with `status --view report` (change 0176), so the
|
|
53
|
+
// deprecated name and its survivor cannot print different digests.
|
|
54
|
+
const options = collectReportWindow(projectRoot, days);
|
|
67
55
|
for (const line of renderView("report", collectSnapshot(projectRoot), options)) console.log(line);
|
|
68
56
|
return 0;
|
|
69
57
|
}
|
|
@@ -78,7 +66,8 @@ work with task progress, artifact counts, and a local-git summary
|
|
|
78
66
|
descriptions. Read-only; no network. \`doctrina metrics\` has the
|
|
79
67
|
deeper git-derived numbers.
|
|
80
68
|
|
|
81
|
-
|
|
69
|
+
Deprecated: this is \`doctrina status --view report --since <days>\`, and
|
|
70
|
+
\`--agent-changelog\` is \`doctrina status --view agent-changelog\`.
|
|
82
71
|
|
|
83
72
|
--agent-changelog Draft the AGENTS.md "What changed" block instead of
|
|
84
73
|
the digest: one candidate bullet per archived change
|
package/src/commands/skill.js
CHANGED
|
@@ -12,7 +12,7 @@ import { suggest } from "../lib/suggest.js";
|
|
|
12
12
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
13
|
import { parseFrontmatter } from "../lib/doc-model.js";
|
|
14
14
|
import { git, GIT_STATE } from "../lib/git.js";
|
|
15
|
-
import { FIX_SHAPED, FIX_SHAPED_SUBJECT } from "../lib/lexicon.js";
|
|
15
|
+
import { FIX_SHAPED, FIX_SHAPED_SUBJECT, fold, skillSlug } from "../lib/lexicon.js";
|
|
16
16
|
import { artifactNameError } from "../lib/names.js";
|
|
17
17
|
|
|
18
18
|
const SUBCOMMANDS = ["new", "list", "sync", "suggest"];
|
|
@@ -104,7 +104,7 @@ export function draftFromError(text) {
|
|
|
104
104
|
// Distinctive words, most frequent first — the fallback when an error
|
|
105
105
|
// carries no structure at all.
|
|
106
106
|
const counts = new Map();
|
|
107
|
-
for (const w of raw
|
|
107
|
+
for (const w of fold(raw).match(/[a-z][a-z0-9_-]{3,}/g) ?? []) {
|
|
108
108
|
if (ERROR_STOP.has(w)) continue;
|
|
109
109
|
counts.set(w, (counts.get(w) ?? 0) + 1);
|
|
110
110
|
}
|
|
@@ -125,8 +125,8 @@ export function draftFromError(text) {
|
|
|
125
125
|
// also mentions do not both land in the name.
|
|
126
126
|
const seedTokens = [];
|
|
127
127
|
const seenToken = new Set();
|
|
128
|
-
for (const token of [...identifiers.map((i) => i
|
|
129
|
-
const norm = token.replace(/[^a-z0-9]/g, "");
|
|
128
|
+
for (const token of [...identifiers.map((i) => fold(i)), ...keywords]) {
|
|
129
|
+
const norm = fold(token).replace(/[^a-z0-9]/g, "");
|
|
130
130
|
if (norm === "" || seenToken.has(norm)) continue;
|
|
131
131
|
seenToken.add(norm);
|
|
132
132
|
seedTokens.push(token);
|
|
@@ -344,7 +344,7 @@ function skillSuggest(args, flags) {
|
|
|
344
344
|
idx.save(projectRoot, index);
|
|
345
345
|
}
|
|
346
346
|
console.log("");
|
|
347
|
-
console.log(c.green("ok") + ` scaffolded ${created} skill stub${created === 1 ? "" : "s"} — fill the description/when/body, then \`doctrina
|
|
347
|
+
console.log(c.green("ok") + ` scaffolded ${created} skill stub${created === 1 ? "" : "s"} — fill the description/when/body, then \`doctrina index rebuild\``);
|
|
348
348
|
return 0;
|
|
349
349
|
}
|
|
350
350
|
|
|
@@ -400,16 +400,9 @@ function gitFixCommits(projectRoot, { since, limit }) {
|
|
|
400
400
|
}
|
|
401
401
|
|
|
402
402
|
// Derive a skill slug from a commit subject: drop a conventional-commit
|
|
403
|
-
// "type(scope): " prefix
|
|
403
|
+
// "type(scope): " prefix and hand the rest to the shared slug, which folds.
|
|
404
404
|
function commitSlug(subject) {
|
|
405
|
-
|
|
406
|
-
return skillSlug(stripped.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""));
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function skillSlug(id) {
|
|
410
|
-
// Reuse the change id as the skill slug, trimmed to a sane length.
|
|
411
|
-
const slug = id.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
412
|
-
return slug.length > 40 ? slug.slice(0, 40).replace(/-+$/g, "") : (slug || "lesson");
|
|
405
|
+
return skillSlug(subject.replace(/^[a-z]+(?:\([^)]*\))?!?\s*:\s*/i, ""));
|
|
413
406
|
}
|
|
414
407
|
|
|
415
408
|
// Token-overlap similarity between two kebab slugs (Jaccard over meaningful
|
|
@@ -482,7 +475,7 @@ function skillNew(args, flags) {
|
|
|
482
475
|
idx.save(projectRoot, index);
|
|
483
476
|
console.log(c.green("indexed") + ` skill "${name}"`);
|
|
484
477
|
console.log("");
|
|
485
|
-
console.log(c.gray("hint: ") + "fill the description and when frontmatter fields, then run `doctrina
|
|
478
|
+
console.log(c.gray("hint: ") + "fill the description and when frontmatter fields, then run `doctrina index rebuild` to update index.json");
|
|
486
479
|
return 0;
|
|
487
480
|
}
|
|
488
481
|
|
package/src/commands/spec.js
CHANGED
|
@@ -155,8 +155,12 @@ function specSet(args, flags) {
|
|
|
155
155
|
let text = read(specPath);
|
|
156
156
|
const applied = [];
|
|
157
157
|
const errors = [];
|
|
158
|
+
// An error names what to correct: the INVOCATION (a value out of domain, a
|
|
159
|
+
// criterion that does not exist, a malformed flag) or the SPEC (a header
|
|
160
|
+
// or section it lacks). Only the second is fixed by editing the work.
|
|
161
|
+
const refused = (msg) => errors.push({ msg, argument: true });
|
|
158
162
|
const apply = (res) => {
|
|
159
|
-
if (res.error) errors.push(res.error);
|
|
163
|
+
if (res.error) errors.push({ msg: res.error, argument: res.cause === "argument" });
|
|
160
164
|
else { text = res.text; applied.push(res.summary); }
|
|
161
165
|
};
|
|
162
166
|
// Order: headers, then version, then criterion — independent edits, all or
|
|
@@ -173,7 +177,7 @@ function specSet(args, flags) {
|
|
|
173
177
|
const derivedValue = derivedImplementations(projectRoot, { only: new Set([capability]) })
|
|
174
178
|
.get(capability)?.derived;
|
|
175
179
|
if (!derivedValue) {
|
|
176
|
-
errors.push(`--implementation auto needs acceptance criteria to derive from; ${capability} declares none
|
|
180
|
+
errors.push({ msg: `--implementation auto needs acceptance criteria to derive from; ${capability} declares none`, argument: false });
|
|
177
181
|
} else {
|
|
178
182
|
apply(setHeader(text, "Implementation", derivedValue));
|
|
179
183
|
}
|
|
@@ -182,23 +186,25 @@ function specSet(args, flags) {
|
|
|
182
186
|
}
|
|
183
187
|
}
|
|
184
188
|
if (bump !== undefined) {
|
|
185
|
-
if (!["major", "minor", "patch"].includes(bump))
|
|
189
|
+
if (!["major", "minor", "patch"].includes(bump)) refused(`--bump needs major|minor|patch (got "${bump}")`);
|
|
186
190
|
else apply(bumpVersion(text, bump));
|
|
187
191
|
}
|
|
188
192
|
if (versionExplicit !== undefined) {
|
|
189
|
-
if (!/^\d+\.\d+\.\d+$/.test(versionExplicit))
|
|
193
|
+
if (!/^\d+\.\d+\.\d+$/.test(versionExplicit)) refused(`--version needs semver X.Y.Z (got "${versionExplicit}")`);
|
|
190
194
|
else apply(setHeader(text, "Version", versionExplicit));
|
|
191
195
|
}
|
|
192
196
|
if (criterion !== undefined) {
|
|
193
197
|
const m = String(criterion).match(/^(\d+)\s*:\s*(.+)$/);
|
|
194
|
-
if (!m)
|
|
198
|
+
if (!m) refused(`--criterion needs "<n>:<mark>" (got "${criterion}")`);
|
|
195
199
|
else apply(setCriterionMark(text, Number(m[1]), m[2].trim()));
|
|
196
200
|
}
|
|
197
201
|
|
|
198
202
|
if (errors.length > 0) {
|
|
199
203
|
console.error(c.red("error:") + ` ${errors.length} operation error${errors.length === 1 ? "" : "s"} — spec left untouched:`);
|
|
200
|
-
for (const e of errors) console.error(` - ${e}`);
|
|
201
|
-
|
|
204
|
+
for (const e of errors) console.error(` - ${e.msg}`);
|
|
205
|
+
// Retrying an invocation error unchanged never succeeds (ADR 0018), so
|
|
206
|
+
// one in the list is enough to answer USAGE.
|
|
207
|
+
return errors.some((e) => e.argument) ? EXIT.USAGE : EXIT.GATE;
|
|
202
208
|
}
|
|
203
209
|
|
|
204
210
|
// Stamp Last updated when the header exists (best-effort, never an error).
|