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/skill.js
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
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
5
|
import { spawnSync } from "node:child_process";
|
|
5
6
|
import { exists, isDir, isFile, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
|
|
6
|
-
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
7
|
+
import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.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
12
|
import { suggest } from "../lib/suggest.js";
|
|
13
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
14
|
|
|
13
15
|
const SUBCOMMANDS = ["new", "list", "sync", "suggest"];
|
|
14
16
|
|
|
17
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
18
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
19
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
20
|
+
export const flags = { boolean: ["json", "force", "write"], string: ["since"] };
|
|
21
|
+
|
|
15
22
|
export async function run(positional, flags) {
|
|
16
23
|
const sub = positional[0];
|
|
17
24
|
if (!SUBCOMMANDS.includes(sub)) {
|
|
@@ -81,6 +88,7 @@ function skillSuggest(args, flags) {
|
|
|
81
88
|
|
|
82
89
|
// Two deterministic sources, deduped by slug. `seen` guards against a lesson
|
|
83
90
|
// appearing twice (e.g. a fix committed and later archived as a change).
|
|
91
|
+
/** @type {SkillCandidate[]} */
|
|
84
92
|
const candidates = [];
|
|
85
93
|
const seen = new Set();
|
|
86
94
|
|
|
@@ -93,6 +101,7 @@ function skillSuggest(args, flags) {
|
|
|
93
101
|
if (!FIX_SHAPED.test(id)) continue;
|
|
94
102
|
const slug = skillSlug(id);
|
|
95
103
|
if (seen.has(slug)) continue;
|
|
104
|
+
/** @type {SkillCandidate} */
|
|
96
105
|
const cand = { id, slug, source: "change", from: name };
|
|
97
106
|
if (captured(cand)) continue; // captured (any slug) or cited by an existing skill
|
|
98
107
|
const proposal = path.join(archiveDir, name, "proposal.md");
|
|
@@ -198,6 +207,7 @@ function gitFixCommits(projectRoot, { since, limit }) {
|
|
|
198
207
|
const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
|
|
199
208
|
if (r.error || r.status !== 0 || !r.stdout) return [];
|
|
200
209
|
|
|
210
|
+
/** @type {SkillCandidate[]} */
|
|
201
211
|
const out = [];
|
|
202
212
|
const seen = new Set();
|
|
203
213
|
for (const record of r.stdout.split("\x1e")) {
|
|
@@ -280,11 +290,11 @@ function skillNew(args, flags) {
|
|
|
280
290
|
return 1;
|
|
281
291
|
}
|
|
282
292
|
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
const body = substitute(read(tplPath), { SKILL_NAME: name });
|
|
293
|
+
const tpl = readTemplate(projectRoot, "skill.md.template");
|
|
294
|
+
const body = substitute(tpl.body, { SKILL_NAME: name });
|
|
286
295
|
write(targetPath, body, { force });
|
|
287
|
-
console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}`
|
|
296
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}` +
|
|
297
|
+
(tpl.source === "project" ? c.gray(" (project template)") : ""));
|
|
288
298
|
|
|
289
299
|
const date = today();
|
|
290
300
|
const index = idx.load(projectRoot);
|
|
@@ -395,7 +405,7 @@ function parseFrontmatter(text, key) {
|
|
|
395
405
|
|
|
396
406
|
function ensureDoctrinaProject(projectRoot) {
|
|
397
407
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
398
|
-
throw
|
|
408
|
+
throw notADoctrinaProject();
|
|
399
409
|
}
|
|
400
410
|
}
|
|
401
411
|
|
package/src/commands/spec.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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
5
|
import { exists, isDir, isFile, lineCount, read, relPath, write } from "../lib/fs-ops.js";
|
|
5
|
-
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
6
|
+
import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
6
7
|
import { specHeader, deriveIndex } from "../lib/scan.js";
|
|
7
8
|
import { setHeader, bumpVersion, setCriterionMark } from "../lib/spec-ops.js";
|
|
8
9
|
import * as idx from "../lib/index-json.js";
|
|
@@ -10,9 +11,15 @@ import { today } from "../lib/dates.js";
|
|
|
10
11
|
import { flagBool, flagString } from "../lib/args.js";
|
|
11
12
|
import { c } from "../lib/colors.js";
|
|
12
13
|
import { suggest } from "../lib/suggest.js";
|
|
14
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
15
|
|
|
14
16
|
const SUBCOMMANDS = ["new", "list", "set"];
|
|
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", "bug", "force"], string: ["bump", "criterion", "implementation", "status", "version"] };
|
|
22
|
+
|
|
16
23
|
export async function run(positional, flags) {
|
|
17
24
|
const sub = positional[0];
|
|
18
25
|
if (!SUBCOMMANDS.includes(sub)) {
|
|
@@ -44,13 +51,13 @@ export async function run(positional, flags) {
|
|
|
44
51
|
return 1;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
|
-
const templatesDir = locateTemplatesDir();
|
|
48
54
|
const tplName = bug ? "spec-bug.md.template" : "spec.md.template";
|
|
49
|
-
const
|
|
55
|
+
const tpl = readTemplate(projectRoot, tplName);
|
|
50
56
|
const date = today();
|
|
51
|
-
const body = substitute(
|
|
57
|
+
const body = substitute(tpl.body, { CAPABILITY: capability, DATE: date });
|
|
52
58
|
write(targetPath, body, { force });
|
|
53
|
-
console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}${bug ? " (bug-spec shape)" : ""}`
|
|
59
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}${bug ? " (bug-spec shape)" : ""}` +
|
|
60
|
+
(tpl.source === "project" ? c.gray(" (project template)") : ""));
|
|
54
61
|
|
|
55
62
|
const index = idx.load(projectRoot);
|
|
56
63
|
// Two axes: a fresh capability scaffold is a draft document for a
|
|
@@ -197,7 +204,7 @@ function specSet(args, flags) {
|
|
|
197
204
|
|
|
198
205
|
function ensureDoctrinaProject(projectRoot) {
|
|
199
206
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
200
|
-
throw
|
|
207
|
+
throw notADoctrinaProject();
|
|
201
208
|
}
|
|
202
209
|
}
|
|
203
210
|
|
package/src/commands/status.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";
|
|
@@ -9,6 +10,8 @@ import { summarize as coverageSummary } from "./coverage.js";
|
|
|
9
10
|
import { summarize as traceSummary } from "./trace.js";
|
|
10
11
|
import { flagBool } from "../lib/args.js";
|
|
11
12
|
import { c } from "../lib/colors.js";
|
|
13
|
+
import { emitJson } from "../lib/json-out.js";
|
|
14
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
15
|
|
|
13
16
|
// One-glance project health (review 2026-06-27 passive-user feature #1): a
|
|
14
17
|
// dashboard that answers "where do things stand?" in a single read, so neither
|
|
@@ -20,16 +23,25 @@ import { c } from "../lib/colors.js";
|
|
|
20
23
|
|
|
21
24
|
const CONFIG_REL = ".doctrina/verify.json";
|
|
22
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: ["json"], string: [] };
|
|
34
|
+
|
|
23
35
|
export async function run(_positional, flags) {
|
|
24
36
|
const projectRoot = process.cwd();
|
|
25
37
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
26
|
-
throw
|
|
38
|
+
throw notADoctrinaProject();
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
const s = collectStatus(projectRoot);
|
|
30
42
|
|
|
31
43
|
if (flagBool(flags, "json", false)) {
|
|
32
|
-
|
|
44
|
+
emitJson("status", s);
|
|
33
45
|
return 0;
|
|
34
46
|
}
|
|
35
47
|
|
|
@@ -1,7 +1,16 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "../lib/fs-ops.js";
|
|
4
|
-
import { locateTemplatesDir } from "../lib/templates.js";
|
|
5
|
+
import { locateTemplatesDir, listResolvedTemplates } from "../lib/templates.js";
|
|
6
|
+
import { adapterFiles, isHubPointer, listAdapterNames } from "../lib/adapters.js";
|
|
7
|
+
import {
|
|
8
|
+
surfaceBlock, findSurfaceBlock, surfaceAnchors, placeSurfaceBlock,
|
|
9
|
+
COMMAND_META, COMMAND_NAMES, SURFACE_LINE_BUDGET, surfaceMarkdown,
|
|
10
|
+
agentChangelogBlock, findAgentChangelogBlock,
|
|
11
|
+
} from "../lib/commands.js";
|
|
12
|
+
import { cliVersion } from "../lib/version.js";
|
|
13
|
+
import { ARTIFACT_CATEGORIES } from "../lib/index-json.js";
|
|
5
14
|
import { flagBool } from "../lib/args.js";
|
|
6
15
|
import { c } from "../lib/colors.js";
|
|
7
16
|
import { suggest } from "../lib/suggest.js";
|
|
@@ -27,6 +36,11 @@ const PRODUCT_SECTIONS = [
|
|
|
27
36
|
];
|
|
28
37
|
const INDEX_FIELDS = ["$schema_version", "project", "artifacts"];
|
|
29
38
|
|
|
39
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
40
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
41
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
42
|
+
export const flags = { boolean: ["json", "write"], string: [] };
|
|
43
|
+
|
|
30
44
|
export async function run(positional, flags) {
|
|
31
45
|
const sub = positional[0];
|
|
32
46
|
if (!SUBCOMMANDS.includes(sub)) {
|
|
@@ -56,11 +70,97 @@ function updateTemplates(flags) {
|
|
|
56
70
|
|
|
57
71
|
const plan = [];
|
|
58
72
|
|
|
73
|
+
// AGENTS.md command-surface block: the one CLI-OWNED span of the file
|
|
74
|
+
// (marker-delimited, generated from the command catalog). Three cases:
|
|
75
|
+
// markers present but stale -> regenerate the span; a legacy hand-written
|
|
76
|
+
// "## Doctrina command surface" section without markers -> replace that
|
|
77
|
+
// section with the managed block (the section was CLI-scaffolded content
|
|
78
|
+
// to begin with — this is the one sanctioned exception to additive-only,
|
|
79
|
+
// see ADR 0015); neither -> append the block. This is what makes
|
|
80
|
+
// `doctrina upgrade --write` actually refresh AGENTS.md when the CLI
|
|
81
|
+
// gains commands, instead of only bumping the stamp.
|
|
82
|
+
const agentsMdPath = path.join(projectRoot, "AGENTS.md");
|
|
83
|
+
if (isFile(agentsMdPath)) {
|
|
84
|
+
const text = read(agentsMdPath);
|
|
85
|
+
const block = findSurfaceBlock(text);
|
|
86
|
+
const fresh = surfaceBlock();
|
|
87
|
+
if (block) {
|
|
88
|
+
if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(fresh)) {
|
|
89
|
+
plan.push({
|
|
90
|
+
describe: ["AGENTS.md: regenerate the doctrina:surface block (stale vs installed CLI)"],
|
|
91
|
+
preview: () => diffPreview(text.slice(block.start, block.end), fresh),
|
|
92
|
+
apply() {
|
|
93
|
+
const cur = read(agentsMdPath);
|
|
94
|
+
const b = findSurfaceBlock(cur);
|
|
95
|
+
if (b) write(agentsMdPath, cur.slice(0, b.start) + fresh + cur.slice(b.end), { force: true });
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
const legacy = findLegacySurfaceSection(text);
|
|
101
|
+
const anchors = templateAnchors();
|
|
102
|
+
plan.push({
|
|
103
|
+
describe: [legacy
|
|
104
|
+
? "AGENTS.md: replace the hand-written \"## Doctrina command surface\" section with the generated doctrina:surface block"
|
|
105
|
+
: `AGENTS.md: insert the generated doctrina:surface command block${anchors.after ? ` after "${anchors.after}"` : ""} (agents discover commands through this file)`],
|
|
106
|
+
preview: () => fresh,
|
|
107
|
+
apply() {
|
|
108
|
+
const cur = read(agentsMdPath);
|
|
109
|
+
const l = findLegacySurfaceSection(cur);
|
|
110
|
+
if (l) {
|
|
111
|
+
// The legacy section already sits where the CLI put it, so
|
|
112
|
+
// replacing it in place preserves the layout.
|
|
113
|
+
write(agentsMdPath, cur.slice(0, l.start) + fresh + "\n\n" + cur.slice(l.end), { force: true });
|
|
114
|
+
} else {
|
|
115
|
+
// Neither block nor legacy section: PLACE it at the canonical
|
|
116
|
+
// position instead of appending, so `upgrade` and `init` produce
|
|
117
|
+
// the same layout (C4).
|
|
118
|
+
write(agentsMdPath, placeSurfaceBlock(cur, fresh, anchors), { force: true });
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The agent-facing changelog (M2): three to six lines stating only what
|
|
126
|
+
// alters agent behaviour in this version. An agent reading AGENTS.md after
|
|
127
|
+
// an upgrade learns what is new without being told to look — reported from
|
|
128
|
+
// real use, where a new command shipped and the driving LLM never knew.
|
|
129
|
+
if (isFile(agentsMdPath)) {
|
|
130
|
+
const cur = read(agentsMdPath);
|
|
131
|
+
const fresh = agentChangelogBlock(cliVersion());
|
|
132
|
+
const existing = findAgentChangelogBlock(cur);
|
|
133
|
+
if (fresh && (!existing || normalizeBlock(cur.slice(existing.start, existing.end)) !== normalizeBlock(fresh))) {
|
|
134
|
+
plan.push({
|
|
135
|
+
describe: [`AGENTS.md: ${existing ? "refresh" : "add"} the "What changed in ${cliVersion()}" block for agents`],
|
|
136
|
+
preview: () => fresh,
|
|
137
|
+
apply() {
|
|
138
|
+
const now = read(agentsMdPath);
|
|
139
|
+
const found = findAgentChangelogBlock(now);
|
|
140
|
+
if (found) {
|
|
141
|
+
write(agentsMdPath, now.slice(0, found.start) + fresh + now.slice(found.end), { force: true });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Place it immediately after the surface block, so "what exists"
|
|
145
|
+
// and "what just changed" are read together.
|
|
146
|
+
const sb = findSurfaceBlock(now);
|
|
147
|
+
if (sb) {
|
|
148
|
+
write(agentsMdPath, now.slice(0, sb.end) + "\n\n" + fresh + now.slice(sb.end), { force: true });
|
|
149
|
+
} else {
|
|
150
|
+
write(agentsMdPath, now.replace(/\s+$/, "") + "\n\n" + fresh + "\n", { force: true });
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
59
157
|
// Markdown files: append stub sections for missing recommended headings.
|
|
60
|
-
|
|
158
|
+
/** @type {Array<[string, string[]]>} */
|
|
159
|
+
const markdownTargets = [
|
|
61
160
|
["AGENTS.md", AGENTS_SECTIONS],
|
|
62
161
|
[path.join(".doctrina", "product.md"), PRODUCT_SECTIONS],
|
|
63
|
-
]
|
|
162
|
+
];
|
|
163
|
+
for (const [rel, sections] of markdownTargets) {
|
|
64
164
|
const filePath = path.join(projectRoot, rel);
|
|
65
165
|
if (!isFile(filePath)) continue;
|
|
66
166
|
const text = read(filePath);
|
|
@@ -89,7 +189,7 @@ function updateTemplates(flags) {
|
|
|
89
189
|
if (idx.$schema_version === undefined) describe.push('index.json: add "$schema_version": "0.1.0"');
|
|
90
190
|
if (idx.project === undefined) describe.push(`index.json: add "project": "${path.basename(projectRoot)}"`);
|
|
91
191
|
if (idx.artifacts === undefined) describe.push('index.json: add empty "artifacts" object');
|
|
92
|
-
const categories =
|
|
192
|
+
const categories = ARTIFACT_CATEGORIES;
|
|
93
193
|
for (const cat of categories) {
|
|
94
194
|
if (idx.artifacts && idx.artifacts[cat] === undefined) {
|
|
95
195
|
describe.push(`index.json: add empty artifact category "${cat}"`);
|
|
@@ -120,8 +220,16 @@ function updateTemplates(flags) {
|
|
|
120
220
|
return 0;
|
|
121
221
|
}
|
|
122
222
|
|
|
123
|
-
for (const
|
|
124
|
-
|
|
223
|
+
for (const p of plan) {
|
|
224
|
+
for (const s of p.describe) {
|
|
225
|
+
console.log((writeMode ? c.green("update ") : c.yellow("would ")) + s);
|
|
226
|
+
}
|
|
227
|
+
// A one-line "would append" told the operator nothing about what was
|
|
228
|
+
// about to land in the file they read first. In preview, show the
|
|
229
|
+
// content and where it goes (C4).
|
|
230
|
+
if (!writeMode && p.preview) {
|
|
231
|
+
for (const line of p.preview().split("\n")) console.log(c.gray(" │ ") + line);
|
|
232
|
+
}
|
|
125
233
|
}
|
|
126
234
|
console.log("");
|
|
127
235
|
if (!writeMode) {
|
|
@@ -135,27 +243,32 @@ function updateTemplates(flags) {
|
|
|
135
243
|
}
|
|
136
244
|
|
|
137
245
|
function listTemplates() {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
246
|
+
// Resolution is a CHAIN (M1): a template under the project's
|
|
247
|
+
// `.doctrina/templates/` wins over the bundled copy, per file. Printing
|
|
248
|
+
// WHERE each one resolved from is what turns the override from a hidden
|
|
249
|
+
// behaviour into a usable one.
|
|
250
|
+
const projectRoot = process.cwd();
|
|
251
|
+
const rows = listResolvedTemplates(isDir(path.join(projectRoot, ".doctrina")) ? projectRoot : null);
|
|
252
|
+
if (rows.length === 0) {
|
|
253
|
+
console.error(c.red("error:") + " no templates found");
|
|
142
254
|
return 1;
|
|
143
255
|
}
|
|
144
|
-
|
|
256
|
+
|
|
257
|
+
console.log(c.bold("Templates resolved for this project:"));
|
|
145
258
|
console.log("");
|
|
146
|
-
|
|
147
|
-
const rows = [];
|
|
148
|
-
for (const f of files) {
|
|
149
|
-
const rel = relPath(dir, f);
|
|
150
|
-
const lines = lineCount(f);
|
|
151
|
-
rows.push({ rel, lines });
|
|
152
|
-
if (rel.length > maxLen) maxLen = rel.length;
|
|
153
|
-
}
|
|
259
|
+
const width = Math.max(...rows.map((r) => r.relativePath.length));
|
|
154
260
|
for (const r of rows) {
|
|
155
|
-
|
|
261
|
+
const source = r.source === "project"
|
|
262
|
+
? c.cyan("project") + (r.overrides ? c.gray(" (overrides bundled)") : "")
|
|
263
|
+
: c.gray("bundled");
|
|
264
|
+
console.log(` ${r.relativePath.padEnd(width + 2)}${String(lineCount(r.path)).padStart(4)} lines ${source}`);
|
|
156
265
|
}
|
|
157
266
|
console.log("");
|
|
158
|
-
|
|
267
|
+
const overridden = rows.filter((r) => r.source === "project").length;
|
|
268
|
+
console.log(c.gray(`${rows.length} templates · ${overridden} from this project's .doctrina/templates/`));
|
|
269
|
+
if (overridden === 0) {
|
|
270
|
+
console.log(c.gray("Drop a file there with the same relative path to override one — see docs/en/templates.md."));
|
|
271
|
+
}
|
|
159
272
|
return 0;
|
|
160
273
|
}
|
|
161
274
|
|
|
@@ -166,7 +279,16 @@ function checkTemplates() {
|
|
|
166
279
|
console.error(c.gray("hint: ") + "run `doctrina init` first");
|
|
167
280
|
return 1;
|
|
168
281
|
}
|
|
282
|
+
const { findings, ok } = collectFindings(projectRoot);
|
|
283
|
+
|
|
284
|
+
return report(findings, ok);
|
|
285
|
+
}
|
|
169
286
|
|
|
287
|
+
// The findings `templates check` reports, as structured records with an
|
|
288
|
+
// executable remedy. Exported so a test can seed each finding, run the
|
|
289
|
+
// remedy it names, and assert the finding clears — a remedy that cannot
|
|
290
|
+
// resolve its own finding is not a remedy (C2).
|
|
291
|
+
export function collectFindings(projectRoot) {
|
|
170
292
|
const findings = [];
|
|
171
293
|
const ok = [];
|
|
172
294
|
|
|
@@ -176,10 +298,19 @@ function checkTemplates() {
|
|
|
176
298
|
const text = read(agentsPath);
|
|
177
299
|
for (const heading of AGENTS_SECTIONS) {
|
|
178
300
|
if (hasHeading(text, heading)) ok.push(`AGENTS.md: ${heading}`);
|
|
179
|
-
else findings.push(`AGENTS.md missing recommended section "${heading}"
|
|
301
|
+
else findings.push({ message: `AGENTS.md missing recommended section "${heading}"`, remedy: "doctrina templates update --write" });
|
|
302
|
+
}
|
|
303
|
+
// Command-surface block: present and current vs the installed catalog.
|
|
304
|
+
const block = findSurfaceBlock(text);
|
|
305
|
+
if (!block) {
|
|
306
|
+
findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina templates update --write" });
|
|
307
|
+
} else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock())) {
|
|
308
|
+
findings.push({ message: "AGENTS.md doctrina:surface block is stale vs the installed CLI", remedy: "doctrina templates update --write" });
|
|
309
|
+
} else {
|
|
310
|
+
ok.push("AGENTS.md: doctrina:surface block current");
|
|
180
311
|
}
|
|
181
312
|
} else {
|
|
182
|
-
findings.push("AGENTS.md missing at project root");
|
|
313
|
+
findings.push({ message: "AGENTS.md missing at project root", remedy: "doctrina init --force" });
|
|
183
314
|
}
|
|
184
315
|
|
|
185
316
|
// product.md sections
|
|
@@ -188,10 +319,58 @@ function checkTemplates() {
|
|
|
188
319
|
const text = read(productPath);
|
|
189
320
|
for (const heading of PRODUCT_SECTIONS) {
|
|
190
321
|
if (hasHeading(text, heading)) ok.push(`product.md: ${heading}`);
|
|
191
|
-
else findings.push(`.doctrina/product.md missing recommended section "${heading}"
|
|
322
|
+
else findings.push({ message: `.doctrina/product.md missing recommended section "${heading}"`, remedy: "doctrina templates update --write" });
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
findings.push({ message: ".doctrina/product.md missing", remedy: "doctrina init --force" });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Installed agent adapters: a HUB POINTER file must still reference
|
|
329
|
+
// AGENTS.md (that is why one `upgrade --write` refresh of the hub reaches
|
|
330
|
+
// every installed agent). Only files whose template declares the
|
|
331
|
+
// {{AGENTS_MD_PATH}} token are pointers; a slash-command shim reaches the
|
|
332
|
+
// hub through its parent pointer file and is not checked (C2).
|
|
333
|
+
for (const name of listAdapterNames(projectRoot)) {
|
|
334
|
+
const spec = adapterFiles(projectRoot, name);
|
|
335
|
+
if (!spec) continue;
|
|
336
|
+
for (const file of spec.files) {
|
|
337
|
+
const installed = path.join(projectRoot, file.relativePath);
|
|
338
|
+
if (!isFile(installed)) continue; // not installed — nothing to check
|
|
339
|
+
if (!isHubPointer(file)) continue; // command shim, not a pointer
|
|
340
|
+
if (read(installed).includes("AGENTS.md")) {
|
|
341
|
+
ok.push(`adapter ${file.relativePath}: points at AGENTS.md`);
|
|
342
|
+
} else {
|
|
343
|
+
findings.push({
|
|
344
|
+
message: `adapter ${file.relativePath} no longer references AGENTS.md — agents loading it will miss the hub`,
|
|
345
|
+
// Executable and verified: `adapter add --force` rewrites exactly
|
|
346
|
+
// this file from its template. A remedy that cannot resolve the
|
|
347
|
+
// finding it is attached to is not a remedy (C2).
|
|
348
|
+
remedy: `doctrina adapter add ${name} --force`,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// M2: a command that cannot state its trigger has not earned a place on
|
|
355
|
+
// the surface. The block is an agent's only discovery surface, so both
|
|
356
|
+
// fields are required and the block has a declared size budget.
|
|
357
|
+
for (const name of COMMAND_NAMES) {
|
|
358
|
+
const meta = COMMAND_META[name];
|
|
359
|
+
if (!meta || !meta.purpose || !meta.when) {
|
|
360
|
+
findings.push({
|
|
361
|
+
message: `command "${name}" declares no ${!meta ? "purpose or when" : (!meta.purpose ? "purpose" : "when")} — the surface block cannot say when to reach for it`,
|
|
362
|
+
remedy: null,
|
|
363
|
+
});
|
|
192
364
|
}
|
|
365
|
+
}
|
|
366
|
+
const surfaceLines = surfaceMarkdown().split("\n").length;
|
|
367
|
+
if (surfaceLines > SURFACE_LINE_BUDGET) {
|
|
368
|
+
findings.push({
|
|
369
|
+
message: `the generated surface block is ${surfaceLines} lines (budget ${SURFACE_LINE_BUDGET}) — cut commands rather than raising the budget`,
|
|
370
|
+
remedy: null,
|
|
371
|
+
});
|
|
193
372
|
} else {
|
|
194
|
-
|
|
373
|
+
ok.push(`surface block within budget (${surfaceLines}/${SURFACE_LINE_BUDGET} lines)`);
|
|
195
374
|
}
|
|
196
375
|
|
|
197
376
|
// index.json schema fields
|
|
@@ -201,21 +380,31 @@ function checkTemplates() {
|
|
|
201
380
|
const idx = JSON.parse(read(indexPath));
|
|
202
381
|
for (const field of INDEX_FIELDS) {
|
|
203
382
|
if (idx[field] !== undefined) ok.push(`index.json: ${field}`);
|
|
204
|
-
else findings.push(`.doctrina/index.json missing field "${field}"
|
|
383
|
+
else findings.push({ message: `.doctrina/index.json missing field "${field}"`, remedy: "doctrina templates update --write" });
|
|
205
384
|
}
|
|
206
385
|
if (idx.$schema_version && idx.$schema_version !== "0.1.0") {
|
|
207
|
-
findings.push(`.doctrina/index.json $schema_version is "${idx.$schema_version}" (expected "0.1.0")
|
|
386
|
+
findings.push({ message: `.doctrina/index.json $schema_version is "${idx.$schema_version}" (expected "0.1.0")`, remedy: null });
|
|
208
387
|
}
|
|
209
388
|
} catch (err) {
|
|
210
|
-
findings.push(`.doctrina/index.json failed to parse: ${err.message}
|
|
389
|
+
findings.push({ message: `.doctrina/index.json failed to parse: ${err.message}`, remedy: null });
|
|
211
390
|
}
|
|
212
391
|
} else {
|
|
213
|
-
findings.push(".doctrina/index.json missing");
|
|
392
|
+
findings.push({ message: ".doctrina/index.json missing", remedy: "doctrina index rebuild" });
|
|
214
393
|
}
|
|
215
394
|
|
|
216
395
|
// Output
|
|
396
|
+
return { findings, ok };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function report(findings, ok) {
|
|
217
400
|
for (const o of ok) console.log(c.green("✓ ") + o);
|
|
218
|
-
for (const f of findings)
|
|
401
|
+
for (const f of findings) {
|
|
402
|
+
console.log(c.yellow("✗ ") + f.message);
|
|
403
|
+
// Every finding names the command that RESOLVES it, or says plainly
|
|
404
|
+
// that repair is manual. A remedy the CLI cannot execute and verify is
|
|
405
|
+
// not a remedy — a test runs each one and asserts the finding clears.
|
|
406
|
+
console.log(c.gray(" fix: ") + (f.remedy ? c.cyan(f.remedy) : c.gray("manual repair — no command can fix this")));
|
|
407
|
+
}
|
|
219
408
|
console.log("");
|
|
220
409
|
|
|
221
410
|
if (findings.length === 0) {
|
|
@@ -223,10 +412,53 @@ function checkTemplates() {
|
|
|
223
412
|
return 0;
|
|
224
413
|
}
|
|
225
414
|
console.log(c.red("fail") + ` ${findings.length} recommendation${findings.length === 1 ? "" : "s"}`);
|
|
226
|
-
console.log(c.gray("hint: ") + "this command is read-only; review and add the missing sections by hand");
|
|
227
415
|
return 1;
|
|
228
416
|
}
|
|
229
417
|
|
|
418
|
+
// Line-level preview of what regenerating the block changes: only the
|
|
419
|
+
// lines that differ, so a stale-catalog refresh reads as the two or three
|
|
420
|
+
// commands that moved rather than as twenty unchanged lines.
|
|
421
|
+
function diffPreview(before, after) {
|
|
422
|
+
const a = before.split("\n");
|
|
423
|
+
const b = after.split("\n");
|
|
424
|
+
const aSet = new Set(a);
|
|
425
|
+
const bSet = new Set(b);
|
|
426
|
+
const out = [
|
|
427
|
+
...a.filter((line) => !bSet.has(line)).map((line) => `- ${line}`),
|
|
428
|
+
...b.filter((line) => !aSet.has(line)).map((line) => `+ ${line}`),
|
|
429
|
+
];
|
|
430
|
+
return out.length > 0 ? out.join("\n") : "(no textual change)";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Whitespace/EOL-insensitive comparison for the surface block, so a CRLF
|
|
434
|
+
// checkout is not eternally "stale".
|
|
435
|
+
|
|
436
|
+
// The canonical position, read from the shipped template — the single
|
|
437
|
+
// place the intended layout is defined.
|
|
438
|
+
function templateAnchors() {
|
|
439
|
+
try {
|
|
440
|
+
return surfaceAnchors(read(path.join(locateTemplatesDir(), "AGENTS.md.template")));
|
|
441
|
+
} catch {
|
|
442
|
+
return { after: null, before: null };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function normalizeBlock(s) {
|
|
447
|
+
return s.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trim();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// A pre-marker AGENTS.md's hand-written surface section: from the
|
|
451
|
+
// "## Doctrina command surface" heading up to (excluding) the next "## "
|
|
452
|
+
// heading. The trailing "Continuous: ..." paragraph the old template put
|
|
453
|
+
// after the bullets belongs to the section body, so it is replaced too.
|
|
454
|
+
function findLegacySurfaceSection(text) {
|
|
455
|
+
const head = text.match(/^##\s+Doctrina command surface.*$/m);
|
|
456
|
+
if (!head) return null;
|
|
457
|
+
const after = text.slice(head.index + head[0].length);
|
|
458
|
+
const next = after.match(/^##\s+/m);
|
|
459
|
+
return { start: head.index, end: next ? head.index + head[0].length + next.index : text.length };
|
|
460
|
+
}
|
|
461
|
+
|
|
230
462
|
function hasHeading(text, heading) {
|
|
231
463
|
// Match an exact h2 heading at line start (case-insensitive for the body
|
|
232
464
|
// after "## ", so "## Vision" matches "## VISION" or "## vision" too).
|
|
@@ -245,15 +477,20 @@ Subcommands:
|
|
|
245
477
|
their relative paths and line counts.
|
|
246
478
|
check Compare the current project's AGENTS.md, product.md,
|
|
247
479
|
and index.json against the recommended sections and
|
|
248
|
-
schema fields shipped in this CLI version
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
480
|
+
schema fields shipped in this CLI version — including
|
|
481
|
+
whether the AGENTS.md doctrina:surface block (the
|
|
482
|
+
CLI-owned, generated command catalog) is present and
|
|
483
|
+
current. Read-only; never modifies any files. Exits 1
|
|
484
|
+
when recommendations exist, 0 otherwise.
|
|
485
|
+
update Fixer for what check reports: appends missing
|
|
486
|
+
recommended sections as stubs, adds missing index.json
|
|
487
|
+
fields, and regenerates the AGENTS.md doctrina:surface
|
|
488
|
+
block from the installed command catalog. Preview by
|
|
489
|
+
default (exits 1 while updates are pending); pass
|
|
490
|
+
--write to apply. Additive-only outside the surface
|
|
491
|
+
block: the marker-delimited span is the one CLI-owned
|
|
492
|
+
region it rewrites (ADR 0015); your content is never
|
|
493
|
+
touched.
|
|
257
494
|
|
|
258
495
|
Distinct from \`doctrina validate\`: validate answers "is this a
|
|
259
496
|
well-formed Doctrina tree?"; templates check answers "does this
|
package/src/commands/trace.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,8 @@ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
|
|
|
5
6
|
import { specHeader } from "../lib/scan.js";
|
|
6
7
|
import { flagBool } from "../lib/args.js";
|
|
7
8
|
import { c } from "../lib/colors.js";
|
|
9
|
+
import { emitJson } from "../lib/json-out.js";
|
|
10
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
8
11
|
|
|
9
12
|
// Intent-provenance report (ADR 0006). `coverage` proves a criterion has a
|
|
10
13
|
// test; `trace` proves a capability traces to a stated intent. Together they
|
|
@@ -26,10 +29,19 @@ import { c } from "../lib/colors.js";
|
|
|
26
29
|
|
|
27
30
|
const ANCHOR_RE = /[A-Z]+\d+/g;
|
|
28
31
|
|
|
32
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
33
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
34
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
35
|
+
// This command builds its own JSON payload; the entrypoint must not
|
|
36
|
+
// wrap it in the generic envelope.
|
|
37
|
+
export const jsonNative = true;
|
|
38
|
+
|
|
39
|
+
export const flags = { boolean: ["json", "strict"], string: [] };
|
|
40
|
+
|
|
29
41
|
export async function run(_positional, flags) {
|
|
30
42
|
const projectRoot = process.cwd();
|
|
31
43
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
32
|
-
throw
|
|
44
|
+
throw notADoctrinaProject();
|
|
33
45
|
}
|
|
34
46
|
const strict = flagBool(flags, "strict", false);
|
|
35
47
|
const json = flagBool(flags, "json", false);
|
|
@@ -43,7 +55,7 @@ export async function run(_positional, flags) {
|
|
|
43
55
|
// The feature is unused: do not nag a project that never opted in.
|
|
44
56
|
if (anchors.length === 0 && !anyRealizes) {
|
|
45
57
|
if (json) {
|
|
46
|
-
|
|
58
|
+
emitJson("trace", { anchors: [], dangling: [], untraceable: [], summary: summarize(projectRoot) });
|
|
47
59
|
return 0;
|
|
48
60
|
}
|
|
49
61
|
console.log(
|
|
@@ -77,7 +89,7 @@ export async function run(_positional, flags) {
|
|
|
77
89
|
if (json) {
|
|
78
90
|
const rows = anchors.map((a) => ({ id: a.id, realizedBy: (realizedBy.get(a.id) ?? []).sort() }));
|
|
79
91
|
const clean = rows.every((r) => r.realizedBy.length > 0) && dangling.length === 0 && untraceable.length === 0;
|
|
80
|
-
|
|
92
|
+
emitJson("trace", { anchors: rows, dangling, untraceable, summary: summarize(projectRoot) });
|
|
81
93
|
return clean ? 0 : strict ? 1 : 0;
|
|
82
94
|
}
|
|
83
95
|
|