doctrina-cli 0.13.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 +66 -19
- package/src/commands/change.js +100 -42
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +57 -2
- 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 +139 -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 +193 -59
- package/src/commands/trace.js +15 -3
- package/src/commands/upgrade.js +8 -1
- package/src/commands/validate.js +58 -2
- 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 +8 -1
- package/src/index.js +60 -6
- package/src/lib/adapters.js +140 -0
- package/src/lib/adr-guard.js +1 -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 +239 -26
- 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 +94 -9
- 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/AGENTS.md.template +59 -53
package/src/commands/validate.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
4
|
-
import { exists, isDir, isFile, lineCount, read, relPath, walk } from "../lib/fs-ops.js";
|
|
5
|
+
import { exists, isDir, isFile, lineCount, read, relPath, walk , write} from "../lib/fs-ops.js";
|
|
5
6
|
import * as idx from "../lib/index-json.js";
|
|
6
7
|
import { SCHEMA_VERSION } from "../lib/index-json.js";
|
|
7
8
|
import { cliVersion } from "../lib/version.js";
|
|
8
9
|
import { today } from "../lib/dates.js";
|
|
9
10
|
import { flagBool } from "../lib/args.js";
|
|
10
11
|
import { c } from "../lib/colors.js";
|
|
12
|
+
import { kindFromPath, nonConformingHeaders, repairHeaders } from "../lib/doc-model.js";
|
|
13
|
+
import { emitJson } from "../lib/json-out.js";
|
|
14
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
11
15
|
import { parseFrontmatter } from "./skill.js";
|
|
12
16
|
import { checkEars, isEarsSpec } from "../lib/ears.js";
|
|
13
17
|
import { specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
|
|
@@ -19,8 +23,21 @@ import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
|
|
|
19
23
|
// `doctrina --help` and is never nagged about omissions.
|
|
20
24
|
const CATALOG_THRESHOLD = 8;
|
|
21
25
|
|
|
26
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
27
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
28
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
29
|
+
// This command builds its own JSON payload; the entrypoint must not
|
|
30
|
+
// wrap it in the generic envelope.
|
|
31
|
+
export const jsonNative = true;
|
|
32
|
+
|
|
33
|
+
export const flags = { boolean: ["fix", "json"], string: [] };
|
|
34
|
+
|
|
22
35
|
export async function run(_positional, flags) {
|
|
23
36
|
const projectRoot = process.cwd();
|
|
37
|
+
// Outside a Doctrina project this is a PRECONDITION, not a pile of
|
|
38
|
+
// gate failures about missing files: the remedy is `doctrina init`,
|
|
39
|
+
// not editing artifacts that do not exist yet (C7).
|
|
40
|
+
if (!isDir(path.join(projectRoot, ".doctrina"))) throw notADoctrinaProject();
|
|
24
41
|
const errors = [];
|
|
25
42
|
const warnings = [];
|
|
26
43
|
|
|
@@ -87,6 +104,45 @@ export async function run(_positional, flags) {
|
|
|
87
104
|
const productMd = path.join(projectRoot, ".doctrina", "product.md");
|
|
88
105
|
if (!isFile(productMd)) errors.push(".doctrina/product.md missing");
|
|
89
106
|
|
|
107
|
+
// Header repair (M3). One grammar means a non-canonical header can be
|
|
108
|
+
// REWRITTEN, not just reported: `- **Status**: x` becomes
|
|
109
|
+
// `- **Status:** x`, and a spec written with list-style headers is
|
|
110
|
+
// normalised to the bare-bold form its kind uses. Content is never
|
|
111
|
+
// touched — only the header's own punctuation and style.
|
|
112
|
+
const doFix = flagBool(flags, "fix", false);
|
|
113
|
+
{
|
|
114
|
+
const artifacts = [];
|
|
115
|
+
const dot = path.join(projectRoot, ".doctrina");
|
|
116
|
+
for (const f of walk(dot)) {
|
|
117
|
+
if (!f.endsWith(".md")) continue;
|
|
118
|
+
const rel = relPath(projectRoot, f).replace(/\\/g, "/");
|
|
119
|
+
if (rel.includes("/changes/archive/")) continue; // history is immutable
|
|
120
|
+
artifacts.push({ file: f, rel, kind: kindFromPath(rel) });
|
|
121
|
+
}
|
|
122
|
+
let repairedFiles = 0;
|
|
123
|
+
for (const a of artifacts) {
|
|
124
|
+
const text = read(a.file);
|
|
125
|
+
const bad = nonConformingHeaders(text, a.kind);
|
|
126
|
+
if (bad.length === 0) continue;
|
|
127
|
+
if (doFix) {
|
|
128
|
+
const { text: fixed, repaired } = repairHeaders(text, a.kind);
|
|
129
|
+
if (repaired > 0 && fixed !== text) {
|
|
130
|
+
write(a.file, fixed, { force: true });
|
|
131
|
+
repairedFiles += 1;
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
for (const h of bad) {
|
|
135
|
+
warnings.push(
|
|
136
|
+
`${a.rel}:${h.line} header "${h.name}" is ${h.wrongStyle ? `in ${h.style} style but a ${a.kind} uses ${h.expectedStyle} style` : "not in canonical form"} — run \`doctrina validate --fix\``,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (doFix && repairedFiles > 0) {
|
|
142
|
+
console.log(c.green("fixed") + ` normalised headers in ${repairedFiles} artifact${repairedFiles === 1 ? "" : "s"}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
90
146
|
// 3. index.json
|
|
91
147
|
let index = null;
|
|
92
148
|
try {
|
|
@@ -574,7 +630,7 @@ export async function run(_positional, flags) {
|
|
|
574
630
|
|
|
575
631
|
// Output
|
|
576
632
|
if (flagBool(flags, "json", false)) {
|
|
577
|
-
|
|
633
|
+
emitJson("validate", { ok: errors.length === 0, errors, warnings });
|
|
578
634
|
return errors.length === 0 ? 0 : 1;
|
|
579
635
|
}
|
|
580
636
|
for (const w of warnings) console.log(c.yellow("warn: ") + w);
|
package/src/commands/verify.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,6 +7,7 @@ import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
|
|
|
6
7
|
import { flagBool, flagString } from "../lib/args.js";
|
|
7
8
|
import { today } from "../lib/dates.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
10
|
+
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
9
11
|
|
|
10
12
|
// The build/verify gate `validate` is not. `validate` checks the shape of
|
|
11
13
|
// the artifact tree; it never runs the project, so a repo that does not
|
|
@@ -29,10 +31,15 @@ const STARTER = {
|
|
|
29
31
|
],
|
|
30
32
|
};
|
|
31
33
|
|
|
34
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
35
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
36
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
37
|
+
export const flags = { boolean: ["json", "clean", "force", "init", "list", "strict"], string: ["signoff"] };
|
|
38
|
+
|
|
32
39
|
export async function run(_positional, flags) {
|
|
33
40
|
const projectRoot = process.cwd();
|
|
34
41
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
35
|
-
throw
|
|
42
|
+
throw notADoctrinaProject();
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
// --clean is the reproducibility lint (review G4): `verify` runs the
|
|
@@ -66,7 +73,10 @@ export async function run(_positional, flags) {
|
|
|
66
73
|
// the false confidence this command exists to prevent. Fail loudly.
|
|
67
74
|
console.error(c.red("error:") + ` no ${CONFIG_REL} — nothing to verify`);
|
|
68
75
|
console.error(c.gray("hint: ") + "scaffold one with `doctrina verify --init`, then declare your typecheck/test/build commands");
|
|
69
|
-
|
|
76
|
+
// PRECONDITION, not a gate failure: the work may be perfect; this
|
|
77
|
+
// project has simply never declared a build gate. An agent reading
|
|
78
|
+
// exit 1 would iterate on the code forever (C7).
|
|
79
|
+
return EXIT.PRECONDITION;
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
let config;
|
package/src/commands/watch.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { watch as fsWatch } from "node:fs";
|
|
4
5
|
import { exists } from "../lib/fs-ops.js";
|
|
5
6
|
import { flagBool } from "../lib/args.js";
|
|
6
7
|
import { c } from "../lib/colors.js";
|
|
8
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
7
9
|
import * as validate from "./validate.js";
|
|
8
10
|
import * as next from "./next.js";
|
|
9
11
|
|
|
@@ -15,11 +17,16 @@ import * as next from "./next.js";
|
|
|
15
17
|
// makes) so it never loops on its own output. `--once` runs a single pass and
|
|
16
18
|
// exits — the testable, scriptable form.
|
|
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", "once"], string: [] };
|
|
24
|
+
|
|
18
25
|
export async function run(_positional, flags) {
|
|
19
26
|
const projectRoot = process.cwd();
|
|
20
27
|
const dot = path.join(projectRoot, ".doctrina");
|
|
21
28
|
if (!exists(dot)) {
|
|
22
|
-
throw
|
|
29
|
+
throw notADoctrinaProject();
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
if (flagBool(flags, "once", false)) {
|
package/src/commands/why.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getSectionParagraph } from "../lib/doc-model.js";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import process from "node:process";
|
|
3
5
|
import { readdirSync } from "node:fs";
|
|
@@ -7,6 +9,7 @@ import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
|
|
|
7
9
|
import * as idx from "../lib/index-json.js";
|
|
8
10
|
import { c } from "../lib/colors.js";
|
|
9
11
|
import { suggest } from "../lib/suggest.js";
|
|
12
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
10
13
|
|
|
11
14
|
// Provenance explainer (review 2026-06-27 passive-user feature #5): answer
|
|
12
15
|
// "why does this capability exist / why was it built this way?" by assembling
|
|
@@ -16,11 +19,16 @@ import { suggest } from "../lib/suggest.js";
|
|
|
16
19
|
// asks; the tool answers from the artifacts, instead of grepping by hand.
|
|
17
20
|
// Read-only. Builds on the Realizes:/trace provenance (ADR 0006/0011).
|
|
18
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
|
+
|
|
19
27
|
export async function run(positional, _flags) {
|
|
20
28
|
const cap = positional[0];
|
|
21
29
|
const projectRoot = process.cwd();
|
|
22
30
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
23
|
-
throw
|
|
31
|
+
throw notADoctrinaProject();
|
|
24
32
|
}
|
|
25
33
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
26
34
|
const known = isDir(specsDir) ? readdirSync(specsDir).filter((e) => isFile(path.join(specsDir, e, "spec.md"))) : [];
|
|
@@ -72,7 +80,7 @@ export async function run(positional, _flags) {
|
|
|
72
80
|
const status = specHeader(text, "Status") ?? "—";
|
|
73
81
|
const impl = specHeader(text, "Implementation") ?? "—";
|
|
74
82
|
console.log(` ${c.gray("status:")} ${status} ${c.gray("implementation:")} ${impl}`);
|
|
75
|
-
const purpose =
|
|
83
|
+
const purpose = getSectionParagraph(text, "Purpose");
|
|
76
84
|
if (purpose) console.log(" " + purpose.replace(/\s+/g, " ").trim());
|
|
77
85
|
|
|
78
86
|
// Dependency graph, both directions (the machine-readable **Depends on:**
|
|
@@ -229,28 +237,6 @@ function productAnchors(projectRoot) {
|
|
|
229
237
|
return out;
|
|
230
238
|
}
|
|
231
239
|
|
|
232
|
-
// First non-comment paragraph under a "## <name>" heading.
|
|
233
|
-
function sectionParagraph(text, name) {
|
|
234
|
-
const lines = text.split(/\r?\n/);
|
|
235
|
-
let inSection = false;
|
|
236
|
-
const buf = [];
|
|
237
|
-
for (const line of lines) {
|
|
238
|
-
if (/^##\s+/.test(line)) {
|
|
239
|
-
if (inSection) break;
|
|
240
|
-
if (new RegExp(`^##\\s+${name}\\b`, "i").test(line)) inSection = true;
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
if (!inSection) continue;
|
|
244
|
-
if (/^\s*<!--/.test(line) || /-->/.test(line)) continue;
|
|
245
|
-
if (line.trim() === "") {
|
|
246
|
-
if (buf.length) break;
|
|
247
|
-
continue;
|
|
248
|
-
}
|
|
249
|
-
buf.push(line.trim());
|
|
250
|
-
}
|
|
251
|
-
return buf.join(" ");
|
|
252
|
-
}
|
|
253
|
-
|
|
254
240
|
// Accepted ADRs whose body names the capability (word-ish boundary).
|
|
255
241
|
function decisionsMentioning(projectRoot, cap) {
|
|
256
242
|
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
package/src/commands/work.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";
|
|
@@ -8,6 +9,7 @@ import * as idx from "../lib/index-json.js";
|
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
9
10
|
import { assessBrief } from "../lib/clarity.js";
|
|
10
11
|
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
12
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
11
13
|
import { changeNew } from "./change.js";
|
|
12
14
|
|
|
13
15
|
// `work` is the second half of the no-ceremony path (ADR 0005): a brief
|
|
@@ -18,11 +20,16 @@ import { changeNew } from "./change.js";
|
|
|
18
20
|
// unproven verification). The CLI's own language processing stops at
|
|
19
21
|
// slugging and term counting; everything semantic is the agent's job.
|
|
20
22
|
|
|
23
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
24
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
25
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
26
|
+
export const flags = { boolean: ["json", "chore", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "id", "resume", "title"] };
|
|
27
|
+
|
|
21
28
|
export async function run(positional, flags) {
|
|
22
29
|
const prompt = positional.join(" ").trim();
|
|
23
30
|
const projectRoot = process.cwd();
|
|
24
31
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
25
|
-
throw
|
|
32
|
+
throw notADoctrinaProject();
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
// --resume <id>: reprint the playbook for an existing open change rather than
|
package/src/index.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { parseArgs } from "./lib/args.js";
|
|
4
5
|
import { c } from "./lib/colors.js";
|
|
5
6
|
import { suggest } from "./lib/suggest.js";
|
|
6
7
|
import { cliVersion } from "./lib/version.js";
|
|
7
|
-
import { surfaceHelp } from "./lib/commands.js";
|
|
8
|
+
import { surfaceHelp, OPERATIONS } from "./lib/commands.js";
|
|
9
|
+
import { GLOBAL_FLAGS } from "./lib/flag-catalog.js";
|
|
10
|
+
import { EXIT, exitCodeHelp } from "./lib/exit-codes.js";
|
|
11
|
+
import { recordUsage } from "./lib/usage.js";
|
|
12
|
+
import { wantsJson, emitJson, captureOutput, stripAnsi } from "./lib/json-out.js";
|
|
8
13
|
|
|
9
14
|
import * as init from "./commands/init.js";
|
|
10
15
|
import * as spec from "./commands/spec.js";
|
|
@@ -41,6 +46,7 @@ import * as report from "./commands/report.js";
|
|
|
41
46
|
import * as completion from "./commands/completion.js";
|
|
42
47
|
import * as intent from "./commands/intent.js";
|
|
43
48
|
import * as upgrade from "./commands/upgrade.js";
|
|
49
|
+
import * as adapter from "./commands/adapter.js";
|
|
44
50
|
|
|
45
51
|
const COMMANDS = {
|
|
46
52
|
init, spec, change, decision, validate, hooks, analyze, clarify,
|
|
@@ -48,7 +54,7 @@ const COMMANDS = {
|
|
|
48
54
|
intake, work, coverage, verify, contract, trace,
|
|
49
55
|
status, close, review, watch, why, constitution,
|
|
50
56
|
prime, handoff, show, doctor, report, completion,
|
|
51
|
-
intent, upgrade,
|
|
57
|
+
intent, upgrade, adapter,
|
|
52
58
|
};
|
|
53
59
|
|
|
54
60
|
const TOP_HELP = `
|
|
@@ -60,6 +66,10 @@ ${surfaceHelp()}
|
|
|
60
66
|
Global flags:
|
|
61
67
|
--help, -h Show this message (or per-command help if after a command)
|
|
62
68
|
--version, -v Print the version
|
|
69
|
+
--debug On an unexpected error, also print the stack trace
|
|
70
|
+
|
|
71
|
+
Exit codes (a contract — an agent reads these to decide what to do next):
|
|
72
|
+
${exitCodeHelp()}
|
|
63
73
|
`;
|
|
64
74
|
|
|
65
75
|
async function main(argv) {
|
|
@@ -69,8 +79,23 @@ async function main(argv) {
|
|
|
69
79
|
// version and exit, looking exactly like the spec's version had been set
|
|
70
80
|
// (0.11.0 field-review papercut). `-v` stays global either way.
|
|
71
81
|
const versionOnly = argv.every((t) => t === "--version" || t === "-v") && argv.length > 0;
|
|
82
|
+
|
|
83
|
+
// Two passes. The first resolves only the command name, using the global
|
|
84
|
+
// flags; the second re-parses with THAT command's declared flags merged in.
|
|
85
|
+
//
|
|
86
|
+
// One global boolean list used to serve every command, and six flags read
|
|
87
|
+
// via flagBool were missing from it. An undeclared flag whose next token
|
|
88
|
+
// does not start with "-" swallows that token as its value, so
|
|
89
|
+
// `change new --chore ajuste-ci "Ajustar CI"` lost the id AND silently
|
|
90
|
+
// ignored the flag, reporting "requires a title" for a quoted title (C3).
|
|
91
|
+
// Declarations now live with the command, so a new command cannot
|
|
92
|
+
// reintroduce the gap by forgetting to edit this file.
|
|
93
|
+
const bootstrap = parseArgs(argv, { boolean: [...GLOBAL_FLAGS.boolean] });
|
|
94
|
+
const commandModule = COMMANDS[bootstrap.positional[0]];
|
|
95
|
+
const spec = commandModule?.flags;
|
|
72
96
|
const { positional, flags } = parseArgs(argv, {
|
|
73
|
-
boolean: [
|
|
97
|
+
boolean: [...GLOBAL_FLAGS.boolean, ...(spec?.boolean ?? [])],
|
|
98
|
+
string: [...GLOBAL_FLAGS.string, ...(spec?.string ?? [])],
|
|
74
99
|
});
|
|
75
100
|
|
|
76
101
|
if (versionOnly || flags.get("v") === true) {
|
|
@@ -93,7 +118,7 @@ async function main(argv) {
|
|
|
93
118
|
} else {
|
|
94
119
|
console.error(c.gray("hint: ") + "try `doctrina --help` for the command surface");
|
|
95
120
|
}
|
|
96
|
-
return
|
|
121
|
+
return EXIT.USAGE;
|
|
97
122
|
}
|
|
98
123
|
|
|
99
124
|
if (flags.get("help") || flags.get("h")) {
|
|
@@ -102,14 +127,43 @@ async function main(argv) {
|
|
|
102
127
|
}
|
|
103
128
|
|
|
104
129
|
try {
|
|
130
|
+
// --json on a command that builds no payload of its own still answers in
|
|
131
|
+
// JSON: its output is captured into a versioned envelope beside `ok` and
|
|
132
|
+
// `exit_code`. Branch on those; the lines are for completeness (M7).
|
|
133
|
+
if (wantsJson(flags) && !command.jsonNative) {
|
|
134
|
+
const { code, stdout, stderr } = await captureOutput(
|
|
135
|
+
() => command.run(positional.slice(1), flags),
|
|
136
|
+
);
|
|
137
|
+
emitJson([commandName, ...positional.slice(1)].join(" "), {
|
|
138
|
+
stdout: stdout.map(stripAnsi),
|
|
139
|
+
stderr: stderr.map(stripAnsi),
|
|
140
|
+
}, { ok: code === EXIT.OK, exitCode: code });
|
|
141
|
+
return code;
|
|
142
|
+
}
|
|
105
143
|
return await command.run(positional.slice(1), flags);
|
|
106
144
|
} catch (err) {
|
|
107
145
|
console.error(c.red("error:") + ` ${err.message}`);
|
|
146
|
+
if (err.remedy) {
|
|
147
|
+
console.error(c.gray("hint: ") + `run ${c.cyan(err.remedy)} first, then retry`);
|
|
148
|
+
}
|
|
108
149
|
if (flags.get("debug") && err.stack) {
|
|
109
150
|
console.error(c.gray(err.stack));
|
|
110
151
|
}
|
|
111
|
-
|
|
152
|
+
// A typed error carries its own class (precondition vs environment).
|
|
153
|
+
// An untyped throw is an unexpected failure of the work itself, which
|
|
154
|
+
// is the GATE class: something is wrong here, fix it and retry (C7).
|
|
155
|
+
return err.exitCode ?? EXIT.GATE;
|
|
112
156
|
}
|
|
113
157
|
}
|
|
114
158
|
|
|
115
|
-
main(process.argv.slice(2)).then((code) =>
|
|
159
|
+
main(process.argv.slice(2)).then((code) => {
|
|
160
|
+
// Record which operation ran, if and only if the operator asked for it
|
|
161
|
+
// by setting DOCTRINA_USAGE_LOG (M8). Off by default, local file only,
|
|
162
|
+
// no arguments captured, and never fatal. See lib/usage.js.
|
|
163
|
+
recordUsage(
|
|
164
|
+
process.argv.slice(2).filter((a) => !a.startsWith("-")),
|
|
165
|
+
code,
|
|
166
|
+
new Set(OPERATIONS.map((o) => o[0])),
|
|
167
|
+
);
|
|
168
|
+
process.exit(code ?? 0);
|
|
169
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, read, relPath, walk } from "./fs-ops.js";
|
|
5
|
+
import { locateTemplatesDir, substitute } from "./templates.js";
|
|
6
|
+
|
|
7
|
+
// Agent adapters: the thin per-agent files that point at AGENTS.md.
|
|
8
|
+
//
|
|
9
|
+
// Installing one used to be reachable ONLY through a full `doctrina init`
|
|
10
|
+
// run, and `init` refuses to run twice — so `init --force` was the only
|
|
11
|
+
// path, and it regenerated AGENTS.md and product.md from blank templates,
|
|
12
|
+
// destroying every hand-authored rule and the entire product definition
|
|
13
|
+
// with no warning (audit item C1, data loss). This module owns adapters as
|
|
14
|
+
// their own concern so adding one can be strictly additive.
|
|
15
|
+
//
|
|
16
|
+
// Three states, which the old surface could not tell apart:
|
|
17
|
+
// installed — the adapter's files are present in the project
|
|
18
|
+
// available — the adapter ships files and none are installed
|
|
19
|
+
// native — the agent reads AGENTS.md directly and needs no file
|
|
20
|
+
// (its template dir carries only a README)
|
|
21
|
+
|
|
22
|
+
export const ADAPTER_STATES = Object.freeze({
|
|
23
|
+
INSTALLED: "installed",
|
|
24
|
+
AVAILABLE: "available",
|
|
25
|
+
NATIVE: "native",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Where a named adapter's templates live. A project-local adapter under
|
|
29
|
+
// `.doctrina/templates/adapters/<name>/` wins over the bundled one, so a
|
|
30
|
+
// team can ship an adapter for an agent Doctrina does not know about.
|
|
31
|
+
export function resolveAdapterDir(projectRoot, name) {
|
|
32
|
+
const local = path.join(projectRoot, ".doctrina", "templates", "adapters", name);
|
|
33
|
+
if (isDir(local) && walk(local).length > 0) return { dir: local, source: "project" };
|
|
34
|
+
const bundledRoot = path.join(locateTemplatesDir(), "adapters");
|
|
35
|
+
const bundled = path.join(bundledRoot, name);
|
|
36
|
+
if (isDir(bundled)) return { dir: bundled, source: "bundled" };
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Every adapter name known to this project: bundled plus project-local.
|
|
41
|
+
export function listAdapterNames(projectRoot) {
|
|
42
|
+
const names = new Set();
|
|
43
|
+
try {
|
|
44
|
+
const bundledRoot = path.join(locateTemplatesDir(), "adapters");
|
|
45
|
+
if (isDir(bundledRoot)) {
|
|
46
|
+
for (const e of readdirSync(bundledRoot)) {
|
|
47
|
+
if (isDir(path.join(bundledRoot, e))) names.add(e);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch { /* unusual install without a templates dir */ }
|
|
51
|
+
const localRoot = path.join(projectRoot, ".doctrina", "templates", "adapters");
|
|
52
|
+
if (isDir(localRoot)) {
|
|
53
|
+
for (const e of readdirSync(localRoot)) {
|
|
54
|
+
if (isDir(path.join(localRoot, e))) names.add(e);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return [...names].sort();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The files a named adapter would write, as project-relative POSIX paths
|
|
61
|
+
// paired with their template source. A README inside the adapter template
|
|
62
|
+
// dir documents the adapter and is never materialised.
|
|
63
|
+
export function adapterFiles(projectRoot, name) {
|
|
64
|
+
const resolved = resolveAdapterDir(projectRoot, name);
|
|
65
|
+
if (!resolved) return null;
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const templatePath of walk(resolved.dir)) {
|
|
68
|
+
const rel = relPath(resolved.dir, templatePath).replace(/\\/g, "/");
|
|
69
|
+
if (rel === "README.md" || rel.endsWith(".gitkeep")) continue;
|
|
70
|
+
out.push({
|
|
71
|
+
templatePath,
|
|
72
|
+
relativePath: rel.replace(/\.template$/, ""),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return { source: resolved.source, files: out.sort((a, b) => a.relativePath.localeCompare(b.relativePath)) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Adapter state plus the files backing it, for `adapter list`.
|
|
79
|
+
export function describeAdapter(projectRoot, name) {
|
|
80
|
+
const spec = adapterFiles(projectRoot, name);
|
|
81
|
+
if (!spec) return { name, state: ADAPTER_STATES.AVAILABLE, source: "unknown", files: [], installed: [] };
|
|
82
|
+
if (spec.files.length === 0) {
|
|
83
|
+
// Reads AGENTS.md natively — "no adapter installed" and "no adapter
|
|
84
|
+
// needed" were indistinguishable before this distinction existed.
|
|
85
|
+
return { name, state: ADAPTER_STATES.NATIVE, source: spec.source, files: [], installed: [] };
|
|
86
|
+
}
|
|
87
|
+
const installed = spec.files.filter((f) => isFile(path.join(projectRoot, f.relativePath)));
|
|
88
|
+
return {
|
|
89
|
+
name,
|
|
90
|
+
source: spec.source,
|
|
91
|
+
files: spec.files,
|
|
92
|
+
installed,
|
|
93
|
+
state: installed.length > 0 ? ADAPTER_STATES.INSTALLED : ADAPTER_STATES.AVAILABLE,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The AGENTS.md path an adapter file should point at, relative to its own
|
|
98
|
+
// location — a nested command file needs "../../AGENTS.md".
|
|
99
|
+
export function agentsMdPathFor(relativePath) {
|
|
100
|
+
const depth = relativePath.split("/").length - 1;
|
|
101
|
+
return depth === 0 ? "AGENTS.md" : "../".repeat(depth) + "AGENTS.md";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Is this adapter file a HUB POINTER — a file whose job is to route the
|
|
105
|
+
// agent at AGENTS.md?
|
|
106
|
+
//
|
|
107
|
+
// Declared by the template itself: a template that uses the
|
|
108
|
+
// {{AGENTS_MD_PATH}} token is saying "I point at the hub". Everything else
|
|
109
|
+
// is a command shim — a Claude/Cursor slash command like `/doctrina-status`
|
|
110
|
+
// whose job is to invoke the CLI, and which reaches the hub through its
|
|
111
|
+
// parent CLAUDE.md rather than by naming it.
|
|
112
|
+
//
|
|
113
|
+
// This distinction is the fix for audit item C2: the pointer check demanded
|
|
114
|
+
// the literal string "AGENTS.md" in EVERY adapter file, so a clean
|
|
115
|
+
// `init --agent claude` failed `templates check` on four command shims that
|
|
116
|
+
// were never pointers, and neither suggested remedy could clear it.
|
|
117
|
+
export function isHubPointer(file) {
|
|
118
|
+
return read(file.templatePath).includes("{{AGENTS_MD_PATH}}");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Render one adapter file's body for this project.
|
|
122
|
+
export function renderAdapterFile(file, tokens) {
|
|
123
|
+
return substitute(read(file.templatePath), {
|
|
124
|
+
...tokens,
|
|
125
|
+
AGENTS_MD_PATH: agentsMdPathFor(file.relativePath),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Is this project-relative path one that some adapter owns? Used by
|
|
130
|
+
// `adapter remove` so it deletes only what an adapter created, and by
|
|
131
|
+
// `init` so it never counts adapter files as user content.
|
|
132
|
+
export function isAdapterOwnedPath(projectRoot, relPathPosix) {
|
|
133
|
+
for (const name of listAdapterNames(projectRoot)) {
|
|
134
|
+
const spec = adapterFiles(projectRoot, name);
|
|
135
|
+
if (spec?.files.some((f) => f.relativePath === relPathPosix)) return true;
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export { exists };
|
package/src/lib/adr-guard.js
CHANGED
package/src/lib/args.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
// Tiny argv parser. Returns { positional: string[], flags: Map<string, string|boolean> }.
|
|
2
3
|
// Supports:
|
|
3
4
|
// --flag -> flags.set("flag", true)
|
|
@@ -82,6 +83,15 @@ export function flagString(flags, name, fallback = undefined) {
|
|
|
82
83
|
return String(v);
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
// True when a value-taking flag was written WITHOUT one — `--budget` at the
|
|
87
|
+
// end of the line, or `--budget -100`, where the parser cannot tell a value
|
|
88
|
+
// from the next flag. `flagString` reports both as absent, so without this
|
|
89
|
+
// a mistyped number silently becomes "use the default", which is the one
|
|
90
|
+
// outcome the author definitely did not ask for.
|
|
91
|
+
export function flagGivenWithoutValue(flags, name) {
|
|
92
|
+
return flags.get(name) === true;
|
|
93
|
+
}
|
|
94
|
+
|
|
85
95
|
export function flagBool(flags, name, fallback = false) {
|
|
86
96
|
const v = flags.get(name);
|
|
87
97
|
if (v === undefined) return fallback;
|
package/src/lib/clarity.js
CHANGED
package/src/lib/colors.js
CHANGED